From 0fed63eeb33c18a0cc824ae1d956947ec303595a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 3 Aug 2026 15:22:49 +0200 Subject: [PATCH 001/298] :bug: Fix text replacement on selection and text offsets (#10983) * :bug: Fix text not being replaced when there is a selection * :bug: Fix text editor offsets on transformed text --- .../ui/specs/text-editor-v3.spec.js | 57 ++++++ render-wasm/src/globals.rs | 11 ++ render-wasm/src/render/text_editor.rs | 12 +- render-wasm/src/shapes/text.rs | 169 ++++++++++++++---- render-wasm/src/state/text_editor.rs | 6 +- render-wasm/src/wasm/text/helpers.rs | 150 ++++++++++++++-- render-wasm/src/wasm/text_editor.rs | 51 ++++-- 7 files changed, 390 insertions(+), 66 deletions(-) diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 53b439ab19..963d48a9ca 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -129,6 +129,63 @@ test("BUG 10467 - Auto-width text captures every typed character", async ({ await workspace.waitForSelectedShapeName("hello world"); }); +test.describe("BUG 10910 - Text is not replaced when there is a selection", () => { + // Non-ascii on purpose: selection offsets are counted in characters. + test("Typing over a selection replaces it", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Añadir"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.type("nuevo"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("nuevo"); + }); + + test("Typing over a selection that contains emoji replaces it", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Hola 😀"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.type("ok"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); + + test("Backspace deletes the selection", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + await workspace.createAutoWidthTextShape(200, 150, "Añadir texto"); + + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type("ok"); + + await workspace.textEditor.stopEditing(); + + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("ok"); + }); +}); + test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ page, }) => { diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index cd0fcf0bbf..b1de8c9271 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -40,6 +40,17 @@ pub(crate) fn get_render_state() -> &'static mut RenderState { } } +#[inline(always)] +pub(crate) fn current_browser() -> u8 { + unsafe { + if DESIGN_STATE.is_null() { + 0 + } else { + (*DESIGN_STATE).current_browser + } + } +} + #[inline(always)] pub(crate) fn has_render_state() -> bool { unsafe { !RENDER_STATE.is_null() } diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 8ed53bb436..169f4cb735 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -156,12 +156,13 @@ fn calculate_cursor_rect( .map(|span| span.text.chars().count()) .sum(); + // Skia ranges are UTF-16 code units, not characters. let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( - 0..1, + 0..para.char_utf16_len_at(0), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -172,8 +173,10 @@ fn calculate_cursor_rect( (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { + let last_char = para_char_count.saturating_sub(1); + let last_start = para.char_offset_to_utf16(last_char); let rects = laid_out_para.get_rects_for_range( - para_char_count.saturating_sub(1)..para_char_count, + last_start..last_start + para.char_utf16_len_at(last_char), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -189,8 +192,9 @@ fn calculate_cursor_rect( ) } } else { + let utf16_pos = para.char_offset_to_utf16(char_pos); let rects = laid_out_para.get_rects_for_range( - char_pos..char_pos + 1, + utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos), RectHeightStyle::Max, RectWidthStyle::Tight, ); @@ -264,7 +268,7 @@ fn calculate_selection_rects( if range_start < range_end { use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let text_boxes = laid_out_para.get_rects_for_range( - range_start..range_end, + para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end), RectHeightStyle::Max, RectWidthStyle::Tight, ); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f5f58f5a80..d5506850c0 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -653,38 +653,14 @@ impl TextContent { let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { - // Computed position keeps the current position in terms - // of number of characters of text. This is used to know - // in which span we are. - let mut computed_position: usize = 0; - - // If paragraph has no spans, default to span 0, offset 0 - if !paragraph.children().is_empty() { - for span in paragraph.children() { - let length = span.text.chars().count(); - let start_position = computed_position; - let end_position = computed_position + length; - let current_position = position_with_affinity.position as usize; - - // Handle empty spans: if the span is empty and current position - // matches the start, this is the right span - if length == 0 && current_position == start_position { - break; - } - - if start_position <= current_position - && end_position >= current_position - { - break; - } - computed_position += length; - } - } + // Skia reports UTF-16 code units, the model counts characters. + let offset = + paragraph.utf16_offset_to_char(position_with_affinity.position as usize); return Some(TextPositionWithAffinity::new( position_with_affinity, paragraph_index, - position_with_affinity.position as usize, + offset, )); } } @@ -1160,6 +1136,53 @@ impl Paragraph { &mut self.children } + fn char_count(&self) -> usize { + self.children + .iter() + .map(|span| span.text.chars().count()) + .sum() + } + + /// Translate a character offset into the UTF-16 offset Skia indexes by. + /// Both differ on astral-plane characters (emoji) and whenever a text + /// transform changes the length of the laid out text (`ß` -> `SS`). + pub fn char_offset_to_utf16(&self, char_offset: usize) -> usize { + let mut remaining = char_offset; + let mut utf16 = 0; + for span in &self.children { + if remaining == 0 { + break; + } + let span_len = span.text.chars().count(); + let take = remaining.min(span_len); + let prefix: String = span.text.chars().take(take).collect(); + utf16 += span.transform_text(&prefix).encode_utf16().count(); + remaining -= take; + } + utf16 + } + + /// Translate a UTF-16 offset coming from Skia into a character offset. + /// An offset inside a character rounds up, so it never splits a glyph. + pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize { + let (mut low, mut high) = (0, self.char_count()); + while low < high { + let middle = (low + high) / 2; + if self.char_offset_to_utf16(middle) < utf16_offset { + low = middle + 1; + } else { + high = middle; + } + } + low + } + + /// UTF-16 length of the character at `char_offset`, so a caret range covers + /// the whole glyph. + pub fn char_utf16_len_at(&self, char_offset: usize) -> usize { + self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset) + } + pub fn line_height(&self) -> f32 { self.line_height } @@ -1377,9 +1400,8 @@ impl TextSpan { format!("{}", self.font_family) } - pub fn apply_text_transform(&self) -> String { - let browser = crate::with_state!(state, { state.current_browser }); - let text = process_ignored_chars(&self.text, browser); + pub fn transform_text(&self, text: &str) -> String { + let text = process_ignored_chars(text, crate::globals::current_browser()); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), @@ -1388,6 +1410,10 @@ impl TextSpan { } } + pub fn apply_text_transform(&self) -> String { + self.transform_text(&self.text) + } + pub fn scale_content(&mut self, value: f32) { self.font_size *= value; } @@ -1732,4 +1758,85 @@ mod tests { "ab" ); } + + fn test_paragraph(texts: &[&str]) -> Paragraph { + let spans = texts + .iter() + .map(|text| { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ) + }) + .collect(); + + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + spans, + ) + } + + #[test] + fn char_offsets_match_utf16_offsets_for_bmp_text() { + let para = test_paragraph(&["Añadir"]); + for offset in 0..=6 { + assert_eq!(para.char_offset_to_utf16(offset), offset); + assert_eq!(para.utf16_offset_to_char(offset), offset); + } + } + + #[test] + fn char_offsets_account_for_astral_characters() { + let para = test_paragraph(&["a", "😀b"]); + + assert_eq!(para.char_offset_to_utf16(0), 0); + assert_eq!(para.char_offset_to_utf16(1), 1); + assert_eq!(para.char_offset_to_utf16(2), 3); + assert_eq!(para.char_offset_to_utf16(3), 4); + + assert_eq!(para.utf16_offset_to_char(0), 0); + assert_eq!(para.utf16_offset_to_char(1), 1); + assert_eq!(para.utf16_offset_to_char(3), 2); + assert_eq!(para.utf16_offset_to_char(4), 3); + } + + #[test] + fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { + let para = test_paragraph(&["a😀b"]); + assert_eq!(para.utf16_offset_to_char(2), 2); + } + + #[test] + fn char_offsets_account_for_text_transforms() { + // Skia lays out the transformed text, where "Straße" is "STRASSE". + let mut para = test_paragraph(&["Straße"]); + para.children_mut()[0].text_transform = Some(TextTransform::Uppercase); + + assert_eq!(para.char_offset_to_utf16(4), 4); + assert_eq!(para.char_offset_to_utf16(6), 7); + assert_eq!(para.char_utf16_len_at(4), 2); + assert_eq!(para.utf16_offset_to_char(7), 6); + } + + #[test] + fn char_utf16_len_at_covers_the_whole_glyph() { + let para = test_paragraph(&["a😀b"]); + assert_eq!(para.char_utf16_len_at(0), 1); + assert_eq!(para.char_utf16_len_at(1), 2); + assert_eq!(para.char_utf16_len_at(2), 1); + } } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 3e46f77116..ecb792f775 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -447,10 +447,8 @@ impl TextEditorState { let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; - let mut offset = 0; - for span in last_paragraph.children() { - offset += span.text.len(); - } + // Offsets are counted in characters, not bytes. + let offset = text_helpers::paragraph_char_count(last_paragraph); self.extend_selection_from_position(&TextPositionWithAffinity::new( PositionWithAffinity { position: offset as i32, diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index 32ed8238d7..fedfdba215 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -459,11 +459,13 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe let end = selection.end(); let paragraphs = text_content.paragraphs_mut(); - if start.paragraph >= paragraphs.len() { + if paragraphs.is_empty() || start.paragraph >= paragraphs.len() { return; } - if start.paragraph == end.paragraph { + let end_paragraph = end.paragraph.min(paragraphs.len() - 1); + + if start.paragraph == end_paragraph { delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); } else { let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); @@ -473,19 +475,15 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe start_para_len, ); - delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); + delete_range_in_paragraph(&mut paragraphs[end_paragraph], 0, end.offset); - if end.paragraph < paragraphs.len() { - let end_para_children: Vec<_> = - paragraphs[end.paragraph].children_mut().drain(..).collect(); - paragraphs[start.paragraph] - .children_mut() - .extend(end_para_children); - } + let end_para_children: Vec<_> = + paragraphs[end_paragraph].children_mut().drain(..).collect(); + paragraphs[start.paragraph] + .children_mut() + .extend(end_para_children); - if end.paragraph < paragraphs.len() { - paragraphs.drain((start.paragraph + 1)..=end.paragraph); - } + paragraphs.drain((start.paragraph + 1)..=end_paragraph); let children = paragraphs[start.paragraph].children_mut(); let has_content = children.iter().any(|span| !span.text.is_empty()); @@ -499,6 +497,11 @@ pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSe /// Delete a range of characters within a single paragraph. pub fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { + // An out of bounds offset must not skip the deletion. + let para_len = paragraph_char_count(para); + let start_offset = start_offset.min(para_len); + let end_offset = end_offset.min(para_len); + if start_offset >= end_offset { return; } @@ -848,3 +851,124 @@ pub fn split_paragraph_at_cursor( true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::Rect; + use crate::shapes::{FontFamily, FontStyle, GrowType, TextAlign, TextSpan}; + use crate::uuid::Uuid; + + fn span(text: &str) -> TextSpan { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![], + ) + } + + fn paragraph(texts: &[&str]) -> Paragraph { + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + texts.iter().copied().map(span).collect(), + ) + } + + fn content(paragraphs: Vec) -> TextContent { + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 100.0), GrowType::Fixed); + for para in paragraphs { + content.add_paragraph(para); + } + content + } + + fn text_of(content: &TextContent) -> String { + content + .paragraphs() + .iter() + .map(|para| { + para.children() + .iter() + .map(|span| span.text.as_str()) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn selection(start: (usize, usize), end: (usize, usize)) -> TextSelection { + TextSelection { + anchor: TextPositionWithAffinity::new_without_affinity(start.0, start.1), + focus: TextPositionWithAffinity::new_without_affinity(end.0, end.1), + } + } + + #[test] + fn delete_selection_range_deletes_a_whole_multibyte_paragraph() { + let mut content = content(vec![paragraph(&["Añadir"])]); + delete_selection_range(&mut content, &selection((0, 0), (0, 6))); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_range_in_paragraph_clamps_an_overshooting_end_offset() { + let mut para = paragraph(&["Añadir"]); + delete_range_in_paragraph(&mut para, 0, 7); + assert_eq!(para.children().len(), 1); + assert_eq!(para.children()[0].text, ""); + } + + #[test] + fn delete_selection_range_deletes_emoji() { + let mut content = content(vec![paragraph(&["Hi 😀"])]); + delete_selection_range(&mut content, &selection((0, 0), (0, 4))); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_selection_range_never_splits_an_emoji() { + let mut content = content(vec![paragraph(&["a😀b"])]); + delete_selection_range(&mut content, &selection((0, 1), (0, 2))); + assert_eq!(text_of(&content), "ab"); + } + + #[test] + fn delete_selection_range_spanning_several_spans() { + let mut content = content(vec![paragraph(&["Añ", "adir"])]); + delete_selection_range(&mut content, &selection((0, 1), (0, 4))); + assert_eq!(text_of(&content), "Air"); + } + + #[test] + fn delete_selection_range_across_paragraphs() { + let mut content = content(vec![ + paragraph(&["Añadir"]), + paragraph(&["Más"]), + paragraph(&["Fin"]), + ]); + delete_selection_range(&mut content, &selection((0, 0), (2, 3))); + assert_eq!(content.paragraphs().len(), 1); + assert_eq!(text_of(&content), ""); + } + + #[test] + fn delete_selection_range_clamps_an_out_of_range_focus() { + let mut content = content(vec![paragraph(&["Añadir"])]); + delete_selection_range(&mut content, &selection((0, 0), (5, 99))); + assert_eq!(text_of(&content), ""); + } +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index ca54edc39a..af37164af9 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1060,17 +1060,40 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { - if !get_text_editor_state().selection.is_selection() { - return false; - } - let sel = &get_text_editor_state().selection; - unsafe { - *buffer_ptr = sel.anchor.paragraph as u32; - *buffer_ptr.add(1) = sel.anchor.offset as u32; - *buffer_ptr.add(2) = sel.focus.paragraph as u32; - *buffer_ptr.add(3) = sel.focus.offset as u32; - } - true + with_state!(state, { + if !get_text_editor_state().selection.is_selection() { + return false; + } + + let sel = get_text_editor_state().selection; + + // The frontend indexes these offsets into JS strings, which are UTF-16. + let (anchor_offset, focus_offset) = match get_text_editor_state() + .active_shape_id + .and_then(|shape_id| state.shapes.get(&shape_id)) + .map(|shape| &shape.shape_type) + { + Some(Type::Text(text_content)) => { + let paragraphs = text_content.paragraphs(); + let to_utf16 = |position: TextPositionWithAffinity| { + paragraphs + .get(position.paragraph) + .map(|para| para.char_offset_to_utf16(position.offset)) + .unwrap_or(position.offset) + }; + (to_utf16(sel.anchor), to_utf16(sel.focus)) + } + _ => (sel.anchor.offset, sel.focus.offset), + }; + + unsafe { + *buffer_ptr = sel.anchor.paragraph as u32; + *buffer_ptr.add(1) = anchor_offset as u32; + *buffer_ptr.add(2) = sel.focus.paragraph as u32; + *buffer_ptr.add(3) = focus_offset as u32; + } + true + }) } // ============================================================================ @@ -1099,11 +1122,11 @@ fn get_cursor_rect( let mut y_offset = valign_offset; for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { if idx == cursor.paragraph { - let char_pos = cursor.offset; + let utf16_pos = paragraphs[cursor.paragraph].char_offset_to_utf16(cursor.offset); use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let rects = laid_out_para.get_rects_for_range( - char_pos..char_pos, + utf16_pos..utf16_pos, RectHeightStyle::Tight, RectWidthStyle::Tight, ); @@ -1187,7 +1210,7 @@ fn get_selection_rects( if range_start < range_end { use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let text_boxes = laid_out_para.get_rects_for_range( - range_start..range_end, + para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end), RectHeightStyle::Tight, RectWidthStyle::Tight, ); From c6c8a38544b2592e34db43ad6790f1b8d51bb82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 3 Aug 2026 17:11:59 +0200 Subject: [PATCH 002/298] :bug: Fix not being able to add multiple fills to text spans (v3) (#10988) --- .../src/app/main/data/workspace/texts.cljs | 13 ++- frontend/src/app/render_wasm/api.cljs | 7 +- frontend/src/app/render_wasm/text_editor.cljs | 66 +++++++++--- .../text_editor_apply_styles_test.cljs | 102 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 5 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a905523b6..3fe9161d97 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -30,6 +30,7 @@ [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] @@ -699,13 +700,19 @@ (rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options)) (when (features/active-feature? state "text-editor-wasm/v1") - (let [styles ((comp update-node-fn migrate-node)) - result (wasm.api/apply-styles-to-selection styles)] + ;; Transform each span so add-fill preserves its existing fills. + (let [result (wasm.api/apply-styles-to-selection + (comp update-node-fn migrate-node) + {:with-fills? true})] (when result (rx/of (v2-update-text-shape-content (:shape-id result) (:content result) - :update-name? true))))))))) + :update-name? true) + ;; Refresh the panel now, not only after a reselect. + (dwt-v3/v3-update-text-editor-styles + (:shape-id result) + {:fills (:fills result)}))))))))) ptk/EffectEvent (effect [_ state _] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c24bcf0460..aa86d52701 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -703,9 +703,10 @@ (defn apply-styles-to-selection "Apply style attrs to the currently selected text spans. - Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving." - [attrs] - (let [result (text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)] + Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving. + `:with-fills?` also returns the selection's `:fills`." + [styles & [opts]] + (let [result (text-editor/apply-styles-to-selection styles use-shape set-shape-text-content opts)] (request-render "apply-styles-to-selection") result)) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 9686889dab..1794c1ae22 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -623,10 +623,9 @@ {:start-para focus-para :start-offset focus-offset :end-para anchor-para :end-offset anchor-offset})) -(defn- apply-attrs-to-paragraph - "Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph. - Splits spans at boundaries as needed." - [para sel-start sel-end attrs] +(defn apply-attrs-to-paragraph + "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." + [para sel-start sel-end styles] (let [spans (:children para) result (loop [spans spans @@ -645,8 +644,10 @@ (recur (rest spans) span-end (conj acc span)) (let [before (when (> ol-start pos) (assoc span :text (subs text 0 (- ol-start pos)))) - selected (merge span attrs - {:text (subs text (- ol-start pos) (- ol-end pos))}) + selected (-> (if (fn? styles) + (styles span) + (merge span styles)) + (assoc :text (subs text (- ol-start pos) (- ol-end pos)))) after (when (< ol-end span-end) (assoc span :text (subs text (- ol-end pos))))] (recur (rest spans) span-end @@ -658,15 +659,50 @@ [para] (apply + (map (fn [span] (count (:text span))) (:children para)))) +(defn- paragraph-selected-spans + "Return the spans of `para` that overlap the [sel-start, sel-end) char range." + [para sel-start sel-end] + (loop [spans (:children para) + pos 0 + acc []] + (if (empty? spans) + acc + (let [span (first spans) + span-end (+ pos (count (:text span))) + overlap? (< (max pos sel-start) (min span-end sel-end))] + (recur (rest spans) span-end (cond-> acc overlap? (conj span))))))) + +(defn selection-fills + "The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty." + [content {:keys [start-para start-offset end-para end-offset]}] + (let [paragraphs (:children (first (:children content))) + selected (mapcat (fn [idx para] + (cond + (or (< idx start-para) (> idx end-para)) nil + (= start-para end-para) (paragraph-selected-spans para start-offset end-offset) + (= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para)) + (= idx end-para) (paragraph-selected-spans para 0 end-offset) + :else (paragraph-selected-spans para 0 (para-char-count para)))) + (range (count paragraphs)) + paragraphs) + fills-set (into #{} (map :fills) selected)] + (cond + (empty? selected) nil + (= 1 (count fills-set)) (first fills-set) + :else :multiple))) + (defn apply-styles-to-selection - [attrs use-shape-fn set-shape-text-content-fn] + "Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`." + [styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]] (when (wasm/ready?) (let [;; Drop nil-valued attrs so they are never merged onto text spans. ;; The DOM editor path strips these in `attrs->styles`; the WASM merge ;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr ;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded ;; font) would corrupt the span and fail the backend schema. - attrs (into {} (remove (comp nil? val)) attrs) + styles (if (fn? styles) + styles + (into {} (remove (comp nil? val)) styles)) shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] @@ -691,19 +727,19 @@ ;; same paragraph. (= start-para end-para) - (apply-attrs-to-paragraph para start-offset end-offset attrs) + (apply-attrs-to-paragraph para start-offset end-offset styles) ;; first paragraph (= idx start-para) - (apply-attrs-to-paragraph para start-offset (para-char-count para) attrs) + (apply-attrs-to-paragraph para start-offset (para-char-count para) styles) ;; final paragraph (= idx end-para) - (apply-attrs-to-paragraph para 0 end-offset attrs) + (apply-attrs-to-paragraph para 0 end-offset styles) ;; any other paragraph :else - (apply-attrs-to-paragraph para 0 (para-char-count para) attrs))) + (apply-attrs-to-paragraph para 0 (para-char-count para) styles))) (range (count paragraphs)) paragraphs)) @@ -716,5 +752,7 @@ (update-cached-content! shape-id new-content) (use-shape-fn shape-id) (set-shape-text-content-fn shape-id new-content) - {:shape-id shape-id - :content new-content})))))))) + (cond-> {:shape-id shape-id + :content new-content} + with-fills? + (assoc :fills (selection-fills new-content normalized-selection))))))))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs new file mode 100644 index 0000000000..a6756b8951 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs @@ -0,0 +1,102 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.render-wasm.text-editor-apply-styles-test + "Unit tests for applying styles to a selection of text spans. + + `apply-attrs-to-paragraph` splits the affected spans at the selection + boundaries and either merges a map of attrs onto the selected spans or, when + given a function, transforms each selected span. The function form is what + fill operations (add, remove, reorder...) rely on to preserve each span's + existing fills instead of overwriting them." + (:require + [app.render-wasm.text-editor :as text-editor] + [cljs.test :as t :include-macros true])) + +(def ^:private apply-attrs-to-paragraph text-editor/apply-attrs-to-paragraph) + +(defn- span [text fills] + {:text text :fills fills}) + +(def ^:private red {:fill-color "#ff0000" :fill-opacity 1}) +(def ^:private green {:fill-color "#00ff00" :fill-opacity 1}) + +(defn- prepend-fill + "Mirrors the `add-fill` node transform: prepend a fill to the span's fills." + [fill] + (fn [node] (update node :fills #(into [fill] %)))) + +(t/deftest apply-map-attrs + (t/testing "a map of attrs is merged onto the selected span" + (let [para {:children [(span "hello world" [red])]} + result (apply-attrs-to-paragraph para 0 5 {:font-size "20"})] + (t/is (= [(assoc (span "hello" [red]) :font-size "20") + (span " world" [red])] + (:children result)))))) + +(t/deftest apply-fn-preserves-existing-fills + (t/testing "the fn form prepends to the selected span's existing fills" + (let [para {:children [(span "hello world" [red])]} + result (apply-attrs-to-paragraph para 0 5 (prepend-fill green))] + (t/is (= [(span "hello" [green red]) + (span " world" [red])] + (:children result))))) + + (t/testing "each selected span keeps its own fills across multiple spans" + (let [para {:children [(span "foo" [red]) + (span "bar" [green])]} + ;; select the whole paragraph (6 chars) and prepend green + result (apply-attrs-to-paragraph para 0 6 (prepend-fill green))] + (t/is (= [(span "foo" [green red]) + (span "bar" [green green])] + (:children result))))) + + (t/testing "a span outside the selection is left untouched" + (let [para {:children [(span "abcdef" [red])]} + ;; select only "cd" + result (apply-attrs-to-paragraph para 2 4 (prepend-fill green))] + (t/is (= [(span "ab" [red]) + (span "cd" [green red]) + (span "ef" [red])] + (:children result)))))) + +(defn- content [paras] + {:children [{:children paras}]}) + +(defn- para [spans] + {:children spans}) + +(defn- selection [start-para start-offset end-para end-offset] + {:start-para start-para :start-offset start-offset + :end-para end-para :end-offset end-offset}) + +(t/deftest selection-fills + (t/testing "a selection where every span shares the same fills returns that vector" + (let [c (content [(para [(span "hello world" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 0 0 5)))))) + + (t/testing "a selection within a single span returns that span's fills" + (let [c (content [(para [(span "abcdef" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 2 0 4)))))) + + (t/testing "a selection spanning spans with different fills is :multiple" + (let [c (content [(para [(span "foo" [red]) + (span "bar" [green])])])] + (t/is (= :multiple (text-editor/selection-fills c (selection 0 0 0 6)))))) + + (t/testing "a selection restricted to one uniform span is not :multiple" + (let [c (content [(para [(span "foo" [red]) + (span "bar" [green])])])] + (t/is (= [green] (text-editor/selection-fills c (selection 0 3 0 6)))))) + + (t/testing "a selection across paragraphs with the same fills returns that vector" + (let [c (content [(para [(span "foo" [red])]) + (para [(span "bar" [red])])])] + (t/is (= [red] (text-editor/selection-fills c (selection 0 0 1 3)))))) + + (t/testing "a collapsed selection has no selected spans" + (let [c (content [(para [(span "hello" [red])])])] + (t/is (nil? (text-editor/selection-fills c (selection 0 2 0 2))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 21c7d23c06..6d7f09855d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -52,6 +52,7 @@ [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] [frontend-tests.render-wasm.process-objects-test] + [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.svg-fills-test] [frontend-tests.text-editor-paste-guard-test] @@ -143,6 +144,7 @@ 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test + 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.copy-paste-props-test From 0811b1cda6a8d7960452dff0bab1110e4b49386c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 4 Aug 2026 11:55:36 +0200 Subject: [PATCH 003/298] :wrench: Generate the Docker image for the admin console by creating a tag --- .github/workflows/build-develop.yml | 2 +- .github/workflows/build-staging.yml | 2 +- .github/workflows/build-tag.yml | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 2da1ab2a31..b0edcb63cb 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -19,7 +19,7 @@ jobs: with: gh_ref: "develop" - build-admin-console-docker: + build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 2ae5ee13b0..ec3003a4b2 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -19,7 +19,7 @@ jobs: with: gh_ref: "staging" - build-admin-console-docker: + build-docker-admin-console: uses: ./.github/workflows/build-docker-admin-console.yml secrets: inherit with: diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index f488c911a7..18f81d10f1 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -20,10 +20,18 @@ jobs: with: gh_ref: ${{ github.ref_name }} + build-docker-admin-console: + uses: ./.github/workflows/build-docker-admin-console.yml + secrets: inherit + with: + gh_ref: ${{ github.ref_name }} + notify: name: Notifications runs-on: ubuntu-24.04 - needs: build-docker + needs: + - build-docker + - build-docker-admin-console steps: - name: Notify Mattermost uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0 @@ -37,7 +45,9 @@ jobs: publish-final-tag: if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }} - needs: build-docker + needs: + - build-docker + - build-docker-admin-console uses: ./.github/workflows/release.yml secrets: inherit with: From 3865e29b653b1cc36d0ef0bdfc01f1aa48bb961b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 4 Aug 2026 14:15:35 +0200 Subject: [PATCH 004/298] :arrow_up: Update pnpm dependencies across all modules --- backend/package.json | 8 +- backend/pnpm-lock.yaml | 32 +- backend/pnpm-workspace.yaml | 2 + common/package.json | 4 +- common/pnpm-lock.yaml | 20 +- docs/package.json | 4 +- docs/pnpm-lock.yaml | 78 +- docs/pnpm-workspace.yaml | 6 +- exporter/package.json | 4 +- exporter/pnpm-lock.yaml | 39 +- exporter/pnpm-workspace.yaml | 1 + frontend/package.json | 16 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/tokenscript/package.json | 2 +- frontend/packages/ui/package.json | 8 +- frontend/pnpm-lock.yaml | 397 ++-- frontend/pnpm-workspace.yaml | 1 + frontend/text-editor/package.json | 2 +- library/package.json | 2 +- library/pnpm-lock.yaml | 10 +- library/pnpm-workspace.yaml | 2 + mcp/package.json | 2 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/package.json | 2 +- mcp/packages/server/package.json | 10 +- mcp/pnpm-lock.yaml | 368 ++-- mcp/pnpm-workspace.yaml | 8 + package.json | 4 +- .../apps/colors-to-tokens-plugin/package.json | 3 +- .../apps/composable-test-suite/package.json | 55 +- plugins/apps/contrast-plugin/package.json | 3 +- .../apps/create-palette-plugin/package.json | 3 +- plugins/apps/e2e/package.json | 3 +- plugins/apps/example-styles/package.json | 3 +- plugins/apps/icons-plugin/package.json | 3 +- plugins/apps/lorem-ipsum-plugin/package.json | 3 +- .../apps/plugin-api-test-suite/package.json | 5 +- plugins/apps/poc-state-plugin/package.json | 3 +- plugins/apps/poc-tokens-plugin/package.json | 3 +- .../apps/rename-layers-plugin/package.json | 3 +- plugins/apps/table-plugin/package.json | 3 +- plugins/libs/plugin-types/package.json | 3 +- plugins/libs/plugins-runtime/package.json | 3 +- plugins/libs/plugins-styles/package.json | 3 +- plugins/package.json | 20 +- plugins/pnpm-lock.yaml | 1620 ++++++++--------- plugins/pnpm-workspace.yaml | 5 + pnpm-lock.yaml | 8 +- pnpm-workspace.yaml | 1 + render-wasm/package.json | 4 +- render-wasm/pnpm-lock.yaml | 10 +- 52 files changed, 1387 insertions(+), 1421 deletions(-) diff --git a/backend/package.json b/backend/package.json index 9fbce288ab..a49ad4a8a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,19 +4,19 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "dependencies": { - "luxon": "^3.4.4", - "sax": "^1.6.0" + "luxon": "^3.7.2", + "sax": "^1.6.1" }, "devDependencies": { "nodemon": "^3.1.14", "source-map-support": "^0.5.21", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "scripts": { "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index d789e2c2f8..cb48006797 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: luxon: - specifier: ^3.4.4 + specifier: ^3.7.2 version: 3.7.2 sax: - specifier: ^1.6.0 - version: 1.6.0 + specifier: ^1.6.1 + version: 1.6.1 devDependencies: nodemon: specifier: ^3.1.14 @@ -22,8 +22,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.21.1 + version: 8.21.1 packages: @@ -39,9 +39,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -130,8 +130,8 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} semver@7.8.5: @@ -165,8 +165,8 @@ packages: undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -188,7 +188,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -247,7 +247,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -274,7 +274,7 @@ snapshots: dependencies: picomatch: 2.3.2 - sax@1.6.0: {} + sax@1.6.1: {} semver@7.8.5: {} @@ -301,4 +301,4 @@ snapshots: undefsafe@2.0.5: {} - ws@8.21.0: {} + ws@8.21.1: {} diff --git a/backend/pnpm-workspace.yaml b/backend/pnpm-workspace.yaml index e69de29bb2..b3fbd9192b 100644 --- a/backend/pnpm-workspace.yaml +++ b/backend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +minimumReleaseAgeExclude: + - brace-expansion@5.0.8 || 5.0.9 diff --git a/common/package.json b/common/package.json index 09d48ef2c2..c05a294ca5 100644 --- a/common/package.json +++ b/common/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "type": "module", "repository": { "type": "git", @@ -15,7 +15,7 @@ "nodemon": "^3.1.14", "prettier": "3.9.6", "source-map-support": "^0.5.21", - "ws": "^8.21.1" + "ws": "^8.21.2" }, "dependencies": { "date-fns": "^4.4.0" diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index eeb471366f..0f0edb681d 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.2 + version: 8.21.2 packages: @@ -50,9 +50,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -234,8 +234,8 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -273,7 +273,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -357,7 +357,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -439,7 +439,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.1: {} + ws@8.21.2: {} y18n@5.0.8: {} diff --git a/docs/package.json b/docs/package.json index c5286a7d57..9af9a87dba 100644 --- a/docs/package.json +++ b/docs/package.json @@ -35,9 +35,9 @@ "eleventy-plugin-nesting-toc": "^1.3.0", "eleventy-plugin-youtube-embed": "^1.13.2", "luxon": "^3.7.2", - "markdown-it": "^14.3.0", + "markdown-it": "^15.0.0", "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c" + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 2d324a4b2a..c557631422 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -42,11 +42,11 @@ importers: specifier: ^3.7.2 version: 3.7.2 markdown-it: - specifier: ^14.3.0 - version: 14.3.0 + specifier: ^15.0.0 + version: 15.0.0 markdown-it-anchor: specifier: ^9.2.1 - version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0) + version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0) markdown-it-plantuml: specifier: ^1.4.1 version: 1.4.1 @@ -150,6 +150,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.0: + resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -172,8 +175,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -301,6 +304,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + errno@1.0.0: resolution: {integrity: sha512-3zV5mFS1E8/1bPxt/B0xxzI1snsg3uSCIh6Zo1qKg6iMw93hzPANk9oBFzSFBFrwuVoQuE3rLoouAUfwOAj1wQ==} hasBin: true @@ -440,8 +447,8 @@ packages: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true junk@3.1.0: @@ -459,8 +466,11 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - liquidjs@10.27.0: - resolution: {integrity: sha512-tw/OA59K7aIBlMKIrKlumr37fiZUheShVHXY8cVctWisgY1p9mc5hreOvlreoS0wTiwlWk14Ya7305c2a/Cg5w==} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + + liquidjs@10.28.0: + resolution: {integrity: sha512-b6tmBXYMQTuGPnM5vB0CuZMo5kvmKMtSB/gvUWP6RFn2pIB5s+bJkBfIyJnattkc5bgeAiflrZVptx3iaLLioQ==} engines: {node: '>=16'} hasBin: true @@ -487,6 +497,10 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + markdown-it@15.0.0: + resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==} + hasBin: true + mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} @@ -692,8 +706,11 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unpipe@1.0.0: @@ -813,9 +830,9 @@ snapshots: filesize: 10.1.6 gray-matter: 4.0.3 iso-639-1: 3.1.5 - js-yaml: 4.2.0 + js-yaml: 4.3.1 kleur: 4.1.5 - liquidjs: 10.27.0 + liquidjs: 10.28.0 luxon: 3.7.2 markdown-it: 14.3.0 minimist: 1.2.8 @@ -893,6 +910,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.0: {} + asap@2.0.6: {} balanced-match@1.0.2: {} @@ -914,7 +933,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -943,7 +962,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.28.0 + undici: 7.29.0 whatwg-mimetype: 4.0.0 chokidar@3.6.0: @@ -1065,6 +1084,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + errno@1.0.0: dependencies: prr: 1.0.1 @@ -1193,7 +1214,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -1207,7 +1228,11 @@ snapshots: dependencies: uc.micro: 2.1.0 - liquidjs@10.27.0: + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + + liquidjs@10.28.0: dependencies: commander: 10.0.1 @@ -1217,10 +1242,10 @@ snapshots: luxon@3.7.2: {} - markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@14.3.0): + markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0): dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.3.0 + markdown-it: 15.0.0 markdown-it-plantuml@1.4.1: {} @@ -1233,6 +1258,15 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@15.0.0: + dependencies: + argparse: 3.0.0 + entities: 8.0.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + mdurl@2.1.0: {} meta-generator@0.1.5: @@ -1249,7 +1283,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -1410,7 +1444,9 @@ snapshots: uc.micro@2.1.0: {} - undici@7.28.0: {} + uc.micro@3.0.0: {} + + undici@7.29.0: {} unpipe@1.0.0: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml index 79b7d49256..bd2aa47620 100644 --- a/docs/pnpm-workspace.yaml +++ b/docs/pnpm-workspace.yaml @@ -1,3 +1,5 @@ minimumReleaseAgeExclude: - - undici@7.28.0 - - js-yaml@3.15.0 + - undici@7.28.0 || 7.29.0 + - js-yaml@3.15.0 || 4.3.0 + - brace-expansion@1.1.16 || 1.1.17 || 1.1.18 + - liquidjs@10.27.1 diff --git a/exporter/package.json b/exporter/package.json index 5b67de97f1..83518eabee 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -17,7 +17,7 @@ "date-fns": "^4.4.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", - "ioredis": "^5.11.1", + "ioredis": "^6.0.0", "playwright": "1.62.1", "raw-body": "^4.0.0", "source-map-support": "^0.5.21", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 7066611ba6..3764b5fb63 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^2.1.0 version: 2.1.0 ioredis: - specifier: ^5.11.1 - version: 5.11.1 + specifier: ^6.0.0 + version: 6.0.0 playwright: specifier: 1.62.1 version: 1.62.1 @@ -64,8 +64,8 @@ packages: resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} engines: {node: '>=6.9.0'} - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': resolution: {gitHosted: true, integrity: sha512-hG/pgVEWhmHEFMU+evGZkB5kHauff5Zo6ZO+Ro7HY0efsQTJft6svM4isH5jDISeSVrZ1CDGnhWBXuqkztsTWw==, tarball: https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021} @@ -142,9 +142,9 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} @@ -284,9 +284,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} @@ -363,10 +363,6 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -460,7 +456,7 @@ snapshots: dependencies: core-js-pure: 3.49.0 - '@ioredis/commands@1.10.0': {} + '@ioredis/commands@2.0.0': {} '@penpot/svgo@https://codeload.github.com/penpot/svgo/tar.gz/65b3a645df9edbe3c00acf4267dd06c2ca736021': dependencies: @@ -532,7 +528,7 @@ snapshots: boolbase@2.0.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -658,14 +654,13 @@ snapshots: inherits@2.0.4: {} - ioredis@5.11.1: + ioredis@6.0.0: dependencies: - '@ioredis/commands': 1.10.0 + '@ioredis/commands': 2.0.0 cluster-key-slot: 1.1.1 debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 - redis-parser: 3.0.0 standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color @@ -690,7 +685,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} @@ -741,10 +736,6 @@ snapshots: redis-errors@1.2.0: {} - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} diff --git a/exporter/pnpm-workspace.yaml b/exporter/pnpm-workspace.yaml index ef7ae17b58..ac94ff8d53 100644 --- a/exporter/pnpm-workspace.yaml +++ b/exporter/pnpm-workspace.yaml @@ -4,6 +4,7 @@ minimumReleaseAgeExclude: - lodash@4.17.23 || 4.17.24 - playwright-core@1.62.1 - playwright@1.62.1 + - brace-expansion@5.0.7 || 5.0.8 || 5.0.9 overrides: lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.22: ^4.17.23 diff --git a/frontend/package.json b/frontend/package.json index 54063ed48f..e61395d4f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "browserslist": [ "defaults" ], @@ -59,10 +59,10 @@ "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.62.1", - "@storybook/addon-docs": "10.5.5", - "@storybook/addon-themes": "10.5.5", - "@storybook/addon-vitest": "10.5.5", - "@storybook/react-vite": "10.5.5", + "@storybook/addon-docs": "10.5.6", + "@storybook/addon-themes": "10.5.6", + "@storybook/addon-vitest": "10.5.6", + "@storybook/react-vite": "10.5.6", "@tokens-studio/sd-transforms": "2.0.3", "@types/node": "^26.1.2", "@vitest/browser": "4.1.10", @@ -85,7 +85,7 @@ "lodash": "^4.18.1", "lodash.debounce": "^4.0.8", "map-stream": "0.0.7", - "marked": "^18.0.7", + "marked": "^18.0.9", "mkdirp": "^3.0.1", "mustache": "^4.2.0", "nodemon": "^3.1.14", @@ -112,7 +112,7 @@ "sax": "^1.6.1", "scheduler": "^0.27.0", "source-map-support": "^0.5.21", - "storybook": "10.5.5", + "storybook": "10.5.6", "style-dictionary": "5.5.0", "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", @@ -131,6 +131,6 @@ }, "dependencies": { "@penpot/ui": "link:packages/ui", - "react-aria-components": "^1.19.0" + "react-aria-components": "^1.20.0" } } diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index d71c2bf0cf..a4595a5d85 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 4b20059b0f..56b3d9abc2 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index 9c4ed11113..c00cc0082a 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 1fca7c11a8..9fe6715c77 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -20,8 +20,8 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/preset-react": "^8.0.1", - "@storybook/react": "10.5.5", - "@storybook/react-vite": "10.5.5", + "@storybook/react": "10.5.6", + "@storybook/react-vite": "10.5.6", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/react": "^19.2.18", @@ -33,11 +33,11 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "react-compiler-runtime": "^1.0.0", - "storybook": "10.5.5", + "storybook": "10.5.6", "vite-plugin-dts": "^5.0.3" }, "dependencies": { - "react-aria-components": "^1.19.0" + "react-aria-components": "^1.20.0" }, "peerDependencies": { "react": ">=19.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 16975f3248..a8d7873515 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,6 +18,7 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: 1.62.1 + immutable@<4.3.9: ^4.3.9 patchedDependencies: '@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 @@ -30,8 +31,8 @@ importers: specifier: link:packages/ui version: link:packages/ui react-aria-components: - specifier: ^1.19.0 - version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^1.20.0 + version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@penpot/draft-js': specifier: link:packages/draft-js @@ -58,17 +59,17 @@ importers: specifier: 1.62.1 version: 1.62.1 '@storybook/addon-docs': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.6 + version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@storybook/addon-themes': - specifier: 10.5.5 - version: 10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + specifier: 10.5.6 + version: 10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-vitest': - specifier: 10.5.5 - version: 10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10) + specifier: 10.5.6 + version: 10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10) '@storybook/react-vite': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.6 + version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@tokens-studio/sd-transforms': specifier: 2.0.3 version: 2.0.3(style-dictionary@5.5.0(tslib@2.8.1)) @@ -136,8 +137,8 @@ importers: specifier: 0.0.7 version: 0.0.7 marked: - specifier: ^18.0.7 - version: 18.0.7 + specifier: ^18.0.9 + version: 18.0.9 mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -217,8 +218,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 storybook: - specifier: 10.5.5 - version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.6 + version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) style-dictionary: specifier: 5.5.0 version: 5.5.0(tslib@2.8.1) @@ -298,8 +299,8 @@ importers: specifier: '>=19.2' version: 19.2.8 react-aria-components: - specifier: ^1.19.0 - version: 1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^1.20.0 + version: 1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-dom: specifier: '>=19.2' version: 19.2.8(react@19.2.8) @@ -311,11 +312,11 @@ importers: specifier: ^8.0.1 version: 8.0.1(@babel/core@8.0.1) '@storybook/react': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + specifier: 10.5.6 + version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react-vite': - specifier: 10.5.5 - version: 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.6 + version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -350,8 +351,8 @@ importers: specifier: ^1.0.0 version: 1.0.0(react@19.2.8) storybook: - specifier: 10.5.5 - version: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.6 + version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) vite-plugin-dts: specifier: ^5.0.3 version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) @@ -442,6 +443,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -523,6 +528,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.0': resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -584,6 +594,10 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.0': resolution: {integrity: sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -592,6 +606,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.0': resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -960,14 +978,14 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@internationalized/date@3.12.2': - resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} '@internationalized/number@3.6.7': resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} - '@internationalized/string@3.2.9': - resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} @@ -1498,8 +1516,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@react-types/shared@3.36.0': - resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + '@react-types/shared@3.36.1': + resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1870,27 +1888,27 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.5.5': - resolution: {integrity: sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==} + '@storybook/addon-docs@10.5.6': + resolution: {integrity: sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.6 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.5.5': - resolution: {integrity: sha512-ENZCJkvTdGYBRuaE3tEE6jRilMRdGgfYUhnFNEUXAg4II2iVYg9mnrq6tuQfwSVjGuEOTAUen/3YV+l7U4oOOA==} + '@storybook/addon-themes@10.5.6': + resolution: {integrity: sha512-fIzu2f6xPh/mtOBUU9TnjSoO3qUIF57AVES9HX4dfhjtbz9lWd+EhNd65nZfmTXxKsS32x/7/aXDTHtuP/ObwQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.6 - '@storybook/addon-vitest@10.5.5': - resolution: {integrity: sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==} + '@storybook/addon-vitest@10.5.6': + resolution: {integrity: sha512-oxq7Qi4Vujc8Etoi1TZBurMs4RiKoGnvAOCXePOLglXSJMpy95gEb7iu/hvj8E21lV+vVtQYmFvj9Z7gJeMtdg==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.5 + storybook: ^10.5.6 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -1902,18 +1920,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.5.5': - resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} + '@storybook/builder-vite@10.5.6': + resolution: {integrity: sha512-Ts8EohKPj8okDPCkueeKVN+IRGNpI3LuddsFGupqraRvK6aRWawDKA28uc0PlsLCLWbMkMsGVw+IpFXfmoLJgQ==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.5.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.5': - resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} + '@storybook/csf-plugin@10.5.6': + resolution: {integrity: sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.5.5 + storybook: ^10.5.6 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1934,40 +1952,40 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.5': - resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==} + '@storybook/react-dom-shim@10.5.6': + resolution: {integrity: sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.6 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.5': - resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==} + '@storybook/react-vite@10.5.6': + resolution: {integrity: sha512-DCTfNZWhQUH4Zf8LDE4zdLn6+C26QNW0KETdnFUkdrqRADlwS6oExtGWC5f/uP/GDbKb9jrGbC+/ap8nWEH/vQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.6 typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.5.5': - resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==} + '@storybook/react@10.5.6': + resolution: {integrity: sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.5.6 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -2003,8 +2021,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.3': + resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -2404,8 +2422,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolbase@1.0.0: @@ -2415,12 +2433,12 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2644,6 +2662,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3165,8 +3187,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3488,9 +3510,8 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immutable@3.8.3: - resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==} - engines: {node: '>=0.10.0'} + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -3974,8 +3995,8 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} - marked@18.0.7: - resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==} + marked@18.0.9: + resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==} engines: {node: '>= 20'} hasBin: true @@ -4530,14 +4551,14 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-aria-components@1.19.0: - resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==} + react-aria-components@1.20.0: + resolution: {integrity: sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-aria@3.50.0: - resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + react-aria@3.51.0: + resolution: {integrity: sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -4575,8 +4596,8 @@ packages: react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} - react-stately@3.48.0: - resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + react-stately@3.49.0: + resolution: {integrity: sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -5012,8 +5033,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.5.5: - resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==} + storybook@10.5.6: + resolution: {integrity: sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5198,8 +5219,8 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - svgo@2.8.2: - resolution: {integrity: sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==} + svgo@2.8.3: + resolution: {integrity: sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==} engines: {node: '>=10.13.0'} hasBin: true @@ -5342,6 +5363,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5686,6 +5711,18 @@ packages: utf-8-validate: optional: true + ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -5858,6 +5895,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@8.0.0': dependencies: '@babel/parser': 8.0.0 @@ -5958,6 +6003,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/parser@8.0.0': dependencies: '@babel/types': 8.0.0 @@ -6044,6 +6093,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/traverse@7.29.8(supports-color@5.5.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@8.0.0': dependencies: '@babel/code-frame': 8.0.0 @@ -6059,6 +6132,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0': dependencies: '@babel/helper-string-parser': 8.0.0 @@ -6367,7 +6445,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@internationalized/date@3.12.2': + '@internationalized/date@3.12.3': dependencies: '@swc/helpers': 0.5.23 @@ -6375,7 +6453,7 @@ snapshots: dependencies: '@swc/helpers': 0.5.23 - '@internationalized/string@3.2.9': + '@internationalized/string@3.2.10': dependencies: '@swc/helpers': 0.5.23 @@ -6825,7 +6903,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@react-types/shared@3.36.0(react@19.2.8)': + '@react-types/shared@3.36.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -7069,15 +7147,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/addon-docs@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 optionalDependencies: '@types/react': 19.2.18 @@ -7088,16 +7166,16 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.5.5(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/addon-themes@10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)': + '@storybook/addon-vitest@10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) @@ -7106,10 +7184,10 @@ snapshots: transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/builder-vite@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + '@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) transitivePeerDependencies: @@ -7117,9 +7195,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/csf-plugin@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 @@ -7132,28 +7210,28 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/react-dom-shim@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 react-docgen: 8.0.3(supports-color@10.2.2) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) optionalDependencies: @@ -7166,19 +7244,19 @@ snapshots: - supports-color - webpack - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 react-docgen: 8.0.3(supports-color@5.5.0) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) optionalDependencies: @@ -7191,15 +7269,15 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': + '@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-docgen: 8.0.3(supports-color@10.2.2) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -7207,15 +7285,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react@10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': + '@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-docgen: 8.0.3(supports-color@5.5.0) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -7257,7 +7335,7 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -7292,24 +7370,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/chai@5.2.3': dependencies: @@ -7532,7 +7610,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -7540,7 +7618,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7744,17 +7822,17 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.2(supports-color@5.5.0): + body-parser@2.3.0(supports-color@5.5.0): dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -7762,12 +7840,12 @@ snapshots: boolbase@2.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -8001,6 +8079,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -8252,7 +8332,7 @@ snapshots: draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) - immutable: 3.8.3 + immutable: 4.3.9 object-assign: 4.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -8631,7 +8711,7 @@ snapshots: express@5.2.1(supports-color@5.5.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@5.5.0) + body-parser: 2.3.0(supports-color@5.5.0) content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -8681,7 +8761,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastest-levenshtein@1.0.16: {} @@ -9001,7 +9081,7 @@ snapshots: ignore@7.0.6: {} - immutable@3.8.3: {} + immutable@4.3.9: {} immutable@5.1.9: {} @@ -9464,7 +9544,7 @@ snapshots: map-stream@0.0.7: {} - marked@18.0.7: {} + marked@18.0.9: {} math-intrinsics@1.1.0: {} @@ -9526,11 +9606,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -10036,29 +10116,30 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-aria-components@1.19.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-aria-components@1.20.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/date': 3.12.3 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 client-only: 0.0.1 react: 19.2.8 - react-aria: 3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-aria: 3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) - react-stately: 3.48.0(react@19.2.8) + react-stately: 3.49.0(react@19.2.8) - react-aria@3.50.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-aria@3.51.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 + '@internationalized/date': 3.12.3 '@internationalized/number': 3.6.7 - '@internationalized/string': 3.2.9 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 aria-hidden: 1.2.6 clsx: 2.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-stately: 3.48.0(react@19.2.8) + react-stately: 3.49.0(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) react-compiler-runtime@1.0.0(react@19.2.8): @@ -10072,8 +10153,8 @@ snapshots: react-docgen@8.0.3(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -10087,8 +10168,8 @@ snapshots: react-docgen@8.0.3(supports-color@5.5.0): dependencies: '@babel/core': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@5.5.0) + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -10114,12 +10195,12 @@ snapshots: react-lifecycles-compat@3.0.4: {} - react-stately@3.48.0(react@19.2.8): + react-stately@3.49.0(react@19.2.8): dependencies: - '@internationalized/date': 3.12.2 + '@internationalized/date': 3.12.3 '@internationalized/number': 3.6.7 - '@internationalized/string': 3.2.9 - '@react-types/shared': 3.36.0(react@19.2.8) + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.2.8) '@swc/helpers': 0.5.23 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) @@ -10619,13 +10700,13 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.5.5(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): + storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/user-event': 14.6.3(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 @@ -10637,7 +10718,7 @@ snapshots: recast: 0.23.19 semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.8) - ws: 8.21.1 + ws: 8.21.2 optionalDependencies: '@types/react': 19.2.18 prettier: 3.9.6 @@ -10898,7 +10979,7 @@ snapshots: lodash.merge: 4.6.2 mustache: 4.2.0 prettysize: 2.0.0 - svgo: 2.8.2 + svgo: 2.8.3 vinyl: 2.2.1 winston: 3.19.0 xpath: 0.0.34 @@ -10906,7 +10987,7 @@ snapshots: svg-tags@1.0.0: {} - svgo@2.8.2: + svgo@2.8.3: dependencies: commander: 7.2.0 css-select: 4.3.0 @@ -11045,6 +11126,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -11396,6 +11483,8 @@ snapshots: ws@8.21.1: {} + ws@8.21.2: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 2c3a2c026f..6fb26e5399 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -32,3 +32,4 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: "1.62.1" + immutable@<4.3.9: ^4.3.9 diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index 56aa083345..b916b5b12e 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b" + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/library/package.json b/library/package.json index 48fa9671b7..06c0763609 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0-RC1", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "type": "module", "repository": { "type": "git", diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index 63128ed4cc..9d0024fe7e 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -59,9 +59,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -274,7 +274,7 @@ snapshots: binary-extensions@2.3.0: {} - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -358,7 +358,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 ms@2.1.3: {} diff --git a/library/pnpm-workspace.yaml b/library/pnpm-workspace.yaml index bd4e87df02..b5e864413d 100644 --- a/library/pnpm-workspace.yaml +++ b/library/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +minimumReleaseAgeExclude: + - brace-expansion@5.0.8 || 5.0.9 patchedDependencies: '@zip.js/zip.js@2.8.34': patches/@zip.js__zip.js@2.8.11.patch diff --git a/mcp/package.json b/mcp/package.json index 20064d1a38..f40e0e87ce 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,7 +23,7 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "devDependencies": { "concurrently": "^10.0.4", "prettier": "^3.9.6" diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 81e4449b60..3af6239ff4 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index bd8c45e49e..5377e1bba7 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -18,7 +18,7 @@ "devDependencies": { "cross-env": "^10.1.0", "typescript": "^6.0.3", - "vite": "^8.1.5", + "vite": "^8.2.0", "vite-live-preview": "^0.4.0" } } diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 4dd4dd0b6c..05f48fc974 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -24,14 +24,14 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "express": "^5.2.1", - "ioredis": "^5.11.1", - "js-yaml": "^5.2.2", + "ioredis": "^6.0.0", + "js-yaml": "^5.2.3", "nrepl-client": "^0.3.0", "pino": "^10.3.1", "pino-loki": "^3.0.0", @@ -45,12 +45,12 @@ "@penpot/mcp-common": "workspace:../common", "@types/express": "^5.0.6", "@types/js-yaml": "^4.0.9", - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "@types/ws": "^8.18.1", "cross-env": "^10.1.0", "esbuild": "^0.28.1", "ts-node": "^10.9.2", - "tsx": "^4.23.1", + "tsx": "^4.23.5", "typescript": "^6.0.3" }, "ts-node": { diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 9b657ed4a2..4d49e451b3 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@hono/node-server@<2.0.5': ^2.0.5 + importers: .: @@ -37,11 +40,11 @@ importers: specifier: ^6.0.3 version: 6.0.3 vite: - specifier: ^8.1.5 - version: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1) + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1)) + version: 0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5)) packages/server: dependencies: @@ -58,11 +61,11 @@ importers: specifier: ^5.2.1 version: 5.2.1(supports-color@10.2.2) ioredis: - specifier: ^5.11.1 - version: 5.11.1(supports-color@10.2.2) + specifier: ^6.0.0 + version: 6.0.0(supports-color@10.2.2) js-yaml: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.2.3 + version: 5.2.3 nrepl-client: specifier: ^0.3.0 version: 0.3.0 @@ -80,7 +83,7 @@ importers: version: 0.2.2 sharp: specifier: ^0.35.3 - version: 0.35.3(@types/node@26.0.1) + version: 0.35.3(@types/node@26.1.2) ws: specifier: ^8.21.1 version: 8.21.1 @@ -98,8 +101,8 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/node': - specifier: ^26.0.1 - version: 26.0.1 + specifier: ^26.1.2 + version: 26.1.2 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -111,10 +114,10 @@ importers: version: 0.28.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@26.0.1)(typescript@6.0.3) + version: 10.9.2(@types/node@26.1.2)(typescript@6.0.3) tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.5 + version: 4.23.5 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -125,18 +128,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} @@ -296,9 +290,9 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -464,8 +458,8 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@1.10.0': - resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} @@ -487,15 +481,8 @@ packages: '@cfworker/json-schema': optional: true - '@napi-rs/wasm-runtime@1.2.1': - resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} @@ -506,97 +493,92 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -622,9 +604,6 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -643,8 +622,8 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -709,8 +688,8 @@ packages: bencode@2.0.3: resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} bytes@3.1.2: @@ -759,6 +738,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -887,8 +870,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -950,8 +933,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} http-errors@2.0.1: @@ -965,12 +948,12 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.11.1: - resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} - engines: {node: '>=12.22.0'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -990,8 +973,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@5.2.2: - resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} + js-yaml@5.2.3: + resolution: {integrity: sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==} hasBin: true json-schema-traverse@1.0.0: @@ -1106,8 +1089,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1226,10 +1209,6 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -1237,8 +1216,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1382,8 +1361,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} engines: {node: '>=18.0.0'} hasBin: true @@ -1391,6 +1370,10 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1420,13 +1403,13 @@ packages: peerDependencies: vite: '>=5.4.0' - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1529,27 +1512,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': - dependencies: - tslib: 2.8.1 - optional: true - '@epic-web/invariant@1.0.0': {} '@esbuild/aix-ppc64@0.28.1': @@ -1630,9 +1597,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@hono/node-server@1.19.14(hono@4.12.27)': + '@hono/node-server@2.0.12(hono@4.12.34)': dependencies: - hono: 4.12.27 + hono: 4.12.34 '@img/colour@1.1.0': {} @@ -1740,7 +1707,7 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true - '@ioredis/commands@1.10.0': {} + '@ioredis/commands@2.0.0': {} '@jridgewell/resolve-uri@3.1.2': {} @@ -1753,7 +1720,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.27) + '@hono/node-server': 2.0.12(hono@4.12.34) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1763,7 +1730,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1(supports-color@10.2.2) express-rate-limit: 8.5.2(express@5.2.1(supports-color@10.2.2)) - hono: 4.12.27 + hono: 4.12.34 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -1773,14 +1740,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.142.0': {} '@penpot/plugin-styles@1.5.0': {} @@ -1788,53 +1748,46 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.2': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.2': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-openharmony-arm64@1.2.2': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1851,23 +1804,18 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/connect@3.4.38': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1882,7 +1830,7 @@ snapshots: '@types/js-yaml@4.0.9': {} - '@types/node@26.0.1': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -1892,18 +1840,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/validator@13.15.10': {} '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 accepts@2.0.0: dependencies: @@ -1923,7 +1871,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1939,17 +1887,17 @@ snapshots: bencode@2.0.3: {} - body-parser@2.2.2(supports-color@10.2.2): + body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -1998,6 +1946,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2106,12 +2056,12 @@ snapshots: express-rate-limit@8.5.2(express@5.2.1(supports-color@10.2.2)): dependencies: express: 5.2.1(supports-color@10.2.2) - ip-address: 10.2.0 + ip-address: 10.4.0 express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@10.2.2) + body-parser: 2.3.0(supports-color@10.2.2) content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 @@ -2147,7 +2097,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.2: {} + fast-uri@3.1.5: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: @@ -2205,7 +2155,7 @@ snapshots: help-me@5.0.0: {} - hono@4.12.27: {} + hono@4.12.34: {} http-errors@2.0.1: dependencies: @@ -2221,19 +2171,18 @@ snapshots: inherits@2.0.4: {} - ioredis@5.11.1(supports-color@10.2.2): + ioredis@6.0.0(supports-color@10.2.2): dependencies: - '@ioredis/commands': 1.10.0 + '@ioredis/commands': 2.0.0 cluster-key-slot: 1.1.1 debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 redis-errors: 1.2.0 - redis-parser: 3.0.0 standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -2245,7 +2194,7 @@ snapshots: joycon@3.1.1: {} - js-yaml@5.2.2: + js-yaml@5.2.3: dependencies: argparse: 2.0.1 @@ -2322,7 +2271,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.17: {} negotiator@1.0.0: {} @@ -2400,7 +2349,7 @@ snapshots: postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2445,34 +2394,29 @@ snapshots: redis-errors@1.2.0: {} - redis-parser@3.0.0: - dependencies: - redis-errors: 1.2.0 - reflect-metadata@0.2.2: {} require-from-string@2.0.2: {} - rolldown@1.1.5: + rolldown@1.2.2: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 router@2.2.0(supports-color@10.2.2): dependencies: @@ -2523,7 +2467,7 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@26.0.1): + sharp@0.35.3(@types/node@26.1.2): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -2554,7 +2498,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.0.1 + '@types/node': 26.1.2 shebang-command@2.0.0: dependencies: @@ -2635,14 +2579,14 @@ snapshots: tree-kill@1.2.2: {} - ts-node@10.9.2(@types/node@26.0.1)(typescript@6.0.3): + ts-node@10.9.2(@types/node@26.1.2)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 26.0.1 + '@types/node': 26.1.2 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -2655,7 +2599,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.5: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -2667,6 +2611,12 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} undici-types@8.3.0: {} @@ -2679,31 +2629,31 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1)): + vite-live-preview@0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5) ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate - vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(tsx@4.23.1): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.1.5 + rolldown: 1.2.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 - tsx: 4.23.1 + tsx: 4.23.5 which@2.0.2: dependencies: diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 14eaeefa61..2425fd761c 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -12,8 +12,16 @@ linkWorkspacePackages: true minimumReleaseAgeExclude: - qs@6.14.2 || 6.15.2 - path-to-regexp@8.4.0 + - body-parser@2.3.0 + - '@hono/node-server@2.0.5' + - fast-uri@3.1.3 || 3.1.4 || 3.1.5 + - ip-address@10.2.1 || 10.2.2 || 10.3.1 + - hono@4.12.34 packages: - "./packages/common" - "./packages/server" - "./packages/plugin" + +overrides: + '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/package.json b/package.json index 610bdf4419..695a8f2e20 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -19,6 +19,6 @@ "esbuild": "^0.28.1", "mdts": "^0.20.6", "nrepl-client": "^0.3.0", - "playwright": "^1.62.0" + "playwright": "1.62.1" } } diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index 17903d2e95..e0719fca63 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 36573fd34b..9d5446a4ea 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -1,29 +1,30 @@ { - "name": "composable-test-suite", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "start": "vite build --watch", - "init": "pnpm run build && pnpm run start", - "build": "tsc && vite build", - "build:headless": "vite build --config vite.config.headless.ts", - "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", - "preview": "vite preview", - "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", - "types:check": "tsc --noEmit", - "fmt": "prettier --write src ci index.html", - "clean": "rm -rf dist/" - }, - "dependencies": { - "@penpot/plugin-styles": "1.4.1", - "@penpot/plugin-types": "1.4.1" - }, - "devDependencies": { - "playwright": "^1.61.1", - "prettier": "^3.6.2", - "typescript": "^5.8.3", - "vite": "^7.0.8", - "vite-live-preview": "^0.3.2" - } + "name": "composable-test-suite", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "vite build --watch", + "init": "pnpm run build && pnpm run start", + "build": "tsc && vite build", + "build:headless": "vite build --config vite.config.headless.ts", + "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", + "preview": "vite preview", + "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", + "types:check": "tsc --noEmit", + "fmt": "prettier --write src ci index.html", + "clean": "rm -rf dist/" + }, + "dependencies": { + "@penpot/plugin-styles": "1.4.2", + "@penpot/plugin-types": "1.4.2" + }, + "devDependencies": { + "playwright": "^1.62.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vite-live-preview": "^0.4.0" + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index 01ea179e5d..dce3ec209f 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index 919d3035fc..b558d558e5 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -11,5 +11,6 @@ "init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index d6e4bce356..a5b3507429 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -6,5 +6,6 @@ "scripts": { "test": "vitest", "lint": "eslint ." - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 8d88f029cd..0f756858b8 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -10,5 +10,6 @@ "init": "concurrently --kill-others --names build,serve \"pnpm run watch\" \"pnpm run serve\"", "serve": "vite preview", "lint": "eslint ." - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index bed848681c..58e75dc346 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run build:plugin:watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index f4935c7790..bc36b0f6e6 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 305612633c..91c907367d 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -17,6 +17,7 @@ "test:ci:mocked": "pnpm run build:headless && MOCK_BACKEND=1 tsx ci/run-ci.ts" }, "devDependencies": { - "playwright": "^1.61.1" - } + "playwright": "^1.62.1" + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index b658551e41..3cdfff436f 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index 577c40559f..c30a9e92c9 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "exit 0" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index ecd20a38b6..fc6c9ffecc 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 5626e57444..2200694357 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -12,5 +12,6 @@ "init": "concurrently --kill-others --names plugin,serve \"pnpm run watch\" \"pnpm run serve\"", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index f79cbe7d85..86207557f6 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -6,5 +6,6 @@ "scripts": { "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index bd56f9de75..d98d7eeb6b 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -15,5 +15,6 @@ "preview": "vite preview", "lint": "eslint .", "test": "vitest" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 2ba002b6f5..890b9fb454 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -5,5 +5,6 @@ "scripts": { "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" - } + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/package.json b/plugins/package.json index 344909ffb8..81c5b1ae31 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", @@ -32,19 +32,19 @@ }, "private": true, "devDependencies": { - "@angular-devkit/core": "22.1.0", - "@angular-devkit/schematics": "22.1.0", + "@angular-devkit/core": "22.1.2", + "@angular-devkit/schematics": "22.1.2", "@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/template-parser": "22.1.0", - "@angular/build": "22.1.0", - "@angular/cli": "22.1.0", + "@angular/build": "22.1.2", + "@angular/cli": "22.1.2", "@angular/compiler-cli": "22.1.0", "@angular/language-service": "22.1.0", "@eslint/js": "10.0.1", - "@schematics/angular": "22.1.0", + "@schematics/angular": "22.1.2", "@types/feather-icons": "^4.29.4", - "@types/node": "26.0.1", + "@types/node": "26.1.2", "@types/yargs": "^17.0.35", "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", @@ -62,17 +62,17 @@ "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-unused-imports": "^4.4.1", "fs-extra": "^11.4.0", - "globals": "^17.8.0", + "globals": "^17.9.0", "happy-dom": "^20.11.1", "jiti": "2.7.0", "jsdom": "~30.0.1", "jsonc-eslint-parser": "^3.1.0", "prettier": "^3.9.6", - "tsx": "^4.23.1", + "tsx": "^4.23.5", "typedoc": "^0.28.20", "typescript": "6.0.3", "typescript-eslint": "^8.65.0", - "vite": "8.1.5", + "vite": "8.2.0", "vite-plugin-checker": "^0.14.5", "vite-plugin-dts": "5.0.3", "vite-plugin-static-copy": "^4.1.1", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index ec671d53a0..f1f6bdea00 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -6,7 +6,9 @@ settings: overrides: '@babel/core@<=7.29.0': ^7.29.1 + '@hono/node-server@<2.0.5': ^2.0.5 ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0 + brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.23: ^4.17.24 minimatch@>=10.0.0 <10.2.1: ^10.2.1 @@ -36,7 +38,7 @@ importers: version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) axios: specifier: ^1.19.0 - version: 1.19.0 + version: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) feather-icons: specifier: ^4.29.2 version: 4.29.2 @@ -60,26 +62,26 @@ importers: version: 0.16.2 devDependencies: '@angular-devkit/core': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.2 + version: 22.1.2(chokidar@5.0.0) '@angular-devkit/schematics': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.2 + version: 22.1.2(chokidar@5.0.0) '@angular-eslint/eslint-plugin': specifier: 22.1.0 - version: 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/eslint-plugin-template': specifier: 22.1.0 - version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/template-parser': specifier: 22.1.0 - version: 22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular/build': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.0.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.1)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0) + specifier: 22.1.2 + version: 22.1.2(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.5)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0) '@angular/cli': - specifier: 22.1.0 - version: 22.1.0(@types/node@26.0.1)(chokidar@5.0.0) + specifier: 22.1.2 + version: 22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2) '@angular/compiler-cli': specifier: 22.1.0 version: 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) @@ -88,28 +90,28 @@ importers: version: 22.1.0 '@eslint/js': specifier: 10.0.1 - version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) '@schematics/angular': - specifier: 22.1.0 - version: 22.1.0(chokidar@5.0.0) + specifier: 22.1.2 + version: 22.1.2(chokidar@5.0.0) '@types/feather-icons': specifier: ^4.29.4 version: 4.29.4 '@types/node': - specifier: 26.0.1 - version: 26.0.1 + specifier: 26.1.2 + version: 26.1.2 '@types/yargs': specifier: ^17.0.35 version: 17.0.35 '@typescript-eslint/eslint-plugin': specifier: 8.65.0 - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': specifier: 8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/utils': specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) @@ -127,31 +129,31 @@ importers: version: 0.28.1 eslint: specifier: 10.6.0 - version: 10.6.0(jiti@2.7.0) + version: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) eslint-config-prettier: specifier: 10.1.8 - version: 10.1.8(eslint@10.6.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) + version: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@10.6.0(jiti@2.7.0)) + version: 6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@10.6.0(jiti@2.7.0)) + version: 7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react-hooks: specifier: 7.1.1 - version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + version: 7.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) fs-extra: specifier: ^11.4.0 version: 11.4.0 globals: - specifier: ^17.8.0 - version: 17.8.0 + specifier: ^17.9.0 + version: 17.9.0 happy-dom: specifier: ^20.11.1 version: 20.11.1 @@ -168,8 +170,8 @@ importers: specifier: ^3.9.6 version: 3.9.6 tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.5 + version: 4.23.5 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -178,22 +180,22 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) vite-plugin-checker: specifier: ^0.14.5 - version: 0.14.5(eslint@10.6.0(jiti@2.7.0))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 0.14.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) vite-plugin-dts: specifier: 5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) vite-plugin-static-copy: specifier: ^4.1.1 - version: 4.1.1(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) yargs: specifier: ^18.1.0 version: 18.1.0 @@ -203,27 +205,27 @@ importers: apps/composable-test-suite: dependencies: '@penpot/plugin-styles': - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 '@penpot/plugin-types': - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 devDependencies: playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 prettier: - specifier: ^3.6.2 - version: 3.9.4 + specifier: ^3.9.6 + version: 3.9.6 typescript: - specifier: ^5.8.3 + specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.0.8 - version: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + specifier: ^8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) vite-live-preview: - specifier: ^0.3.2 - version: 0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + specifier: ^0.4.0 + version: 0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) apps/contrast-plugin: {} @@ -240,8 +242,8 @@ importers: apps/plugin-api-test-suite: devDependencies: playwright: - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 apps/poc-state-plugin: {} @@ -273,13 +275,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2201.0': - resolution: {integrity: sha512-5/AOHK/9K5vNJngDCKuja223fNVxQUx4kneGBHr5vsh78Tj1iueCxMy3BnTawsZ54djxW+Qy74Q9zzvdYqm/YQ==} + '@angular-devkit/architect@0.2201.2': + resolution: {integrity: sha512-RRG3JA3hPH0ypbDIyquZt9DDTP5pOMPgqQ/iLSkok1MZdKiOgpk6FGfXCa1ei72SwlX7lnJdq94d6WWdqpbyKg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@22.1.0': - resolution: {integrity: sha512-FUwmS33Yc97FCOEpFlA2L8KISrxMRggBLUzfFkrXcxMtvPs/zTjbKtf/9ElZQyW0+a6TzAj/tyZUf0/FsM3bZg==} + '@angular-devkit/core@22.1.2': + resolution: {integrity: sha512-tF1oEE7KPs8I08HJQmH5e4GkLUB3+MXXy8t6gMJULaLFxZYP9K1oXRFLappMpdm9OIbEXOChk23hrho0By9aYg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -287,8 +289,8 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@22.1.0': - resolution: {integrity: sha512-HDF9DvBR7l5tu5Tzkuo7Z4glldV4VsWP61qlbXYgwPcTlVluew6k9P3hUNHSwNozG6nGxLnAcenI3wDsQu2cMQ==} + '@angular-devkit/schematics@22.1.2': + resolution: {integrity: sha512-Lw6NvW5rfMUl/2dsuWY8l6wlfWCuYBzCYSSqqliLPDco0doGzBliHwY9uxuzuUKZgOl5TvuVyvEo0t3o4Jj4GA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/bundled-angular-compiler@22.1.0': @@ -323,8 +325,8 @@ packages: eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/build@22.1.0': - resolution: {integrity: sha512-1AyhEOV+Yc0tB5a0DA0k16H4eO3FHVi1vrIX83Y/C46u0AIedg82zVwi+8jMwOJR/k2FcRnMhAvhDX5ffCTV1A==} + '@angular/build@22.1.2': + resolution: {integrity: sha512-DE/3o17JTel4EBt2BA4DqJYeBBuz5Ef/kf1jL9YZTyJu4SrLr/HI79K14jFr0VRIxzcqG92FdIzfLDxbOesQsg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^22.0.0 @@ -334,7 +336,7 @@ packages: '@angular/platform-browser': ^22.0.0 '@angular/platform-server': ^22.0.0 '@angular/service-worker': ^22.0.0 - '@angular/ssr': ^22.1.0 + '@angular/ssr': ^22.1.2 istanbul-lib-instrument: ^6.0.0 karma: ^6.4.0 less: ^4.2.0 @@ -375,8 +377,8 @@ packages: vitest: optional: true - '@angular/cli@22.1.0': - resolution: {integrity: sha512-0Nf5UNFzF/Uwdc+nylpLNoe7RrRo7VUCYxzC/gixgd+j3Rr0ybZwKL4M6H+blHilSDF5lM6muw8k6CNshQIDfQ==} + '@angular/cli@22.1.2': + resolution: {integrity: sha512-gzB+iuZzB507DAkZb9s5+Jw8QRzOBolUhHEuAKH74xF6oWlEP5JdexfTgti45SjXaKKqeYpODJFnUmSQQJRhxA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true @@ -584,6 +586,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -599,11 +605,6 @@ packages: '@bufbuild/protobuf@2.13.0': resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} - '@commander-js/extra-typings@12.1.0': - resolution: {integrity: sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==} - peerDependencies: - commander: ~12.1.0 - '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -661,6 +662,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@endo/cache-map@1.1.0': resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==} @@ -670,312 +674,156 @@ packages: '@endo/immutable-arraybuffer@1.1.2': resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1036,9 +884,9 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -1448,136 +1296,136 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@napi-rs/wasm-runtime@1.2.1': - resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@oxc-parser/binding-android-arm-eabi@0.140.0': - resolution: {integrity: sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.140.0': - resolution: {integrity: sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==} + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.140.0': - resolution: {integrity: sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==} + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.140.0': - resolution: {integrity: sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==} + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.140.0': - resolution: {integrity: sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==} + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': - resolution: {integrity: sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': - resolution: {integrity: sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==} + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': - resolution: {integrity: sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==} + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.140.0': - resolution: {integrity: sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==} + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': - resolution: {integrity: sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': - resolution: {integrity: sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==} + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': - resolution: {integrity: sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==} + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': - resolution: {integrity: sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==} + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.140.0': - resolution: {integrity: sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==} + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.140.0': - resolution: {integrity: sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==} + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.140.0': - resolution: {integrity: sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==} + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.140.0': - resolution: {integrity: sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==} + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': - resolution: {integrity: sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==} + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': - resolution: {integrity: sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==} + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.140.0': - resolution: {integrity: sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==} + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1588,6 +1436,9 @@ packages: '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -1670,11 +1521,11 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} - '@penpot/plugin-styles@1.4.1': - resolution: {integrity: sha512-6TuJqKQsq1Xmhn2A02R+kCOzIzIdqgFg5z6ncLH2PlAflKIX6aYsGiOF7yFx4RYgCegRVMFPnVis6/hwO+YGQg==} + '@penpot/plugin-styles@1.4.2': + resolution: {integrity: sha512-/Rn6xy80W+mxAi6j5/SOiNY8P0qHMB1WW+j+nyZajecFSnVGPzSWOiMcIDH+Jtsz7Xpmd5AICpgVM0xjechQig==} - '@penpot/plugin-types@1.4.1': - resolution: {integrity: sha512-pHE2B3GI8M5JR03S/NdBoN+z6e1R1IEh3vpFbLG9LN0EZpQE6nEbmCo5jWAWI73Jqlg6CHG/RWVJNmWECnkDTA==} + '@penpot/plugin-types@1.4.2': + resolution: {integrity: sha512-O8wU6RSYE8bIVU7g8cSTYi32ppxs3R13dq7X3Nn9tmDaJjBOKOBpVLuoRPIp3fJC65fv8/7om0sdrtFoL5v19g==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1704,6 +1555,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1716,6 +1573,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1728,6 +1591,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1740,6 +1609,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1752,6 +1627,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1766,6 +1647,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1780,6 +1668,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1794,6 +1689,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1808,6 +1710,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1822,6 +1731,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1836,6 +1752,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1848,6 +1771,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1870,6 +1799,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1882,6 +1817,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2133,10 +2074,16 @@ packages: '@rushstack/ts-command-line@5.1.7': resolution: {integrity: sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==} - '@schematics/angular@22.1.0': - resolution: {integrity: sha512-3nI/qZ75RPadJ0R0YMe6zKZPHYYJXw4U6Ha+HToostCFlIRh52qC+zH0j/pghgyggwMkvRsE9qekKkRUHqPIbw==} + '@schematics/angular@22.1.2': + resolution: {integrity: sha512-52udja/QGSNH5geSnL4JWFOEfx8M7tqf7LNXz8byjki4VshVOkKHTawQcL4YbJfo3MfwwXm4AsoadMOk8EST2w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@seahax/deep-copy@0.1.0': + resolution: {integrity: sha512-Ux88qw9ypPbqszDGwj0JvP8FP017MV3ck7jnkit+0i3mecTkqhYiCUqy3exbxhGFBu07zrUS6yPRUWVct1eSMQ==} + + '@seahax/semaphore@0.5.1': + resolution: {integrity: sha512-q6SXYYbE6X+LDcq2h2yCgE+pCWJumNP3XCZkztdG4S4tiig9akMZGp8TsfU/EIRcHWPdnQ3BA8/NAvdDYdF/NQ==} + '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -2161,18 +2108,12 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/ansi-html@0.0.0': - resolution: {integrity: sha512-PEBpUlteD0VW02udY7UjjgjxHwVXmkdanhmRIMkzatGmORJGjzqKylrXVxz1G5xRTEECMxIkwTHpPmZ9Jb7ANQ==} - '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2209,11 +2150,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2346,6 +2284,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2478,11 +2421,6 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} - ansi-html@0.0.9: - resolution: {integrity: sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==} - engines: {'0': node >= 0.8.0} - hasBin: true - ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2576,8 +2514,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.11: + resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -2599,15 +2537,11 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2708,10 +2642,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2881,8 +2811,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.398: - resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2961,11 +2891,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3160,8 +3085,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3296,8 +3221,8 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - globals@17.8.0: - resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} + globals@17.9.0: + resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3352,8 +3277,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.32: - resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} hosted-git-info@10.1.1: @@ -3424,8 +3349,8 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.3.1: - resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -3595,8 +3520,8 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - jose@6.2.5: - resolution: {integrity: sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4032,14 +3957,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.140.0: - resolution: {integrity: sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} engines: {node: ^20.19.0 || >=22.12.0} - p-defer@4.0.1: - resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} - engines: {node: '>=12'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4126,14 +4047,14 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true possible-typed-array-names@1.1.0: @@ -4149,10 +4070,6 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -4161,11 +4078,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} - engines: {node: '>=14'} - hasBin: true - prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -4294,6 +4206,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4789,8 +4706,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} engines: {node: '>=18.0.0'} hasBin: true @@ -4933,11 +4850,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-live-preview@0.3.2: - resolution: {integrity: sha512-NrmGaAc85qvkx/+6FluiTo9rLnoY+/NOYnuUvcW5Yb5tSJzUxuloXYrCSS1dtxQB9YKUbpQ95JCb0GRuF//JEQ==} + vite-live-preview@0.4.0: + resolution: {integrity: sha512-Qz8kr0kixXwnQl+zLPZX66OjajN4jnVnDwhNToJsO6TTboUtBo8pEmRuc0iBmkwW9lXR8mOeMu+QtxFkXBcHYg==} hasBin: true peerDependencies: - vite: '>=5.2.13' + vite: '>=5.4.0' vite-plugin-checker@0.14.5: resolution: {integrity: sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA==} @@ -4990,15 +4907,16 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - vite@7.3.5: - resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -5009,12 +4927,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -5030,13 +4950,13 @@ packages: yaml: optional: true - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5302,14 +5222,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2201.0(chokidar@5.0.0)': + '@angular-devkit/architect@0.2201.2(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.2(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@22.1.0(chokidar@5.0.0)': + '@angular-devkit/core@22.1.2(chokidar@5.0.0)': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -5320,9 +5240,9 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@22.1.0(chokidar@5.0.0)': + '@angular-devkit/schematics@22.1.2(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.2(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 1.0.0 ora: 9.4.1 @@ -5332,61 +5252,61 @@ snapshots: '@angular-eslint/bundled-angular-compiler@22.1.0': {} - '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/template-parser': 22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@angular-eslint/template-parser': 22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) aria-query: 5.3.2 axobject-query: 4.1.0 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - '@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) eslint-scope: 9.1.2 typescript: 6.0.3 - '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular/build@22.1.0(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.0.1)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.1)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0)': + '@angular/build@22.1.2(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.5)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2201.0(chokidar@5.0.0) + '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) '@angular/compiler': 22.1.0 '@angular/compiler-cli': 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 6.1.1(@types/node@26.0.1) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + '@inquirer/confirm': 6.1.1(@types/node@26.1.2) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) beasties: 0.4.3 browserslist: 4.28.7 esbuild: 0.28.1 - https-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0(supports-color@10.2.2) jsonc-parser: 3.3.1 listr2: 10.2.2 magic-string: 1.0.0 mrmime: 2.0.1 - oxc-parser: 0.140.0 + oxc-parser: 0.142.0 parse5-html-rewriting-stream: 8.0.1 picomatch: 4.0.5 piscina: 5.2.0 @@ -5397,17 +5317,17 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) less: 4.6.4 lmdb: 3.5.6 postcss: 8.5.25 rollup: 4.60.4 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -5422,15 +5342,15 @@ snapshots: - tsx - yaml - '@angular/cli@22.1.0(@types/node@26.0.1)(chokidar@5.0.0)': + '@angular/cli@22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2)': dependencies: - '@angular-devkit/architect': 0.2201.0(chokidar@5.0.0) - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.0(chokidar@5.0.0) - '@inquirer/prompts': 8.5.2(@types/node@26.0.1) - '@listr2/prompt-adapter-inquirer': 4.2.4(@inquirer/prompts@8.5.2(@types/node@26.0.1))(@types/node@26.0.1)(listr2@10.2.2) - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) - '@schematics/angular': 22.1.0(chokidar@5.0.0) + '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) + '@angular-devkit/core': 22.1.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.2(chokidar@5.0.0) + '@inquirer/prompts': 8.5.2(@types/node@26.1.2) + '@listr2/prompt-adapter-inquirer': 4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) + '@schematics/angular': 22.1.2(chokidar@5.0.0) jsonc-parser: 3.3.1 listr2: 10.2.2 npm-package-arg: 14.0.0 @@ -5532,20 +5452,20 @@ snapshots: '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5612,25 +5532,25 @@ snapshots: '@babel/helper-globals@8.0.0': {} - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color '@babel/helper-split-export-declaration@7.24.7': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/helper-string-parser@7.29.7': {} @@ -5674,7 +5594,7 @@ snapshots: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -5682,7 +5602,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5701,6 +5621,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.4': dependencies: '@babel/helper-string-parser': 8.0.0 @@ -5715,10 +5640,6 @@ snapshots: '@bufbuild/protobuf@2.13.0': optional: true - '@commander-js/extra-typings@12.1.0(commander@12.1.0)': - dependencies: - commander: 12.1.0 - '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -5757,6 +5678,7 @@ snapshots: '@emnapi/core@1.11.3': dependencies: + '@emnapi/wasi-threads': 1.2.3 tslib: 2.8.1 optional: true @@ -5780,179 +5702,106 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@endo/cache-map@1.1.0': {} '@endo/env-options@1.1.11': {} '@endo/immutable-arraybuffer@1.1.2': {} - '@esbuild/aix-ppc64@0.27.2': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.2': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.2': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.2': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.2': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.2': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.2': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.2': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.2': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.2': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.2': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.2': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.2': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.2': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.2': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.2': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.2': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.2': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.2': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.2': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.2': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.2': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.2': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.2': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.2': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.2': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -5965,9 +5814,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))': optionalDependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -5989,9 +5838,9 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true - '@hono/node-server@1.19.17(hono@4.12.32)': + '@hono/node-server@2.0.12(hono@4.12.34)': dependencies: - hono: 4.12.32 + hono: 4.12.34 '@humanfs/core@0.19.2': dependencies: @@ -6011,122 +5860,122 @@ snapshots: '@inquirer/ansi@2.0.7': {} - '@inquirer/checkbox@5.2.1(@types/node@26.0.1)': + '@inquirer/checkbox@5.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/confirm@6.1.1(@types/node@26.0.1)': + '@inquirer/confirm@6.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/core@11.2.1(@types/node@26.0.1)': + '@inquirer/core@11.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/editor@5.2.2(@types/node@26.0.1)': + '@inquirer/editor@5.2.2(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/external-editor': 3.0.3(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/external-editor': 3.0.3(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/expand@5.1.1(@types/node@26.0.1)': + '@inquirer/expand@5.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/external-editor@3.0.3(@types/node@26.0.1)': + '@inquirer/external-editor@3.0.3(@types/node@26.1.2)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@inquirer/figures@2.0.7': {} - '@inquirer/input@5.1.2(@types/node@26.0.1)': + '@inquirer/input@5.1.2(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/number@4.1.1(@types/node@26.0.1)': + '@inquirer/number@4.1.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/password@5.1.1(@types/node@26.0.1)': + '@inquirer/password@5.1.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/prompts@8.5.2(@types/node@26.0.1)': + '@inquirer/prompts@8.5.2(@types/node@26.1.2)': dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@26.0.1) - '@inquirer/confirm': 6.1.1(@types/node@26.0.1) - '@inquirer/editor': 5.2.2(@types/node@26.0.1) - '@inquirer/expand': 5.1.1(@types/node@26.0.1) - '@inquirer/input': 5.1.2(@types/node@26.0.1) - '@inquirer/number': 4.1.1(@types/node@26.0.1) - '@inquirer/password': 5.1.1(@types/node@26.0.1) - '@inquirer/rawlist': 5.3.1(@types/node@26.0.1) - '@inquirer/search': 4.2.1(@types/node@26.0.1) - '@inquirer/select': 5.2.1(@types/node@26.0.1) + '@inquirer/checkbox': 5.2.1(@types/node@26.1.2) + '@inquirer/confirm': 6.1.1(@types/node@26.1.2) + '@inquirer/editor': 5.2.2(@types/node@26.1.2) + '@inquirer/expand': 5.1.1(@types/node@26.1.2) + '@inquirer/input': 5.1.2(@types/node@26.1.2) + '@inquirer/number': 4.1.1(@types/node@26.1.2) + '@inquirer/password': 5.1.1(@types/node@26.1.2) + '@inquirer/rawlist': 5.3.1(@types/node@26.1.2) + '@inquirer/search': 4.2.1(@types/node@26.1.2) + '@inquirer/select': 5.2.1(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/rawlist@5.3.1(@types/node@26.0.1)': + '@inquirer/rawlist@5.3.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/search@4.2.1(@types/node@26.0.1)': + '@inquirer/search@4.2.1(@types/node@26.1.2)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/select@5.2.1(@types/node@26.0.1)': + '@inquirer/select@5.2.1(@types/node@26.1.2)': dependencies: '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.0.1) + '@inquirer/core': 11.2.1(@types/node@26.1.2) '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/type': 4.0.7(@types/node@26.1.2) optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 - '@inquirer/type@4.0.7(@types/node@26.0.1)': + '@inquirer/type@4.0.7(@types/node@26.1.2)': optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@istanbuljs/schema@0.1.6': optional: true @@ -6156,10 +6005,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.0.1))(@types/node@26.0.1)(listr2@10.2.2)': + '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2)': dependencies: - '@inquirer/prompts': 8.5.2(@types/node@26.0.1) - '@inquirer/type': 4.0.7(@types/node@26.0.1) + '@inquirer/prompts': 8.5.2(@types/node@26.1.2) + '@inquirer/type': 4.0.7(@types/node@26.1.2) listr2: 10.2.2 transitivePeerDependencies: - '@types/node' @@ -6185,24 +6034,24 @@ snapshots: '@lmdb/lmdb-win32-x64@3.5.6': optional: true - '@microsoft/api-extractor-model@7.32.2(@types/node@26.0.1)': + '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.2)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) transitivePeerDependencies: - '@types/node' optional: true - '@microsoft/api-extractor@7.56.0(@types/node@26.0.1)': + '@microsoft/api-extractor@7.56.0(@types/node@26.1.2)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@26.0.1) + '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.2) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@26.0.1) - '@rushstack/ts-command-line': 5.1.7(@types/node@26.0.1) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) + '@rushstack/ts-command-line': 5.1.7(@types/node@26.1.2) diff: 8.0.4 lodash: 4.18.1 minimatch: 10.2.6 @@ -6225,9 +6074,9 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.17(hono@4.12.32) + '@hono/node-server': 2.0.12(hono@4.12.34) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -6235,10 +6084,10 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.6.1(express@5.2.1) - hono: 4.12.32 - jose: 6.2.5 + express: 5.2.1(supports-color@10.2.2) + express-rate-limit: 8.6.1(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.12.34 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -6375,88 +6224,90 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-parser/binding-android-arm-eabi@0.140.0': + '@oxc-parser/binding-android-arm-eabi@0.142.0': optional: true - '@oxc-parser/binding-android-arm64@0.140.0': + '@oxc-parser/binding-android-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.140.0': + '@oxc-parser/binding-darwin-arm64@0.142.0': optional: true - '@oxc-parser/binding-darwin-x64@0.140.0': + '@oxc-parser/binding-darwin-x64@0.142.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.140.0': + '@oxc-parser/binding-freebsd-x64@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.140.0': + '@oxc-parser/binding-linux-arm64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.140.0': + '@oxc-parser/binding-linux-x64-gnu@0.142.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.140.0': + '@oxc-parser/binding-linux-x64-musl@0.142.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.140.0': + '@oxc-parser/binding-openharmony-arm64@0.142.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.140.0': + '@oxc-parser/binding-wasm32-wasi@0.142.0': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.140.0': + '@oxc-parser/binding-win32-x64-msvc@0.142.0': optional: true '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.140.0': {} + '@oxc-project/types@0.142.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -6514,9 +6365,9 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true - '@penpot/plugin-styles@1.4.1': {} + '@penpot/plugin-styles@1.4.2': {} - '@penpot/plugin-types@1.4.1': {} + '@penpot/plugin-types@1.4.2': {} '@polka/url@1.0.0-next.29': {} @@ -6531,84 +6382,120 @@ snapshots: '@rolldown/binding-android-arm64@1.2.0': optional: true + '@rolldown/binding-android-arm64@1.2.2': + optional: true + '@rolldown/binding-darwin-arm64@1.1.5': optional: true '@rolldown/binding-darwin-arm64@1.2.0': optional: true + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + '@rolldown/binding-darwin-x64@1.1.5': optional: true '@rolldown/binding-darwin-x64@1.2.0': optional: true + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + '@rolldown/binding-freebsd-x64@1.1.5': optional: true '@rolldown/binding-freebsd-x64@1.2.0': optional: true + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.2.0': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true '@rolldown/binding-linux-arm64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true '@rolldown/binding-linux-arm64-musl@1.2.0': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true '@rolldown/binding-linux-ppc64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true '@rolldown/binding-linux-s390x-gnu@1.2.0': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true '@rolldown/binding-linux-x64-gnu@1.2.0': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true '@rolldown/binding-linux-x64-musl@1.2.0': optional: true + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true '@rolldown/binding-openharmony-arm64@1.2.0': optional: true + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-wasm32-wasi@1.2.0': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -6617,12 +6504,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.0': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/binding-win32-x64-msvc@1.2.0': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.60.4)': @@ -6768,7 +6661,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@26.0.1)': + '@rushstack/node-core-library@5.19.1(@types/node@26.1.2)': dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) @@ -6779,12 +6672,12 @@ snapshots: resolve: 1.22.12 semver: 7.5.4 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@26.0.1)': + '@rushstack/problem-matcher@0.1.1(@types/node@26.1.2)': optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true '@rushstack/rig-package@0.6.0': @@ -6793,18 +6686,18 @@ snapshots: strip-json-comments: 3.1.1 optional: true - '@rushstack/terminal@0.21.0(@types/node@26.0.1)': + '@rushstack/terminal@0.21.0(@types/node@26.1.2)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@26.0.1) - '@rushstack/problem-matcher': 0.1.1(@types/node@26.0.1) + '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.2) supports-color: 8.1.1 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 optional: true - '@rushstack/ts-command-line@5.1.7(@types/node@26.0.1)': + '@rushstack/ts-command-line@5.1.7(@types/node@26.1.2)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@26.0.1) + '@rushstack/terminal': 0.21.0(@types/node@26.1.2) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -6812,15 +6705,19 @@ snapshots: - '@types/node' optional: true - '@schematics/angular@22.1.0(chokidar@5.0.0)': + '@schematics/angular@22.1.2(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.0(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.0(chokidar@5.0.0) + '@angular-devkit/core': 22.1.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.2(chokidar@5.0.0) jsonc-parser: 3.3.1 typescript: 6.0.3 transitivePeerDependencies: - chokidar + '@seahax/deep-copy@0.1.0': {} + + '@seahax/semaphore@0.5.1': {} + '@shikijs/engine-oniguruma@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -6853,8 +6750,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/ansi-html@0.0.0': {} - '@types/argparse@1.0.38': optional: true @@ -6863,10 +6758,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -6883,7 +6774,8 @@ snapshots: '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.8': + optional: true '@types/estree@1.0.9': {} @@ -6901,9 +6793,7 @@ snapshots: '@types/json5@0.0.29': {} - '@types/ms@2.1.0': {} - - '@types/node@26.0.1': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -6913,7 +6803,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/yargs-parser@21.0.3': {} @@ -6921,15 +6811,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6937,23 +6827,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6967,13 +6857,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -6981,13 +6871,13 @@ snapshots: '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6996,13 +6886,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7012,9 +6902,9 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))': dependencies: - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -7028,7 +6918,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -7039,13 +6929,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -7074,7 +6964,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: @@ -7088,11 +6978,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 '@webassemblyjs/ast@1.14.1': dependencies: @@ -7212,9 +7104,9 @@ snapshots: acorn@8.18.0: optional: true - agent-base@6.0.2: + agent-base@6.0.2(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -7250,7 +7142,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -7258,7 +7150,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -7266,8 +7158,6 @@ snapshots: dependencies: environment: 1.1.0 - ansi-html@0.0.9: {} - ansi-regex@6.2.2: {} ansi-styles@6.2.3: {} @@ -7373,11 +7263,11 @@ snapshots: axe-core@4.11.4: {} - axios@1.19.0: + axios@1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 - https-proxy-agent: 5.0.1 + https-proxy-agent: 5.0.1(supports-color@10.2.2) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -7389,7 +7279,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.11: {} beasties@0.4.3: dependencies: @@ -7409,11 +7299,11 @@ snapshots: binary-extensions@2.3.0: {} - body-parser@2.3.0: + body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -7425,16 +7315,12 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.15: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: - dependencies: - balanced-match: 4.0.4 - - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7444,9 +7330,9 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.7 + baseline-browser-mapping: 2.11.11 caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.398 + electron-to-chromium: 1.5.399 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -7454,7 +7340,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 bytes@3.1.2: {} @@ -7541,8 +7427,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@12.1.0: {} - commander@2.20.3: optional: true @@ -7637,13 +7521,17 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@3.2.7: + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -7704,7 +7592,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.398: {} + electron-to-chromium@1.5.399: {} emoji-regex@10.6.0: {} @@ -7840,35 +7728,6 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -7906,40 +7765,40 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-import-resolver-node@0.3.10: + eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) is-core-module: 2.16.2 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 + '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 10.6.0(jiti@2.7.0) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0(jiti@2.7.0)) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7951,13 +7810,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -7967,7 +7826,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7976,18 +7835,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -7995,7 +7854,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -8009,11 +7868,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-scope@5.1.1: dependencies: @@ -8032,11 +7891,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.6.0(jiti@2.7.0): + eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -8046,7 +7905,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -8111,28 +7970,28 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.6.1(express@5.2.1): + express-rate-limit@8.6.1(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 4.4.3 - express: 5.2.1 - ip-address: 10.3.1 + debug: 4.4.3(supports-color@10.2.2) + express: 5.2.1(supports-color@10.2.2) + ip-address: 10.4.0 transitivePeerDependencies: - supports-color - express@5.2.1: + express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@10.2.2) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@10.2.2) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -8143,9 +8002,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@10.2.2) + send: 1.2.1(supports-color@10.2.2) + serve-static: 2.2.1(supports-color@10.2.2) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -8166,16 +8025,12 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -8195,9 +8050,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -8218,7 +8073,9 @@ snapshots: flatted@3.4.3: {} - follow-redirects@1.16.0: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@10.2.2)): + optionalDependencies: + debug: 4.4.3(supports-color@10.2.2) for-each@0.3.5: dependencies: @@ -8311,7 +8168,7 @@ snapshots: glob-to-regexp@0.4.1: optional: true - globals@17.8.0: {} + globals@17.9.0: {} globalthis@1.0.4: dependencies: @@ -8324,7 +8181,7 @@ snapshots: happy-dom@20.11.1: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -8367,7 +8224,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.32: {} + hono@4.12.34: {} hosted-git-info@10.1.1: dependencies: @@ -8396,17 +8253,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1: + https-proxy-agent@5.0.1(supports-color@10.2.2): dependencies: - agent-base: 6.0.2 - debug: 4.4.3 + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -8445,7 +8302,7 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 - ip-address@10.3.1: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -8582,9 +8439,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -8615,7 +8472,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true @@ -8625,7 +8482,7 @@ snapshots: jju@1.4.0: optional: true - jose@6.2.5: {} + jose@6.2.8: {} js-tokens@10.0.0: {} @@ -8919,15 +8776,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.18 minimist@1.2.8: {} @@ -9103,32 +8960,30 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.140.0: + oxc-parser@0.142.0: dependencies: - '@oxc-project/types': 0.140.0 + '@oxc-project/types': 0.142.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.140.0 - '@oxc-parser/binding-android-arm64': 0.140.0 - '@oxc-parser/binding-darwin-arm64': 0.140.0 - '@oxc-parser/binding-darwin-x64': 0.140.0 - '@oxc-parser/binding-freebsd-x64': 0.140.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.140.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.140.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.140.0 - '@oxc-parser/binding-linux-arm64-musl': 0.140.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.140.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-musl': 0.140.0 - '@oxc-parser/binding-openharmony-arm64': 0.140.0 - '@oxc-parser/binding-wasm32-wasi': 0.140.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.140.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.140.0 - '@oxc-parser/binding-win32-x64-msvc': 0.140.0 - - p-defer@4.0.1: {} + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 p-limit@3.1.0: dependencies: @@ -9202,11 +9057,11 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -9218,12 +9073,6 @@ snapshots: dependencies: postcss: 8.5.25 - postcss@8.5.16: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.25: dependencies: nanoid: 3.3.16 @@ -9232,8 +9081,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.4: {} - prettier@3.9.6: {} proc-log@7.0.0: {} @@ -9413,6 +9260,26 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.0 '@rolldown/binding-win32-x64-msvc': 1.2.0 + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -9443,10 +9310,11 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.60.4 '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + optional: true - router@2.2.0: + router@2.2.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -9613,9 +9481,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -9629,12 +9497,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@10.2.2): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9866,15 +9734,16 @@ snapshots: tapable@2.3.3: optional: true - terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + terser-webpack-plugin@5.6.1(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.2 - webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.25) + webpack: 5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: esbuild: 0.28.1 + lightningcss: 1.33.0 postcss: 8.5.25 optional: true @@ -9936,7 +9805,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.5: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -9996,13 +9865,13 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 - typescript-eslint@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10035,25 +9904,25 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.60.4) - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) compare-versions: 6.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 local-pkg: 1.2.1 magic-string: 0.30.21 typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: - '@microsoft/api-extractor': 7.56.0(@types/node@26.0.1) + '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) '@rspack/core': 1.6.8(@swc/helpers@0.5.18) esbuild: 0.28.1 - rolldown: 1.2.0 + rolldown: 1.2.2 rollup: 4.60.4 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) - webpack: 5.106.2(esbuild@0.28.1)(postcss@8.5.25) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + webpack: 5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - supports-color @@ -10081,26 +9950,20 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite-live-preview@0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): dependencies: - '@commander-js/extra-typings': 12.1.0(commander@12.1.0) - '@types/ansi-html': 0.0.0 - '@types/debug': 4.1.13 + '@seahax/deep-copy': 0.1.0 + '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 - ansi-html: 0.0.9 - chalk: 5.6.2 - commander: 12.1.0 - debug: 4.4.3 escape-goat: 4.0.0 - p-defer: 4.0.1 - vite: 7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) - ws: 8.21.0 + strip-ansi: 7.2.0 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + ws: 8.21.1 transitivePeerDependencies: - bufferutil - - supports-color - utf-8-validate - vite-plugin-checker@0.14.5(eslint@10.6.0(jiti@2.7.0))(optionator@0.9.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -10109,19 +9972,19 @@ snapshots: picomatch: 4.0.4 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) optionalDependencies: - eslint: 10.6.0(jiti@2.7.0) + eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) optionator: 0.9.4 typescript: 6.0.3 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.0.1))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.60.4)(typescript@6.0.3)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) optionalDependencies: - '@microsoft/api-extractor': 7.56.0(@types/node@26.0.1) + '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) rollup: 4.60.4 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -10131,35 +9994,15 @@ snapshots: - typescript - webpack - vite-plugin-static-copy@4.1.1(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite-plugin-static-copy@4.1.1(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): dependencies: chokidar: 3.6.0 p-map: 7.0.4 picocolors: 1.1.1 tinyglobby: 0.2.17 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) - vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.33.0)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0): - dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.16 - rollup: 4.60.4 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 26.0.1 - fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.6.4 - lightningcss: 1.33.0 - sass: 1.101.0 - sass-embedded: 1.97.3 - terser: 5.46.2 - tsx: 4.23.1 - yaml: 2.9.0 - - vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0): + vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -10167,7 +10010,7 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -10175,13 +10018,32 @@ snapshots: sass: 1.101.0 sass-embedded: 1.97.3 terser: 5.46.2 - tsx: 4.23.1 + tsx: 4.23.5 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.0.1)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.6.4 + sass: 1.101.0 + sass-embedded: 1.97.3 + terser: 5.46.2 + tsx: 4.23.5 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -10198,10 +10060,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.0.1 + '@types/node': 26.1.2 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) '@vitest/ui': 4.1.10(vitest@4.1.10) happy-dom: 20.11.1 @@ -10231,7 +10093,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25): + webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -10254,7 +10116,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(postcss@8.5.25)) + terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: diff --git a/plugins/pnpm-workspace.yaml b/plugins/pnpm-workspace.yaml index ce14894b25..a0099791fc 100644 --- a/plugins/pnpm-workspace.yaml +++ b/plugins/pnpm-workspace.yaml @@ -20,10 +20,15 @@ minimumReleaseAgeExclude: - esbuild@0.28.1 - '@babel/core@7.29.1' - undici@7.28.0 + - brace-expansion@1.1.16 || 1.1.17 || 1.1.18 || 5.0.8 || 5.0.9 + - '@hono/node-server@2.0.5' + - fast-uri@3.1.5 overrides: '@babel/core@<=7.29.0': ^7.29.1 + '@hono/node-server@<2.0.5': ^2.0.5 ajv@>=7.0.0-alpha.0 <8.18.0: ^8.18.0 + brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 lodash@<=4.17.23: ^4.17.24 lodash@>=4.0.0 <=4.17.23: ^4.17.24 minimatch@>=10.0.0 <10.2.1: ^10.2.1 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da166d95ba..008bd049b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,8 +240,8 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} bundle-name@4.1.0: @@ -893,7 +893,7 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1209,7 +1209,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minipass@7.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e59a4df9a..7a28fcc29c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ allowBuilds: minimumReleaseAgeExclude: - playwright-core@1.62.1 - playwright@1.62.1 + - brace-expansion@5.0.9 overrides: "playwright@>=1.61.1 <2.0.0-0": "1.62.1" diff --git a/render-wasm/package.json b/render-wasm/package.json index 1a86f5737b..75e77ee457 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,14 +4,14 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "type": "module", "devDependencies": { - "@types/node": "^26.0.1", + "@types/node": "^26.1.2", "esbuild": "^0.28.1" } } diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 5b1cc35622..0d580aceed 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^26.0.1 - version: 26.0.1 + specifier: ^26.1.2 + version: 26.1.2 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -173,8 +173,8 @@ packages: cpu: [x64] os: [win32] - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -264,7 +264,7 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@types/node@26.0.1': + '@types/node@26.1.2': dependencies: undici-types: 8.3.0 From b507a6b667c7841aae175d0ad7b00ffa185051e1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 4 Aug 2026 13:04:19 +0000 Subject: [PATCH 005/298] :recycle: Convert commiter agent to create-commit skill Replace the commiter subagent with a create-commit skill, consistent with the create-pr and create-issue skill patterns. - Remove .opencode/agents/commiter.md - Add .opencode/skills/create-commit/SKILL.md - Update implement-plan.md to use the skill instead of subagent delegation - Document commit body line wrapping at 72 chars in creating-commits memory and skill AI-assisted-by: mimo-v2.5 --- .opencode/agents/commiter.md | 55 ------------------- .opencode/commands/implement-plan.md | 13 ++--- .opencode/skills/create-commit/SKILL.md | 47 ++++++++++++++++ .serena/memories/workflow/creating-commits.md | 2 + 4 files changed, 55 insertions(+), 62 deletions(-) delete mode 100644 .opencode/agents/commiter.md create mode 100644 .opencode/skills/create-commit/SKILL.md diff --git a/.opencode/agents/commiter.md b/.opencode/agents/commiter.md deleted file mode 100644 index a5e128e4d1..0000000000 --- a/.opencode/agents/commiter.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: commiter -description: Git commit assistant -mode: subagent -permission: - read: allow - glob: allow - grep: allow - edit: deny - webfetch: deny - websearch: deny - task: deny - skill: deny - lsp: deny - todowrite: deny - question: deny - external_directory: deny - bash: allow ---- - -## Role - -You are the Penpot commit assistant. You produce git commits that follow the -repository's commit conventions. You do not implement features, review code, or -push branches — you commit. - -## Required Reading - -Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md` -end-to-end**. It is the authoritative source for the commit message format, the -emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it -exactly — do not improvise the format and do not restate its contents here. - -## Pre-commit Workflow - -1. **Stage the files** specified by the calling agent. Do not ask for - confirmation — the calling agent knows exactly which files to commit. -2. Run `git diff --staged` to review the content. If you see secrets (API - keys, tokens, passwords, private keys, `.env` values), debug prints, or - anything that does not match the stated intent, STOP and tell the user - before committing. -3. Following the format in the doc, draft the message and run - `git commit -m "" -m ""` (or `git commit -F -` if the body has - unusual characters). The `AI-assisted-by` trailer value is provided by the - calling agent — use it verbatim. - -## Constraints - -- Do not push. Pushing is a separate workflow handled by the user. -- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations. -- Do not pass `--author`. Author identity comes from the local git config. -- Do not amend a commit you did not create in this session, unless the user explicitly asks. -- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks. -- Do not add untracked files that were not created in this session. -- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response. diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index f6cb42f6b8..20eedb3fe5 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -1,5 +1,5 @@ --- -description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the commiter subagent +description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill agent: build --- @@ -32,12 +32,11 @@ Implement the prepared plan from the session context. Work methodically, keeping changes focused on what the issue requires. Do not commit — the commit happens in step 4. -## 4. Commit with the commiter subagent +## 4. Commit with the create-commit skill -After the implementation is complete, delegate the commit to the **`commiter`** -subagent. Give it a brief summary of what was implemented and why, the issue -reference (`issue-NNNN`), and the model name you are running as so it sets the -`AI-assisted-by` trailer correctly. The subagent owns the commit format and -conventions. +After the implementation is complete, load the **`create-commit`** skill and +follow its workflow to commit the changes. Provide a brief summary of what was +implemented and why, the issue reference (`issue-NNNN`), and the model name you +are running as so the `AI-assisted-by` trailer is set correctly. Do not push. Pushing is handled separately by the user. diff --git a/.opencode/skills/create-commit/SKILL.md b/.opencode/skills/create-commit/SKILL.md new file mode 100644 index 0000000000..790ba1b585 --- /dev/null +++ b/.opencode/skills/create-commit/SKILL.md @@ -0,0 +1,47 @@ +--- +name: create-commit +description: Stage, review, and commit files following Penpot commit conventions. +--- + +# Skill: create-commit + +Produce a git commit that follows Penpot's commit message conventions. This +skill owns the commit format, staging review, and safety checks — it does not +implement features or push. + +## When to Use + +- After code changes are complete and files need to be committed +- When delegated by a workflow step (e.g. implement-plan) to handle the commit + +## Required Reading + +Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It +is the authoritative source for the commit message format, the emoji menu, +subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly. + +## Workflow + +1. **Stage the files** specified by the calling context. Do not ask for + confirmation. +2. Run `git diff --staged` to review the content. If you see secrets (API keys, + tokens, passwords, private keys, `.env` values), debug prints, or anything + that does not match the stated intent, **STOP** and tell the user before + committing. +3. Draft the message following the format in the memory doc, wrapping the body + at 72 characters per line, and run: + ```bash + git commit -m "" -m "" + ``` + (or `git commit -F -` if the body has unusual characters). +4. The `AI-assisted-by` trailer value is provided by the calling context — use + it verbatim. + +## Constraints + +- Do not push. Pushing is a separate workflow handled by the user. +- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm`. +- Do not pass `--author`. Author identity comes from the local git config. +- Do not amend a commit you did not create in this session, unless explicitly asked. +- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked. +- Do not add untracked files that were not created in this session. diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index d37d4672a5..50f460ed28 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -14,6 +14,8 @@ automatically pull the identity from the local git config `user.name` and `user. :emoji: Subject line (imperative, capitalized, no period, <=70 chars) Body explaining what changed and why. +Wrap lines at 72 characters — git log and tooling +render long lines poorly. Keep each line concise. AI-assisted-by: model-name ``` From 14a6ea5c525f8295742b79c1de71cd0008bd8461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 4 Aug 2026 15:30:26 +0200 Subject: [PATCH 006/298] :wrench: Support text style shortcuts (#11002) --- .../main/data/workspace/text/shortcuts.cljs | 3 ++ .../ui/workspace/shapes/text/v3_editor.cljs | 20 ++++++++++--- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 6 ++++ render-wasm/src/wasm/text_editor.rs | 29 +++++++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index fddfaf357a..9943c809e0 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -11,6 +11,7 @@ [app.common.types.text :as txt] [app.main.data.shortcuts :as ds] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.texts-v3 :as dwt-v3] [app.main.data.workspace.undo :as dwu] [app.main.features :as features] [app.main.fonts :as fonts] @@ -170,6 +171,8 @@ :else props)] (when (and shape props) + (when (features/active-feature? @st/state "text-editor-wasm/v1") + (st/emit! (dwt-v3/v3-update-text-editor-styles (:id shape) props))) (st/emit! (dwt/update-attrs (:id shape) props))))) (defn blend-props diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index fb65be820e..e626fa2b74 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -99,6 +99,18 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- input-surface-class + "Class list for the contenteditable capture surface. + + Mousetrap's `stopCallback` drops every keystroke whose target is + contentEditable, so without the `mousetrap` class (as in V1/V2) the text + shortcuts (Ctrl+B, Ctrl+I, …) never reach the dispatcher." + [rotation] + (dm/str "mousetrap " + (cur/get-dynamic "text" rotation) + " " + (stl/css :text-editor-container))) + (mf/defc text-editor* "Contenteditable element positioned over the text shape to capture input events." [{:keys [shape]}] @@ -359,7 +371,9 @@ (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] (mf/set-ref-val! dragging-ref true) - (wasm.api/text-editor-pointer-down off-pt) + (if (.-shiftKey event) + (wasm.api/text-editor-pointer-down-extend off-pt) + (wasm.api/text-editor-pointer-down off-pt)) ;; Repaint the caret over the cached tiles instead of a full render, ;; which flashes at high zoom (see `render-text-editor-overlay!`). (wasm.api/render-text-editor-overlay!)))) @@ -505,7 +519,5 @@ :on-focus on-focus :on-blur on-blur :id "text-editor-wasm-input" - :class (dm/str (cur/get-dynamic "text" (:rotation shape)) - " " - (stl/css :text-editor-container)) + :class (input-surface-class (:rotation shape)) :data-testid "text-editor-container"}]]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index aa86d52701..b8601102ee 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -281,6 +281,7 @@ (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) (def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode) (def text-editor-pointer-down text-editor/text-editor-pointer-down) +(def text-editor-pointer-down-extend text-editor/text-editor-pointer-down-extend) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-get-current-styles text-editor/text-editor-get-current-styles) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 1794c1ae22..c2853dad9e 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -222,6 +222,12 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_pointer_down" x y))) +(defn text-editor-pointer-down-extend + "Extends the selection up to the pointer instead of collapsing the caret." + [{:keys [x y]}] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_pointer_down_extend" x y))) + (defn text-editor-pointer-move [{:keys [x y]}] (when (wasm/ready?) diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index af37164af9..6bb03f9f5e 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -192,6 +192,35 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { }); } +/// Like `text_editor_pointer_down`, but keeps the current anchor and moves the +/// focus to the pointer instead of collapsing the caret there (Shift+click). +#[no_mangle] +pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) { + with_state!(state, { + if !get_text_editor_state().has_focus { + return; + } + let Some(shape_id) = get_text_editor_state().active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + let point = Point::new(x, y); + get_text_editor_state().start_pointer_selection(); + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + get_text_editor_state().extend_selection_from_position(&position); + // The click after pointerup would collapse the caret and drop the + // selection we just extended. + get_text_editor_state().is_click_event_skipped = true; + get_text_editor_state().update_styles(text_content); + } + }); +} + #[no_mangle] pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { with_state!(state, { From 23ea2bbad6bb603deda53ea201ace9a47f3cfcef Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 4 Aug 2026 15:36:33 +0200 Subject: [PATCH 007/298] :paperclip: Update creating-commits serena workflow file --- .serena/memories/workflow/creating-commits.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index 50f460ed28..2fc766d4ad 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -27,3 +27,7 @@ AI-assisted-by: model-name ## Commit Type Emojis `:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight + +## Referencing Issues + +Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue. From 7ae57a035f108a0d767c4b9c63d598135e8bc2f0 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Tue, 4 Aug 2026 16:42:55 +0200 Subject: [PATCH 008/298] :bug: Position overlays by frame selrect, not filter-inflated bounds (#10454) calc-overlay-position measured the destination overlay frame with its full object bounds (get-object-bounds) while measuring the relative-to frame with its selrect. Object bounds include padding for shadows, blur, outer strokes and overflowing children, so centered/right/bottom overlays were shifted by half that extra padding when the overlay frame had such effects (the overlay appeared offset, e.g. a bit to the left). Use the destination frame selrect (the visible frame box) instead, which matches the sibling helper calc-overlay-pos-initial and the viewer, which reserves the bounds size and re-aligns the selrect separately. The now unused geom.shapes.bounds require is removed. Adds a regression test asserting calc-overlay-position returns the same position with and without a bounds-inflating drop shadow on the destination frame. Fixes #9048 Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 4.8 Co-authored-by: Andrey Antukh --- .../app/common/types/shape/interactions.cljc | 9 +++- .../types/shape_interactions_test.cljc | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc index 6b06b68897..99f25fad00 100644 --- a/common/src/app/common/types/shape/interactions.cljc +++ b/common/src/app/common/types/shape/interactions.cljc @@ -9,7 +9,6 @@ [app.common.data :as d] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] - [app.common.geom.shapes.bounds :as gsb] [app.common.schema :as sm] [app.common.schema.generators :as sg])) @@ -482,7 +481,13 @@ (if (nil? dest-frame) [(gpt/point 0 0) [:top :left]] - (let [overlay-size (gsb/get-object-bounds objects dest-frame) + (let [;; Use the destination frame selrect (the visible frame box) to compute + ;; the overlay position, not its full object bounds. Bounds include + ;; padding for shadows, blur, strokes and overflowing children, which + ;; would make centered/right/bottom positions off by half that padding + ;; (the visible frame ends up shifted). The viewer reserves the bounds + ;; size and re-aligns the selrect separately (see viewer/calculate-delta). + overlay-size (:selrect dest-frame) base-frame-size (:selrect base-frame) relative-to-shape-size (:selrect relative-to-shape) relative-to-adjusted-to-base-frame {:x (- (:x relative-to-shape-size) (:x base-frame-size)) diff --git a/common/test/common_tests/types/shape_interactions_test.cljc b/common/test/common_tests/types/shape_interactions_test.cljc index da056ae136..7daac0ab8a 100644 --- a/common/test/common_tests/types/shape_interactions_test.cljc +++ b/common/test/common_tests/types/shape_interactions_test.cljc @@ -10,6 +10,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.bounds :as gsb] [app.common.math :as mth] [app.common.types.shape :as cts] [app.common.types.shape.interactions :as ctsi] @@ -1078,3 +1079,49 @@ [overlay-pos snap] (ctsi/calc-overlay-position frame-relative base-frame objects base-frame base-frame overlay-frame frame-offset)] (t/is (= (gpt/point 18 22) overlay-pos)) (t/is (= [:top :left] snap)))))) + +(t/deftest calc-overlay-position-ignores-filter-bounds + ;; Regression for #9048: the overlay position must be computed from the + ;; destination frame selrect (the visible frame box), not from its + ;; filter-inflated object bounds. Shadows, blur, strokes or overflowing + ;; children make get-object-bounds larger than the selrect, which used to + ;; shift centered/right/bottom overlays by half that extra padding (the + ;; overlay appeared offset, e.g. "a bit to the left"). + (let [base-frame (cts/setup-shape {:type :frame :width 100 :height 100}) + overlay-plain (cts/setup-shape {:type :frame :width 30 :height 20}) + ;; same selrect as overlay-plain, but with a drop shadow that widens + ;; and heightens its object bounds well beyond the selrect. + overlay-shadow (-> (cts/setup-shape {:type :frame :width 30 :height 20}) + (assoc :shadow [{:style :drop-shadow + :offset-x 0 :offset-y 0 + :spread 10 :blur 0 :hidden false}])) + objects {(:id base-frame) base-frame + (:id overlay-plain) overlay-plain + (:id overlay-shadow) overlay-shadow} + frame-offset (gpt/point 5 5) + interaction (-> ctsi/default-interaction + (ctsi/set-action-type :open-overlay) + (ctsi/set-position-relative-to (:id base-frame)))] + + ;; Precondition: the shadow really does inflate the object bounds, so the + ;; assertions below are meaningful (otherwise the test would be vacuous). + (t/is (> (:width (gsb/get-object-bounds objects overlay-shadow)) + (:width (:selrect overlay-shadow)))) + (t/is (> (:height (gsb/get-object-bounds objects overlay-shadow)) + (:height (:selrect overlay-shadow)))) + + ;; For every position type that depends on the overlay size, the computed + ;; position must be identical whether or not the destination frame has a + ;; bounds-inflating shadow. + (doseq [pos-type [:center :top-center :top-right :bottom-center :bottom-right]] + (let [i-plain (-> interaction + (ctsi/set-destination (:id overlay-plain)) + (ctsi/set-overlay-pos-type pos-type base-frame objects)) + i-shadow (-> interaction + (ctsi/set-destination (:id overlay-shadow)) + (ctsi/set-overlay-pos-type pos-type base-frame objects)) + [pos-plain snap-plain] (ctsi/calc-overlay-position i-plain base-frame objects base-frame base-frame overlay-plain frame-offset) + [pos-shadow snap-shadow] (ctsi/calc-overlay-position i-shadow base-frame objects base-frame base-frame overlay-shadow frame-offset)] + (t/testing (str "overlay position ignores filter bounds for " pos-type) + (t/is (= pos-plain pos-shadow)) + (t/is (= snap-plain snap-shadow))))))) From 648c8e2152dc293c998b33aa554f746c1372ce38 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Tue, 4 Aug 2026 16:49:12 +0200 Subject: [PATCH 009/298] :bug: Keep svg-raw children as uuids on binfile import (#10837) Importing a .penpot file left every svg-raw subtree broken: the parent's :shapes vector came back holding plain strings instead of uuids, so the child ids no longer resolved against the page objects map. The next persisted change touching that page then failed referential integrity validation with :child-not-found, surfaced to the client as an HTTP 400 :referential-integrity error, which in practice bricks the file. An svg-raw shape can be a container: importing an SVG builds a tree of svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw with children as group-like. But schema:svg-raw-attrs was an empty map. Frame, group and bool all declare :shapes as a vector of uuid; svg-raw did not, so the JSON decoder used by binfile had no type information for those ids and left them as strings. Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw shape has no children, so the child ids decode back to uuids. Closes #10496. Signed-off-by: Filip Sajdak Co-authored-by: Andrey Antukh --- backend/test/backend_tests/binfile_test.clj | 97 +++++++++++++++++++++ common/src/app/common/types/shape.cljc | 9 +- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 232a44f87c..84310241d4 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -10,6 +10,7 @@ [app.binfile.common :as bfc] [app.binfile.v3 :as v3] [app.common.features :as cfeat] + [app.common.files.validate :as cfv] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.types.shape :as cts] @@ -86,6 +87,102 @@ (dissoc file :data))) +(def ^:private svg-raw-page-id (uuid/custom 1 1)) +(def ^:private svg-raw-root-id (uuid/custom 3 1)) +(def ^:private svg-raw-child-id (uuid/custom 3 2)) + +(defn- prepare-svg-raw-file + "A file containing an svg-raw subtree (an svg-raw parent with an + svg-raw child), which is what importing an SVG produces." + [profile] + (let [page-id svg-raw-page-id + root-id svg-raw-root-id + child-id svg-raw-child-id + + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-page + :name "page 1" + :id page-id}]) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-obj + :page-id page-id + :id root-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id root-id + :name "svg-root" + :frame-id uuid/zero + :parent-id uuid/zero + :type :svg-raw + :content {:tag :svg :attrs {} :content []}})} + {:type :add-obj + :page-id page-id + :id child-id + :parent-id root-id + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id child-id + :name "svg-text" + :frame-id uuid/zero + :parent-id root-id + :type :svg-raw + :content {:tag :text :attrs {} :content []}})}]) + + (dissoc file :data))) + +(t/deftest import-binfile-v3-preserves-svg-raw-children + (let [profile (th/create-profile* 1) + file (prepare-svg-raw-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id (first result) + :components-v2 true})) + root (get-in imported [:data :pages-index svg-raw-page-id + :objects svg-raw-root-id])] + + (t/is (= (count result) 1)) + + ;; The child ids of an svg-raw shape must survive the JSON round + ;; trip as uuids; when they came back as plain strings they no + ;; longer resolved against the objects map. + (t/is (every? uuid? (:shapes root))) + (t/is (= [svg-raw-child-id] (vec (:shapes root)))) + + ;; ...so the imported file passes referential integrity instead + ;; of failing with :child-not-found on the next update-file. + (t/is (nil? (cfv/validate-file imported [])))))) + (t/deftest export-binfile-v3 (let [profile (th/create-profile* 1) file (prepare-simple-file profile) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index d645e72ae1..46f608f390 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -259,7 +259,14 @@ [:map {:title "CircleAttrs"}]) (def ^:private schema:svg-raw-attrs - [:map {:title "SvgRawAttrs"}]) + [:map {:title "SvgRawAttrs"} + ;; An svg-raw shape can be a container: importing an SVG builds a + ;; tree of svg-raw shapes, and `cfh/group-like-shape?` treats an + ;; svg-raw with children as group-like. Declaring `:shapes` here + ;; keeps the child ids typed as uuid, so a JSON round trip (binfile + ;; export/import) decodes them back to uuids instead of leaving + ;; strings that no longer resolve against the objects map. + [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]]) (def schema:image-attrs [:map {:title "ImageAttrs"} From 3fba272848392dfe5e9855aa9db2a5121cc9614b Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Tue, 4 Aug 2026 16:49:12 +0200 Subject: [PATCH 010/298] :bug: Keep svg-raw children as uuids on binfile import (#10837) Importing a .penpot file left every svg-raw subtree broken: the parent's :shapes vector came back holding plain strings instead of uuids, so the child ids no longer resolved against the page objects map. The next persisted change touching that page then failed referential integrity validation with :child-not-found, surfaced to the client as an HTTP 400 :referential-integrity error, which in practice bricks the file. An svg-raw shape can be a container: importing an SVG builds a tree of svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw with children as group-like. But schema:svg-raw-attrs was an empty map. Frame, group and bool all declare :shapes as a vector of uuid; svg-raw did not, so the JSON decoder used by binfile had no type information for those ids and left them as strings. Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw shape has no children, so the child ids decode back to uuids. Closes #10496. Signed-off-by: Filip Sajdak Co-authored-by: Andrey Antukh --- backend/test/backend_tests/binfile_test.clj | 97 +++++++++++++++++++++ common/src/app/common/types/shape.cljc | 9 +- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 232a44f87c..84310241d4 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -10,6 +10,7 @@ [app.binfile.common :as bfc] [app.binfile.v3 :as v3] [app.common.features :as cfeat] + [app.common.files.validate :as cfv] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.types.shape :as cts] @@ -86,6 +87,102 @@ (dissoc file :data))) +(def ^:private svg-raw-page-id (uuid/custom 1 1)) +(def ^:private svg-raw-root-id (uuid/custom 3 1)) +(def ^:private svg-raw-child-id (uuid/custom 3 2)) + +(defn- prepare-svg-raw-file + "A file containing an svg-raw subtree (an svg-raw parent with an + svg-raw child), which is what importing an SVG produces." + [profile] + (let [page-id svg-raw-page-id + root-id svg-raw-root-id + child-id svg-raw-child-id + + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-page + :name "page 1" + :id page-id}]) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-obj + :page-id page-id + :id root-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id root-id + :name "svg-root" + :frame-id uuid/zero + :parent-id uuid/zero + :type :svg-raw + :content {:tag :svg :attrs {} :content []}})} + {:type :add-obj + :page-id page-id + :id child-id + :parent-id root-id + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id child-id + :name "svg-text" + :frame-id uuid/zero + :parent-id root-id + :type :svg-raw + :content {:tag :text :attrs {} :content []}})}]) + + (dissoc file :data))) + +(t/deftest import-binfile-v3-preserves-svg-raw-children + (let [profile (th/create-profile* 1) + file (prepare-svg-raw-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id (first result) + :components-v2 true})) + root (get-in imported [:data :pages-index svg-raw-page-id + :objects svg-raw-root-id])] + + (t/is (= (count result) 1)) + + ;; The child ids of an svg-raw shape must survive the JSON round + ;; trip as uuids; when they came back as plain strings they no + ;; longer resolved against the objects map. + (t/is (every? uuid? (:shapes root))) + (t/is (= [svg-raw-child-id] (vec (:shapes root)))) + + ;; ...so the imported file passes referential integrity instead + ;; of failing with :child-not-found on the next update-file. + (t/is (nil? (cfv/validate-file imported [])))))) + (t/deftest export-binfile-v3 (let [profile (th/create-profile* 1) file (prepare-simple-file profile) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index d645e72ae1..46f608f390 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -259,7 +259,14 @@ [:map {:title "CircleAttrs"}]) (def ^:private schema:svg-raw-attrs - [:map {:title "SvgRawAttrs"}]) + [:map {:title "SvgRawAttrs"} + ;; An svg-raw shape can be a container: importing an SVG builds a + ;; tree of svg-raw shapes, and `cfh/group-like-shape?` treats an + ;; svg-raw with children as group-like. Declaring `:shapes` here + ;; keeps the child ids typed as uuid, so a JSON round trip (binfile + ;; export/import) decodes them back to uuids instead of leaving + ;; strings that no longer resolve against the objects map. + [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]]) (def schema:image-attrs [:map {:title "ImageAttrs"} From 8e713df5f07c1723d2082e79a62e8221090e2334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Tue, 4 Aug 2026 16:53:36 +0200 Subject: [PATCH 011/298] :tada: Add repair functions for variant validation errors (#10768) * :tada: Add repair functions for variant validation errors * :books: Fix copyright notice --- common/src/app/common/files/repair.cljc | 227 +++++++++++------ common/src/app/common/files/validate.cljc | 66 ++--- .../src/app/common/test_helpers/variants.cljc | 5 + common/src/app/common/types/file.cljc | 2 + .../test/common_tests/files/repair_test.cljc | 230 ++++++++++++++++++ 5 files changed, 432 insertions(+), 98 deletions(-) create mode 100644 common/test/common_tests/files/repair_test.cljc diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 6714bfce04..95f3fda22f 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -10,12 +10,14 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.logging :as log] + [app.common.path-names :as cpn] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] + [app.common.types.variant :as ctv] [app.common.uuid :as uuid])) (log/set-level! :debug) @@ -35,7 +37,7 @@ (assoc :width 0.01) (assoc :height 0.01) (cts/setup-rect)))] - (log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -48,7 +50,7 @@ (log/debug :hint " -> set to " :parent-id uuid/zero) (assoc shape :parent-id uuid/zero))] - (log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -61,7 +63,7 @@ (log/debug :hint " -> add children to" :parent-id (:id parent-shape)) (update parent-shape :shapes conj (:id shape)))] - (log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:parent-id shape)] repair-shape)))) @@ -74,7 +76,7 @@ (log/debug :hint " -> remove duplicated children") (update shape :shapes distinct))] - (log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -86,14 +88,14 @@ (log/debug :hint " -> remove child" :child-id (:child-id args)) (update parent-shape :shapes (fn [shapes] (d/removev #(= (:child-id args) %) shapes))))] - (log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-parent [_ {:keys [shape page-id args] :as error} file-data _] - (log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true}))) @@ -109,7 +111,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -125,7 +127,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -138,7 +140,7 @@ (log/debug :hint " -> set :main-instance") (assoc shape :main-instance true))] - (log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -155,7 +157,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -174,7 +176,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes shape-ids repair-shape)))) @@ -194,7 +196,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) (if (and (some? component) (not (:deleted component))) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) @@ -211,7 +213,7 @@ ;; Assign main instance in the component to current shape (log/debug :hint " -> assign main-instance-page" :component-id (:id component)) (assoc component :main-instance-page page-id))] - (log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) (pcb/update-component (:component-id shape) repair-component)))) @@ -224,7 +226,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -237,7 +239,7 @@ (log/debug :hint " -> unset :main-instance") (dissoc shape :main-instance))] - (log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -250,7 +252,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -263,7 +265,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -310,7 +312,7 @@ ;; If the shape still refers to the remote component, try to find the corresponding near one ;; and link to it. If not, detach the shape. - (log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (if (some? matching-shape) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) @@ -329,7 +331,7 @@ (log/debug :hint " -> unhead shape") (ctk/unhead-shape shape))] - (log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -354,7 +356,7 @@ (nil? (:component-file args)) (dissoc :component-file)))] - (log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -367,7 +369,7 @@ (log/debug :hint " -> reroot shape") (ctk/rehead-shape shape (:component-file args) (:component-id args)))] - (log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -396,7 +398,7 @@ (assoc acc k v))) {} objects)))))] - (log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -409,7 +411,7 @@ (log/debug :hint " -> unset :shape-ref") (dissoc shape :shape-ref))] - (log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -422,7 +424,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -435,7 +437,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape) @@ -449,7 +451,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -462,7 +464,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -475,7 +477,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -488,7 +490,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -501,7 +503,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -520,7 +522,7 @@ :r3 0 :r4 0))] - (log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -538,7 +540,7 @@ (log/debug :hint " -> remove :objects") (dissoc component :objects))))] - (log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -554,7 +556,7 @@ (dissoc component :objects)) component))] - (log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -567,7 +569,7 @@ (log/debug :hint " -> add :content-group to :touched-groups") (update shape :touched ctk/set-touched-group :content-group))] - (log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -580,7 +582,7 @@ (log/debug :hint " -> remove swap-slot") (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -603,13 +605,11 @@ (log/debug :hint " -> remove swap-slot" :child-id (:id shape)) (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes (map :id child-with-duplicate) repair-shape)))) - - (defmethod repair-error :component-duplicate-slot [_ {:keys [shape] :as error} file-data _] (let [main-shape (get-in shape [:objects (:main-instance-id shape)]) @@ -633,7 +633,7 @@ (:objects component))] (assoc component :objects objects)))] - (log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -649,50 +649,139 @@ (ctk/set-swap-slot shape slot)) shape)))] - (log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) -(defmethod repair-error :not-a-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :main-instance-not-a-variant + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape :variant-id variant-id)))] -(defmethod repair-error :invalid-variant-id - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + (log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + +(defmethod repair-error :main-instance-invalid-variant-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-variant-properties - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [prop-names (:prop-names args) + + component (get-in file-data [:components (:component-id shape)]) + prop-values (into {} (map (juxt :name :value)) (:variant-properties component)) + properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names) + variant-name (ctv/properties-to-name properties') + + repair-component + (fn [component] + ;; Rebuild component properties, removing any extra ones and adding missing ones with empty value + (log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names)) + (assoc component :variant-properties properties')) + + repair-shape + (fn [shape] + (log/debug :hint " -> set variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + + (log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-component) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :variant-not-main - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [page (ctpl/get-page file-data page-id) + shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))] + (log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint " -> delete shapes" :shape-ids shape-ids) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/remove-objects shape-ids)))) (defmethod repair-error :parent-not-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [parent-id (:parent-id shape) + repair-fn + (fn [parent] + (log/debug :hint " -> set :is-variant-container true") + (assoc parent :is-variant-container true))] + (log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [parent-id] repair-fn)))) -(defmethod repair-error :variant-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-name + [_ {:keys [shape page-id args]} file-data _] + (let [repair-fn + (fn [shape] + (log/debug :hint " -> set :name" :name (:variant-name args)) + (assoc shape :name (:variant-name args)))] + (log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) -(defmethod repair-error :variant-bad-variant-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-variant-name + [_ {:keys [shape page-id]} file-data _] + (let [component (get-in file-data [:components (:component-id shape)]) + variant-name (ctv/properties-to-name (:variant-properties component)) + repair-fn + (fn [shape] + (log/debug :hint " -> set :variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + (log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) (defmethod repair-error :variant-component-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [[path name] (cpn/split-group-name (:variant-container-name args)) + repair-fn + (fn [component] + (log/debug :hint " -> set :path and :name" :path path :name name) + (assoc component :path path :name name))] + (log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-fn)))) + +(defmethod repair-error :variant-component-bad-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :default [_ error file _] @@ -701,7 +790,7 @@ (defn repair-file [{:keys [data id] :as file} libraries errors] - (log/dbg :hint "repairing file" :id (str id) :errors (count errors)) + (log/debug :hint "repairing file" :id (str id) :errors (count errors)) (let [{:keys [redo-changes]} (reduce (fn [changes error] (pcb/concat-changes changes diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 82aeed3d9e..9cb2a5e060 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -65,13 +65,13 @@ :misplaced-slot :missing-slot :shape-ref-cycle - :not-a-variant - :invalid-variant-id + :main-instance-not-a-variant + :main-instance-invalid-variant-id :invalid-variant-properties :variant-not-main :parent-not-variant - :variant-bad-name - :variant-bad-variant-name + :variant-main-bad-name + :variant-main-bad-variant-name :variant-component-bad-name :variant-component-bad-id}) @@ -573,19 +573,23 @@ (run! (fn [child-id] (when-let [child (get objects child-id)] (if (not (ctk/is-variant? child)) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id child)) - child file page) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id child)) + child file page + :variant-id shape-id) (do (when (not= (:variant-id child) shape-id) - (report-error :invalid-variant-id - (str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child)) - child file page)) + (report-error :main-instance-invalid-variant-id + (str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child)) + child file page + :variant-id shape-id)) (when (not= prop-names (cfv/extract-properties-names child file-data)) (report-error :invalid-variant-properties (str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names)) - child file page)))))) + child file page + :prop-names prop-names)))))) shapes))) + (defn- check-variant "Shape is a variant, so -it should be a main component @@ -594,9 +598,9 @@ -its name should be the same as its parent's " [shape file page] - (let [parent (ctst/get-shape page (:parent-id shape)) - component (ctkl/get-component (:data file) (:component-id shape) true) - name (ctv/properties-to-name (:variant-properties component))] + (let [parent (ctst/get-shape page (:parent-id shape)) + component (ctkl/get-component (:data file) (:component-id shape) true) + variant-name (ctv/properties-to-name (:variant-properties component))] (when-not (ctk/main-instance? shape) (report-error :variant-not-main (str/ffmt "Variant % is not a main instance" (:id shape)) @@ -605,23 +609,26 @@ (report-error :parent-not-variant (str/ffmt "Variant % has an invalid parent" (:id shape)) shape file page)) - - (when-not (= name (:variant-name shape)) - (report-error :variant-bad-variant-name + (when-not (= variant-name (:variant-name shape)) + (report-error :variant-main-bad-variant-name (str/ffmt "Variant % has an invalid variant-name" (:id shape)) - shape file page)) + shape file page + :variant-name variant-name)) (when-not (= (:name parent) (:name shape)) - (report-error :variant-bad-name - (str/ffmt "Variant % has an invalid name" (:id shape)) - shape file page)) + (report-error :variant-main-bad-name + (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) + shape file page + :variant-name (:name parent))) (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) (report-error :variant-component-bad-name (str/ffmt "Component % has an invalid name" (:id shape)) - shape file page)) + shape file page + :variant-container-name (:name parent))) (when-not (= (:variant-id component) (:variant-id shape)) (report-error :variant-component-bad-id (str/ffmt "Variant % has adifferent variant-id than its component" (:id shape)) - shape file page)))) + shape file page + :variant-id (:variant-id component))))) (defn- check-shape "Validate referential integrity and semantic coherence of @@ -740,14 +747,15 @@ -It should have at least one variant property" [component file] (let [component-page (ctf/get-component-page (:data file) component) - main-component (if (:deleted component) + main-instance (if (:deleted component) (dm/get-in component [:objects (:main-instance-id component)]) (ctst/get-shape component-page (:main-instance-id component)))] - (when (and main-component - (not (ctk/is-variant? main-component))) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id main-component)) - main-component file component-page)))) + (when (and main-instance + (not (ctk/is-variant? main-instance))) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id main-instance)) + main-instance file component-page + :variant-id (:variant-id component))))) (defn- check-main-inside-main [component file] diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index bf2e6fc973..07b4a58be7 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -13,6 +13,11 @@ [app.common.types.text :as txt])) (defn add-variant + "Add a variant component to a file with two variants, each with a root shape. + :variant-label [:name Board] + {:root2-label} [:name Board] # [Component :component2-label] + {:root1-label} [:name Board] # [Component :component1-label] + " [file variant-label component1-label root1-label component2-label root2-label & {:keys [variant1-params variant2-params] :or {variant1-params {} variant2-params {}}}] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 8c97f4cce0..fe91dca8ad 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -912,8 +912,10 @@ (let [shape (get objects shape-id)] (println (str/pad (str (str/repeat " " level) (when (:main-instance shape) "{") + (when (:is-variant-container shape) "{{") (:name shape) (when (:main-instance shape) "}") + (when (:is-variant-container shape) "}}") (when (seq (:touched shape)) "*") (when show-ids (str/format " %s" (:id shape)))) {:length 20 diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc new file mode 100644 index 0000000000..a5021baf03 --- /dev/null +++ b/common/test/common_tests/files/repair_test.cljc @@ -0,0 +1,230 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.files.repair-test + "Tests for the validate / repair functions in app.common.files.validate + and app.common.files.repair. + + The tests generate cases of broken files and check that the validation functions + generate accurate errors, and that the repair functions return the file to + a stable state." + (:require + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.variants :as thv] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(t/deftest repair-main-instance-not-a-variant + (t/testing "detect and repair a variant component whose root shape is not a variant" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id nil)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem + (t/is (= :main-instance-not-a-variant (:code (first errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-id-variant-component-bad-id + (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id (uuid/next))) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem + (t/is (= :main-instance-invalid-variant-id (:code (first errors)))) + (t/is (= :variant-component-bad-id (:code (second errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-properties + (t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"] + ;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3") + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}]}) + (ths/update-shape :root1 :variant-name "Value1, ValueA") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + comp1' (thc/get-component file' :component1) + comp2' (thc/get-component file' :component2) + root1' (ths/get-shape file' :root1) + root2' (ths/get-shape file' :root2)] + + (t/is (= 1 (count errors))) + (t/is (= :invalid-variant-properties (:code (first errors)))) + + (t/is (nil? errors')) + + ;; After repair, component1's properties are rebuilt to match component2's property names + ;; (the first child in the variant container is root2, so prop-names come from component2) + ;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed + (t/is (= [{:name "Property 1" :value "Value1"} + {:name "Property 3" :value ""}] + (:variant-properties comp1'))) + + (t/is (= "Value1" (:variant-name root1'))) + + ;; Component2 is unchanged (it was the reference for the property names) + (t/is (= [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}] + (:variant-properties comp2'))) + + (t/is (= "Value2, ValueB" (:variant-name root2')))))) + +(t/deftest repair-variant-not-main + (t/testing "detect and repair a non-main-instance shape inside a variant container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Add a third child to the variant container with :variant-id but NOT a main-instance + (ths/add-sample-shape :bad-shape + :type :frame + :parent-label :variant1 + :variant-id (thi/id :variant1) + :variant-name "") + ;; Add a child to the bad shape (to verify the repair deletes it too) + (ths/add-sample-shape :bad-child + :type :rect + :parent-label :bad-shape)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + bad-shape' (ths/get-shape file' :bad-shape) + bad-child' (ths/get-shape file' :bad-child)] + + (t/is (= 4 (count errors))) ;; The bad container also triggers other errors + (t/is (= :invalid-variant-properties (:code (nth errors 0)))) + (t/is (= :variant-not-main (:code (nth errors 1)))) + (t/is (= :variant-component-bad-name (:code (nth errors 2)))) + (t/is (= :variant-component-bad-id (:code (nth errors 3)))) + (t/is (nil? errors')) + + (t/is (nil? bad-shape')) + (t/is (nil? bad-child'))))) + +(t/deftest repair-parent-not-variant + (t/testing "detect and repair a variant shape whose parent is not a variant-container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Break the variant container + (ths/update-shape :variant1 :is-variant-container false)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + container' (ths/get-shape file' :variant1)] + + (t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container + (t/is (= :parent-not-variant (:code (first errors)))) + (t/is (= :parent-not-variant (:code (second errors)))) + (t/is (nil? errors')) + + (t/is (true? (:is-variant-container container')))))) + +(t/deftest repair-variant-main-bad-name + (t/testing "detect and repair a main instance whose name doesn't match the variant container's name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Change root1's name so it doesn't match the container + (ths/update-shape :root1 :name "WrongName")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Board" (:name root1')))))) + +(t/deftest repair-variant-main-bad-variant-name + (t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 2" :value "ValueB"}]}) + ;; Change root1's :variant-name to something wrong + (ths/update-shape :root1 :variant-name "WrongVariantName") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-variant-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Value1, ValueA" (:variant-name root1')))))) + +(t/deftest repair-variant-component-bad-name + (t/testing "detect and repair a variant component whose path/name doesn't match the container name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Update names to have path structure + (ths/update-shape :variant1 :name "Group / Subgroup / Component") + (ths/update-shape :root1 :name "Group / Subgroup / Component") + (ths/update-shape :root2 :name "Group / Subgroup / Component") + ;; Update component paths and names + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thc/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + ;; Break component1's name + (thc/update-component :component1 {:name "WrongName"})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + comp1' (thc/get-component file' :component1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-component-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Group / Subgroup" (:path comp1'))) + (t/is (= "Component" (:name comp1')))))) From 83a3d099f65c2f60ea81ab013c6d04dbc41e2352 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 4 Aug 2026 15:55:47 +0000 Subject: [PATCH 012/298] :bug: Mock RPC and timer in composable test interpreter to fix network errors The `SyncFromLibrary` op dispatches a real `sync-file` event that schedules `rx/timer 3000` + an RPC call to `update-file-library-sync-status`. In the headless test runner (no backend), this produces a network error that leaks into test output. Wrap the `check` function in `mock/with-mocks` to mock `rp/cmd!` (returning success) and `rx/timer` (firing instantly). This eliminates the 3200ms grace period in `op-grace-ms` and prevents the network error from appearing in test output. AI-assisted-by: mimo-v2.5 --- .../composable_tests/interpreter.cljs | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 0a6f3b9734..2d799246e5 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -47,11 +47,13 @@ [app.main.data.workspace.transforms :as dwt] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] + [app.main.repo :as rp] [app.main.store :as st] [beicon.v2.core :as rx] [cljs.test :as t] [frontend-tests.composable-tests.comp.nodes :as n] [frontend-tests.composable-tests.core :as tm] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) ;; -------------------------------------------------------------------------- @@ -351,17 +353,11 @@ situation))) (defn- op-grace-ms - "Extra wait AFTER an event-op has settled, before proceeding. Zero for all ops - except `SyncFromLibrary`: the production `sync-file` event additionally - schedules `rx/timer 3000` + an `:update-file-library-sync-status` RPC. There is - no backend in the headless runner, so that delayed call fails (benignly) — but - 3s after the sync it would land INSIDE whatever test is then running, leaking - an error trace across test boundaries (and historically destabilising - whole-suite runs). Waiting it out here absorbs the failure within the test that - caused it." - [op] - (let [op (if (tm/recorded-choice? op) (tm/choice-of op) op)] - (if (instance? n/SyncFromLibrary op) 3200 0))) + "Extra wait AFTER an event-op has settled, before proceeding. Always zero: + the `rx/timer` and `rp/cmd!` calls that `SyncFromLibrary` schedules are + mocked (see `check`) so they fire instantly and succeed." + [_op] + 0) (defn- run-ops "Async fold over `ops` (concrete operation units, in order — plain ops and/or @@ -464,22 +460,31 @@ references the test holds (e.g. `has-property-of` on a change node). In-file propagation is AUTOMATIC (the watcher) — no propagate op is added. + Mocks are installed for the duration of the check: `rp/cmd!` returns + success (recording calls) and `rx/timer` fires instantly, so the + `SyncFromLibrary` op's delayed RPC does not produce network errors. + Arities: `(check done case-map)` or `(check done case-map asserter)`." ([done case-map] (check done case-map nil)) ([done {:keys [setup operation]} asserter] - (let [variants (tm/enumerate operation)] - (letfn [(run-next [vs] - (if (empty? vs) - (done) - (run-variant - setup - ;; a variant is a composed operation; flatten to its ordered leaf - ;; ops. `enumerate` already removed all one-of choices, so the - ;; variant is a Sequence (or a single op). - (tm/sequence-ops (first vs)) - (fn [situation] - (when asserter - (t/testing (str "operations:\n " (tm/describe-applied situation)) - (asserter situation))) - (run-next (rest vs))))))] - (run-next variants))))) + (mock/with-mocks + {rp/cmd! mock/rpc-cmd-mock + rx/timer mock/timer-mock} + (fn [inner-done] + (let [variants (tm/enumerate operation)] + (letfn [(run-next [vs] + (if (empty? vs) + (inner-done) + (run-variant + setup + ;; a variant is a composed operation; flatten to its ordered leaf + ;; ops. `enumerate` already removed all one-of choices, so the + ;; variant is a Sequence (or a single op). + (tm/sequence-ops (first vs)) + (fn [situation] + (when asserter + (t/testing (str "operations:\n " (tm/describe-applied situation)) + (asserter situation))) + (run-next (rest vs))))))] + (run-next variants)))) + done))) From 34702fd46b2da41536282270a753cbc95cb87563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 4 Aug 2026 19:53:20 +0200 Subject: [PATCH 013/298] :whale: Remove the configuration of the admin-console from Nginx if it is not enabled --- docker/images/Dockerfile.frontend | 1 + .../files/nginx-admin-console-locations.conf.template | 7 +++++++ docker/images/files/nginx-entrypoint.sh | 11 +++++++++-- docker/images/files/nginx.conf.template | 8 -------- 4 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 docker/images/files/nginx-admin-console-locations.conf.template diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 306c42467d..a134f15f27 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -25,6 +25,7 @@ COPY $BUNDLE_PATH /var/www/app/ COPY ./files/config.js /var/www/app/js/config.js COPY ./files/nginx.conf.template /tmp/nginx.conf.template COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template +COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template new file mode 100644 index 0000000000..fead3783c9 --- /dev/null +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -0,0 +1,7 @@ +location /admin-console { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; +} diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index fe2fbc9abf..47a8a5544b 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,15 +53,22 @@ update_oidc_name /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} -export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"} if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf +if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then + export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} + envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf +else + rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf +fi + if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 8524f4fb3d..3e91a91714 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -151,14 +151,6 @@ http { proxy_pass $PENPOT_BACKEND_URI/ws/notifications; } - location /admin-console { - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $http_cf_connecting_ip; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; - } - include /etc/nginx/overrides/server.d/*.conf; location / { From c16b7919f9972525a928a9f8b4caa1e3e54331f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 4 Aug 2026 19:53:20 +0200 Subject: [PATCH 014/298] :whale: Remove the configuration of the admin-console from Nginx if it is not enabled --- docker/images/Dockerfile.frontend | 1 + .../files/nginx-admin-console-locations.conf.template | 7 +++++++ docker/images/files/nginx-entrypoint.sh | 11 +++++++++-- docker/images/files/nginx.conf.template | 8 -------- 4 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 docker/images/files/nginx-admin-console-locations.conf.template diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 306c42467d..a134f15f27 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -25,6 +25,7 @@ COPY $BUNDLE_PATH /var/www/app/ COPY ./files/config.js /var/www/app/js/config.js COPY ./files/nginx.conf.template /tmp/nginx.conf.template COPY ./files/nginx-resolvers.conf.template /tmp/resolvers.conf.template +COPY ./files/nginx-admin-console-locations.conf.template /tmp/nginx-admin-console-locations.conf.template COPY ./files/nginx-mcp-locations.conf.template /tmp/nginx-mcp-locations.conf.template COPY ./files/nginx-security-headers.conf /etc/nginx/nginx-security-headers.conf COPY ./files/nginx-mime.types /etc/nginx/mime.types diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template new file mode 100644 index 0000000000..fead3783c9 --- /dev/null +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -0,0 +1,7 @@ +location /admin-console { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_cf_connecting_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; +} diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index fe2fbc9abf..47a8a5544b 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -53,15 +53,22 @@ update_oidc_name /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} -export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-nitrate:3000} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::]:8080 default_server reuseport backlog=16384;"} if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf +if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then + export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} + envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf +else + rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf +fi + if [[ $PENPOT_FLAGS == *"enable-mcp"* ]]; then export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 8524f4fb3d..3e91a91714 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -151,14 +151,6 @@ http { proxy_pass $PENPOT_BACKEND_URI/ws/notifications; } - location /admin-console { - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $http_cf_connecting_ip; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; - } - include /etc/nginx/overrides/server.d/*.conf; location / { From 3e59754a25381fc17608ca9e822cd2284fb0c1bc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 4 Aug 2026 15:38:22 +0000 Subject: [PATCH 015/298] :sparkles: Add optional max-size param to blob decode functions Accept an optional :max-size keyword argument in blob/decode and blob/decode-str. When provided, the uncompressed size declared in the blob header is validated before allocating memory, raising an error if it exceeds the limit. Callers that do not pass :max-size are unaffected. AI-assisted-by: deepseek-v4-pro --- backend/src/app/util/blob.clj | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index afc71653bc..09df353da1 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -8,6 +8,7 @@ "A generic blob storage encoding. Mainly used for page data, page options and txlog payload storage." (:require + [app.common.exceptions :as ex] [app.common.fressian :as fres] [app.common.transit :as t] [app.config :as cf]) @@ -58,12 +59,18 @@ (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) ^bytes (encode data opts)))) (defn decode - "A function used for decode persisted blobs in the database." - [^bytes data] + "A function used for decode persisted blobs in the database. + Accepts optional keyword arguments: + :max-size — maximum allowed uncompressed size in bytes" + [^bytes data & {:keys [max-size]}] (with-open [bais (ByteArrayInputStream. data) dis (DataInputStream. bais)] (let [version (.readShort dis) ulen (.readInt dis)] + (when (and max-size (> ulen max-size)) + (ex/raise :type :validation + :code :blob-too-large + :hint "blob uncompressed size exceeds limit")) (case version 1 (decode-v1 data ulen) 3 (decode-v3 data ulen) @@ -72,9 +79,10 @@ (throw (ex-info "unsupported version" {:version version})))))) (defn decode-str - "Decode a URL-safe base64 string produced by `encode-str` back to data." - [^String s] - (decode (.decode (Base64/getUrlDecoder) s))) + "Decode a URL-safe base64 string produced by `encode-str` back to data. + Accepts the same optional keyword arguments as `decode`." + [^String s & {:as opts}] + (decode (.decode (Base64/getUrlDecoder) s) opts)) ;; --- IMPL From aeedb9626087ead44c6639a6ceb7451568772f9b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 09:41:48 +0200 Subject: [PATCH 016/298] :sparkles: Add media-processor service for image and font processing (#10767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add media-processor service for image and font processing Externalizes ImageMagick and FontForge subprocess invocations into a separate Node.js HTTP service (media-processor/). Backend dispatches via feature flag :use-remote-media-processing. Key changes: - media-processor module (TypeScript, Express 5, Sharp, FontForge/woff) - POST /api/image/info, /api/image/thumbnail, /api/font/generate - Resource limits: 128MP rejection, prlimit (512MB + 30s CPU) - Streaming multipart via SequenceInputStream - app.media split into validation (leaf), local (shell impls), remote (HTTP) - Schema enforcement: :upload and :input schemas in validation namespace - Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT) - 78 tests across 4 files (image, font, middleware, config) - FontForge path escaping for command injection prevention - Parallel font variant conversions with Promise.all AI-assisted-by: mimo-v2.5-pro * :whale: Revert docker-compose changes from media-processor commit Remove docker-compose.yaml modifications that were part of the media-processor service commit. The media-processor service definition, flags, and environment variables are reverted to their previous state. AI-assisted-by: qwen3.7-plus * :arrow_up: Update dependencies * :bug: Fix PR review issues in media-processor - Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir when input is a file path, ensuring output lands in expected location - Error preservation: execCommand preserves killed/signal/code properties from child process errors for OOM detection - Content-Length: service-multipart-request calculates and includes Content-Length header for streaming multipart requests AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor - Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY in devenv to match backend config key - Fix timeout middleware to destroy request AFTER response finishes, preventing truncated 504 responses - Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85) - Replace require('fs') with proper ES module import in upload-storage.ts - Refactor font conversion temp-dir boilerplate into withTempInput helper - Document FontForge escaping limitations (single quotes only) - Fix misleading comment in image.ts about sharp metadata decoding AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 2) - Fix queue middleware to skip next() when response already ended, preventing orphaned work after timeout - Fix hybrid storage to use disk when Content-Length is absent (chunked transfer), preventing unbounded memory allocation - Add source image format validation in generateThumbnail to reject unsupported formats (TIFF, BMP, etc.) with 400 instead of 500 - Remove dead code in convertFont for unreachable woff→woff path - Remove unused isEnabled() method from LokiLogTransport - Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type - Extract queue middleware to separate file for testability - Add comprehensive tests for queue middleware and upload storage AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 3) - Fix disk-backed upload cleanup after successful requests by adding cleanup middleware that removes temp files on response finish/close - Wrap sharp metadata/decoding errors as 400 validation errors instead of 500 internal errors - Only apply flatten() for JPEG output to preserve alpha channel in PNG and WebP outputs AI-assisted-by: qwen3.7-plus * :sparkles: Add comprehensive tests for media-processor Phase 1 - Cleanup verification: - Add cleanup middleware unit tests (6 tests) - Add HTTP upload cleanup integration tests (5 tests) Phase 2 - Error handling & alpha preservation: - Add sharp error wrapping tests (4 tests) - Add HTTP malformed image tests (2 tests) - Add alpha preservation tests (3 tests) Phase 3 - Edge cases: - Add upload storage edge case tests (3 tests) - Add queue middleware edge case tests (4 tests) Phase 4 - Backend mock verification: - Fix backend mocks to include :mtype field in image info responses - Verify all error codes match actual service behavior Total: 27 new tests added (160 tests passing) AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 4) - Add Zod validation constraints for config values (int, positive, min) - Fix auth middleware to compare Buffer byte lengths instead of string lengths - Validate requested output dimensions in generateThumbnail (crop mode) - Change queue middleware to release slot via callback in finally block - Add comprehensive tests for all fixes AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response streams in backend media remote - Wrap stream consumption in try/finally with .close() calls - Add tests to verify stream closure for info, font-convert, and thumbnail AI-assisted-by: qwen3.7-plus * :bug: Fix queue slot leak on upload failures Make releaseQueue idempotent and attach fallback listener to release slot when response finishes. This covers Multer errors that bypass the route handler's finally block, preventing permanent queue stall. AI-assisted-by: qwen3.7-plus * :bug: Cancel processing on timeout Create AbortController in timeout middleware and abort signal when timeout fires. Pass signal to Sharp and FontForge to cancel ongoing processing and release resources when request is cancelled. AI-assisted-by: qwen3.7-plus * :bug: Fix code review issues in media-processor (round 6) - Error handler: check headersSent before writing response to prevent ERR_HTTP_HEADERS_SENT when timeout already sent 504 - Timeout config: increase default requestTimeout from 60s to 180s to match font processing timeout (120s) and backend request timeout - Image processing: check abort signal before starting Sharp operations to cancel processing when timeout fires - Queue lifecycle: remove res.on('close', release) fallback to hold queue slot until processing completes, preventing concurrency limit violation when client disconnects AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response stream in download-image Wrap response body in with-open to ensure stream is closed after writing to temp file, preventing HTTP connection leaks on repeated URL imports. AI-assisted-by: qwen3.7-plus * :bug: Close HTTP response stream on validation errors in download-image Move with-open to wrap the entire validation and processing block, ensuring the response body stream is closed even when validation fails (non-2xx status, missing size, invalid media type). This prevents HTTP connection leaks on repeated failed downloads. Add test to verify stream closure on validation errors. AI-assisted-by: qwen3.7-plus * :bug: Pass abort signal to Sharp toBuffer for timeout cancellation Wrap Sharp's toBuffer() with Promise.race to check abort signal during processing. This ensures large thumbnails stop processing when the request times out, preventing wasted CPU/memory and queue capacity. Add test to verify abort during toBuffer operation. AI-assisted-by: qwen3.7-plus * :bug: Hold queue slot until Sharp completes and handle client disconnect - Remove Promise.race from generateThumbnail — Sharp processing now completes fully before queue slot is released, preventing concurrency limit violations under timeout conditions - Remove res.on("finish", release) fallback from queue middleware — error handler now explicitly calls releaseQueue in all error paths - Add res.on("close") handler in timeout middleware to abort signal when client disconnects, ensuring processing stops early - Add tests for client disconnect handling and queue slot lifecycle AI-assisted-by: qwen3.7-plus * :bug: Address round 9 review findings - Document Sharp 0.35.3 cancellation limitation in image.ts - Add integration test for timeout cleanup with large images - Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw ProcessingError on resource limit kills instead of returning null - Validate font signatures for same-format conversions to prevent arbitrary files from being persisted as valid fonts - Fix concurrent mkdtemp race in upload-storage by using shared initialization promise AI-assisted-by: qwen3.7-plus * :bug: Address round 10 review findings - Add tmpdir assertion in font.ts to prevent path injection - Preserve original error in queue middleware catch handler - Change auth middleware response type from "internal" to "authorization" - Add cleanup flag to prevent double cleanup in cleanup middleware - Move quality clamping into parseQuality function for consistency - Add integration tests for quality parameter clamping at route level - Update existing tests to match new auth response type AI-assisted-by: qwen3.7-plus * :bug: Address round 11 review findings - Extract releaseSlot helper in error-handler to reduce duplication - Remove redundant try/catch in font.ts withTempDir cleanup - Improve font path validation error message for clarity - Move path validation before try/catch to prevent swallowing - Add debug logging for cleanup failures in cleanup middleware - Inline TransportTargetSpec type alias in logger.ts - Extract logging middleware to separate file for consistency - Remove duplicate MIME validation in image thumbnail route - Add test for font path validation (outside tmpdir rejection) - Add tests for error handler queue release across all branches AI-assisted-by: qwen3.7-plus * :bug: Remove Content-Length header from multipart requests The JDK's HttpClient rejects Content-Length as a restricted header, causing IllegalArgumentException when sending multipart requests to the media-processor. Remove the explicit Content-Length header and let the JDK use chunked transfer encoding. The media-processor will use disk storage for all multipart requests (safe default behavior). Remove unused size computations (file-size, header-bytes, footer-bytes, total-size) that were only used for Content-Length. Update test to verify Content-Length is not present in request headers. AI-assisted-by: qwen3.7-plus * :bug: Fix pino ESM bundling for media-processor Mark pino and its transports (pino-pretty, pino-loki) as external to avoid bundling issues with worker thread modules that reference __dirname (not available in ES modules). AI-assisted-by: qwen3.7-plus --- .gitignore | 1 + .serena/memories/critical-info.md | 1 + .serena/memories/media-processor/core.md | 100 + backend/scripts/_env | 4 + backend/src/app/config.clj | 4 + backend/src/app/main.clj | 10 +- backend/src/app/media.clj | 517 +--- backend/src/app/media/local.clj | 426 +++ backend/src/app/media/remote.clj | 264 ++ backend/src/app/media/validation.clj | 68 + backend/src/app/rpc/commands/binfile.clj | 4 +- .../src/app/rpc/commands/files_thumbnails.clj | 14 +- backend/src/app/rpc/commands/fonts.clj | 78 +- backend/src/app/rpc/commands/media.clj | 15 +- backend/src/app/rpc/commands/profile.clj | 7 +- backend/src/app/rpc/commands/teams.clj | 8 +- backend/src/app/rpc/management/exporter.clj | 2 +- backend/src/app/rpc/management/nitrate.clj | 4 +- backend/src/app/setup.clj | 3 +- .../test/backend_tests/media_remote_test.clj | 593 ++++ backend/test/backend_tests/rpc_font_test.clj | 738 ++--- backend/test/backend_tests/rpc_media_test.clj | 37 +- backend/test/backend_tests/storage_test.clj | 29 +- common/src/app/common/flags.cljc | 3 +- docker/images/Dockerfile.media-processor | 86 + media-processor/.prettierignore | 3 + media-processor/.prettierrc | 9 + media-processor/esbuild.config.mjs | 17 + media-processor/package.json | 40 + media-processor/pnpm-lock.yaml | 2649 +++++++++++++++++ media-processor/pnpm-workspace.yaml | 2 + media-processor/scripts/build | 4 + media-processor/scripts/setup | 6 + media-processor/src/config.ts | 64 + media-processor/src/index.ts | 59 + media-processor/src/logger.ts | 135 + media-processor/src/middleware/auth.ts | 27 + media-processor/src/middleware/cleanup.ts | 25 + .../src/middleware/error-handler.ts | 55 + media-processor/src/middleware/logging.ts | 21 + media-processor/src/middleware/queue.ts | 34 + media-processor/src/middleware/timeout.ts | 35 + media-processor/src/routes/font.ts | 55 + media-processor/src/routes/health.ts | 5 + media-processor/src/routes/image.ts | 95 + media-processor/src/services/errors.ts | 26 + media-processor/src/services/font.ts | 313 ++ media-processor/src/services/image.ts | 204 ++ media-processor/src/types.ts | 44 + media-processor/src/upload-storage.ts | 89 + media-processor/src/upload.ts | 50 + media-processor/test/config.test.ts | 130 + media-processor/test/fixtures/font-1.otf | Bin 0 -> 83280 bytes media-processor/test/fixtures/font-1.ttf | Bin 0 -> 68640 bytes media-processor/test/fixtures/font-1.woff | Bin 0 -> 29836 bytes media-processor/test/fixtures/font-1.woff2 | Bin 0 -> 75876 bytes media-processor/test/font.test.ts | 310 ++ media-processor/test/image.test.ts | 909 ++++++ media-processor/test/middleware.test.ts | 681 +++++ media-processor/test/queue.test.ts | 377 +++ .../test/routes-integration.test.ts | 296 ++ media-processor/test/setup.ts | 1 + media-processor/test/upload-storage.test.ts | 212 ++ media-processor/tsconfig.json | 19 + media-processor/vitest.config.ts | 8 + 65 files changed, 8922 insertions(+), 1103 deletions(-) create mode 100644 .serena/memories/media-processor/core.md create mode 100644 backend/src/app/media/local.clj create mode 100644 backend/src/app/media/remote.clj create mode 100644 backend/src/app/media/validation.clj create mode 100644 backend/test/backend_tests/media_remote_test.clj create mode 100644 docker/images/Dockerfile.media-processor create mode 100644 media-processor/.prettierignore create mode 100644 media-processor/.prettierrc create mode 100644 media-processor/esbuild.config.mjs create mode 100644 media-processor/package.json create mode 100644 media-processor/pnpm-lock.yaml create mode 100644 media-processor/pnpm-workspace.yaml create mode 100755 media-processor/scripts/build create mode 100755 media-processor/scripts/setup create mode 100644 media-processor/src/config.ts create mode 100644 media-processor/src/index.ts create mode 100644 media-processor/src/logger.ts create mode 100644 media-processor/src/middleware/auth.ts create mode 100644 media-processor/src/middleware/cleanup.ts create mode 100644 media-processor/src/middleware/error-handler.ts create mode 100644 media-processor/src/middleware/logging.ts create mode 100644 media-processor/src/middleware/queue.ts create mode 100644 media-processor/src/middleware/timeout.ts create mode 100644 media-processor/src/routes/font.ts create mode 100644 media-processor/src/routes/health.ts create mode 100644 media-processor/src/routes/image.ts create mode 100644 media-processor/src/services/errors.ts create mode 100644 media-processor/src/services/font.ts create mode 100644 media-processor/src/services/image.ts create mode 100644 media-processor/src/types.ts create mode 100644 media-processor/src/upload-storage.ts create mode 100644 media-processor/src/upload.ts create mode 100644 media-processor/test/config.test.ts create mode 100644 media-processor/test/fixtures/font-1.otf create mode 100644 media-processor/test/fixtures/font-1.ttf create mode 100644 media-processor/test/fixtures/font-1.woff create mode 100644 media-processor/test/fixtures/font-1.woff2 create mode 100644 media-processor/test/font.test.ts create mode 100644 media-processor/test/image.test.ts create mode 100644 media-processor/test/middleware.test.ts create mode 100644 media-processor/test/queue.test.ts create mode 100644 media-processor/test/routes-integration.test.ts create mode 100644 media-processor/test/setup.ts create mode 100644 media-processor/test/upload-storage.test.ts create mode 100644 media-processor/tsconfig.json create mode 100644 media-processor/vitest.config.ts diff --git a/.gitignore b/.gitignore index 76da22b35f..382b89c92f 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ opencode.json /blob-report/ /playwright/.cache/ /render-wasm/target/ +/media-processor/dist/ /**/node_modules /**/.yarn/* /.pnpm-store diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index b032c97180..117e8ff464 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -39,6 +39,7 @@ This is a monorepo. Principles that apply to one module do *not* generally apply - `plugins/`: TypeScript plugin runtime/examples and Plugin API types; core conventions: `mem:plugins/core`. - `library/`: design library workflows; core conventions: `mem:library/core`. - `docs/`: documentation site; core workflow and conventions: `mem:docs/core`. +- `media-processor/`: TypeScript/Node.js HTTP service for image (sharp) and font (FontForge) processing; core conventions: `mem:media-processor/core`. The memory is structured in a way that you can get the critical information about the module. You can read it from `mem:/core` diff --git a/.serena/memories/media-processor/core.md b/.serena/memories/media-processor/core.md new file mode 100644 index 0000000000..209d4b1a37 --- /dev/null +++ b/.serena/memories/media-processor/core.md @@ -0,0 +1,100 @@ +# Media Processor + +Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools). + +## Tech Stack + +- Language: TypeScript +- Runtime: Node.js +- Framework: Express +- Image processing: sharp (libvips) +- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress +- Upload handling: multer (hybrid storage: memory for small, disk for large) +- Logging: pino (with optional Loki transport) +- Config validation: Zod +- Testing: Vitest +- Package Manager: pnpm + +## Project Structure + +``` +media-processor/ +├── src/ +│ ├── index.ts # Express app setup, routes, middleware +│ ├── config.ts # Zod-validated env config, HKDF key derivation +│ ├── types.ts # TypeScript type definitions +│ ├── upload.ts # Multer configuration, getFileBuffer helper +│ ├── upload-storage.ts # Hybrid storage engine (memory < threshold, disk >= threshold) +│ ├── logger.ts # Pino logger setup +│ ├── middleware/ +│ │ ├── auth.ts # Timing-safe shared key authentication +│ │ ├── error-handler.ts # ProcessingError class, centralized error handling +│ │ └── timeout.ts # Request timeout middleware +│ ├── routes/ +│ │ ├── health.ts # GET /api/health +│ │ ├── image.ts # POST /api/image/info, /api/image/thumbnail +│ │ └── font.ts # POST /api/font/convert +│ └── services/ +│ ├── image.ts # sharp-based image info/thumbnail generation +│ ├── font.ts # FontForge/woff-tools font conversion +│ └── errors.ts # throwValidation, throwRestriction, throwProcessing +├── test/ # Vitest test files +├── vitest.config.ts # Test configuration +├── tsconfig.json # TypeScript configuration +├── esbuild.config.mjs # Build configuration +└── package.json # Dependencies and scripts +``` + +## Key Conventions + +### Auth +- Requests authenticated via `x-shared-key` header using timing-safe comparison +- When no key configured, all requests rejected with 403 +- Key derived from `PENPOT_SECRET_KEY` via HKDF (blake2b512) or set directly via `PENPOT_MEDIA_PROCESSOR_SHARED_KEY` + +### Resource Limits +- Image: max pixels, max width/height enforced before processing +- Font: prlimit wraps FontForge processes with memory (AS) and CPU time limits +- Concurrency: p-queue limits concurrent requests (default 10) +- Upload: hybrid storage — memory for files < 10MB, disk for larger; configurable via `PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD` +- Max file size: configurable (default 350MB) + +### Error Handling +- `throwValidation(code, hint)` — 400 errors for invalid input +- `throwRestriction(code, hint)` — 413 errors for resource limits exceeded +- `throwProcessing(code, hint)` — 503 errors for processing failures (e.g., resource limit kills) + +### Image Processing +- EXIF orientation applied before dimension validation and thumbnail generation +- sharp caching disabled to prevent unbounded memory growth +- `withoutEnlargement: true` prevents upscaling small images + +### Font Conversion +- Supported formats: TTF, OTF, WOFF, WOFF2 +- SFNT type detected via magic bytes (0x4f54544f = OTF, 0x00010000 = TTF) +- Temp files cleaned up in finally blocks (best-effort) + +## Commands + +All commands run from `media-processor/` directory: + +- `pnpm run test` — Run Vitest test suite +- `pnpm run types:check` — TypeScript type checking (tsc --noEmit) +- `pnpm run fmt` — Format code with Prettier +- `pnpm run fmt:check` — Check formatting without modifying +- `pnpm run build` — Build for production (esbuild) +- `pnpm run start:dev` — Start development server (tsx) + +## Docker + +- Exposed port: 6065 (configurable via `PENPOT_MEDIA_PROCESSOR_PORT`) +- Must be deployed on internal Docker network only (not public-facing) +- Backend communicates via `PENPOT_MEDIA_PROCESSING_SERVICE_URI` + +## Testing Principles + +Cross-cutting testing principles and anti-patterns: `mem:testing`. + +- Run `pnpm run test` after changes +- Run `pnpm run types:check` after TypeScript changes +- Run `pnpm run fmt:check` before commits diff --git a/backend/scripts/_env b/backend/scripts/_env index 120bb648bc..04dcf1a724 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -4,6 +4,7 @@ export PENPOT_ADMIN_CONSOLE_SHARED_KEY=super-secret-nitrate-api-key export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key export PENPOT_NEXUS_SHARED_KEY=super-secret-nexus-api-key export PENPOT_SECRET_KEY=super-secret-devenv-key +export PENPOT_MEDIA_PROCESSOR_SHARED_KEY=super-secret-media-processor-key # DEPRECATED: only used for subscriptions export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key @@ -21,6 +22,8 @@ if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then __worker_flag="enable-backend-worker" fi +export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065 + export PENPOT_FLAGS="\ $PENPOT_FLAGS \ enable-login-with-password \ @@ -36,6 +39,7 @@ export PENPOT_FLAGS="\ enable-feature-fdata-objects-map \ enable-audit-log \ enable-transit-readable-response \ + disable-remote-media-processing \ enable-demo-users \ enable-user-feedback \ disable-secure-session-cookies \ diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 27ca224a88..bebd5db826 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -121,6 +121,7 @@ [:exporter-shared-key {:optional true} :string] [:admin-console-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string] + [:media-processor-shared-key {:optional true} :string] [:management-api-key {:optional true} :string] [:telemetry-uri {:optional true} :string] @@ -147,6 +148,9 @@ [:imagemagick-width-limit {:optional true} :string] [:imagemagick-height-limit {:optional true} :string] + [:media-processing-service-uri {:optional true} ::sm/uri] + [:media-processing-service-timeout {:optional true} ::sm/int] + [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 9f90d0b72f..743a17804b 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -335,6 +335,7 @@ ::rpc/rlimit (ig/ref ::rpc/rlimit) ::setup/templates (ig/ref ::setup/templates) ::setup/props (ig/ref ::setup/props) + ::setup/shared-keys (ig/ref ::setup/shared-keys) ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist) @@ -467,10 +468,11 @@ ::migrations (ig/ref :app.migrations/migrations)} ::setup/shared-keys - {::setup/props (ig/ref ::setup/props) - :nexus (cf/get :nexus-shared-key) - :admin-console (cf/get :admin-console-shared-key) - :exporter (cf/get :exporter-shared-key)} + {::setup/props (ig/ref ::setup/props) + :nexus (cf/get :nexus-shared-key) + :admin-console (cf/get :admin-console-shared-key) + :exporter (cf/get :exporter-shared-key) + :media-processor (cf/get :media-processor-shared-key)} ::setup/clock {} diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 30527857ad..dc23e3483c 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -5,316 +5,37 @@ ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns app.media - "Media & Font postprocessing." + "Media & Font postprocessing. + + This namespace is the dispatch layer only. Processing implementations + live in two separate namespaces, each owning their own defmulti: + + app.media.local — shell/ImageMagick/FontForge implementations + app.media.remote — HTTP delegation to media-processor service + + Validation and schemas live in app.media.validation (leaf namespace, + no circular dep). When adding a new :cmd type, add defmethods in + BOTH local and remote." (:require [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.exceptions :as ex] - [app.common.logging :as l] - [app.common.media :as cm] - [app.common.schema :as sm] - [app.common.schema.openapi :as-alias oapi] - [app.common.time :as ct] [app.config :as cf] [app.db :as-alias db] [app.http.client :as http] + [app.media.local :as media.local] + [app.media.remote :as media.remote] [app.media.sanitize :as sanitize] + [app.media.validation :as validation] [app.storage :as-alias sto] [app.storage.tmp :as tmp] - [app.util.shell :as shell] - [buddy.core.bytes :as bb] - [buddy.core.codecs :as bc] - [clojure.string] - [clojure.xml :as xml] [cuerdas.core :as str] - [datoteka.fs :as fs] - [datoteka.io :as io]) - (:import - clojure.lang.XMLHandler - java.io.InputStream - javax.xml.parsers.SAXParserFactory - javax.xml.XMLConstants - org.apache.commons.io.IOUtils)) - -(def schema:upload - [:map {:title "Upload"} - [:filename :string] - [:size ::sm/int] - [:path ::fs/path] - [:mtype {:optional true} :string] - [:headers {:optional true} - [:map-of :string :string]]]) - -(def ^:private schema:input - [:map {:title "Input"} - [:path ::fs/path] - [:mtype {:optional true} ::sm/text]]) - -(def check-input - (sm/check-fn schema:input)) - -(defn validate-media-type! - ([upload] (validate-media-type! upload cm/image-types)) - ([upload allowed] - (when-not (contains? allowed (:mtype upload)) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "Seems like you are uploading an invalid media object")) - - upload)) - -(defn validate-media-size! - [upload] - (let [max-size (cf/get :media-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :media-max-file-size-reached - :hint (str/ffmt "the uploaded file size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defn validate-font-size! - "Validates that the font file `upload` does not exceed the configured - `:font-max-file-size` limit. Accepts the same map shape as - `validate-media-size!` — requires a `:size` key in bytes." - [upload] - (let [max-size (cf/get :font-max-file-size)] - (when (> (:size upload) max-size) - (ex/raise :type :restriction - :code :font-max-file-size-reached - :hint (str/ffmt "the uploaded font size % is greater than the maximum %" - (:size upload) - max-size))) - upload)) - -(defmulti process (fn [_system params] (:cmd params))) - -(defmethod process :default - [_system {:keys [cmd] :as params}] - (ex/raise :type :internal - :code :not-implemented - :hint (str/fmt "No impl found for process cmd: %s" cmd))) + [datoteka.io :as io])) (defn run [system params] - (process system params)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SVG PARSING -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- secure-parser-factory - [^InputStream input ^XMLHandler handler] - (.. (doto (SAXParserFactory/newInstance) - (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) - (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) - (newSAXParser) - (parse input handler))) - -(defn- strip-doctype - [data] - (cond-> data - (str/includes? data "]*>" ""))) - -(defn- parse-svg - [text] - (let [text (strip-doctype text)] - (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] - (xml/parse istream secure-parser-factory)))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; IMAGE THUMBNAILS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:private schema:thumbnail-params - [:map {:title "ThumbnailParams"} - [:input schema:input] - [:format [:enum :jpeg :webp :png]] - [:quality [:int {:min 1 :max 100}]] - [:width :int] - [:height :int]]) - -(def ^:private check-thumbnail-params - (sm/check-fn schema:thumbnail-params)) - -;; Related info on how thumbnails generation -;; http://www.imagemagick.org/Usage/thumbnails/ - -(def ^:private imagemagick-default-env - "Default environment variables for ImageMagick resource limits. - These are the soft ceiling — policy.xml is the hard ceiling." - {"MAGICK_THREAD_LIMIT" "2" - "MAGICK_MEMORY_LIMIT" "256MiB" - "MAGICK_MAP_LIMIT" "512MiB" - "MAGICK_AREA_LIMIT" "128MP" - "MAGICK_DISK_LIMIT" "1GiB" - "MAGICK_TIME_LIMIT" "30"}) - -(defn- get-imagemagick-env - "Returns environment variables for ImageMagick commands. - Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." - [] - (let [thread (cf/get :imagemagick-thread-limit) - memory (cf/get :imagemagick-memory-limit) - map-l (cf/get :imagemagick-map-limit) - area (cf/get :imagemagick-area-limit) - disk (cf/get :imagemagick-disk-limit) - time (cf/get :imagemagick-time-limit) - width (cf/get :imagemagick-width-limit) - height (cf/get :imagemagick-height-limit)] - (cond-> imagemagick-default-env - thread (assoc "MAGICK_THREAD_LIMIT" thread) - memory (assoc "MAGICK_MEMORY_LIMIT" memory) - map-l (assoc "MAGICK_MAP_LIMIT" map-l) - area (assoc "MAGICK_AREA_LIMIT" area) - disk (assoc "MAGICK_DISK_LIMIT" disk) - time (assoc "MAGICK_TIME_LIMIT" time) - width (assoc "MAGICK_WIDTH_LIMIT" width) - height (assoc "MAGICK_HEIGHT_LIMIT" height)))) - -(defn- exec-magick! - "Execute an ImageMagick command with resource limits. - `args` is a vector of string arguments to pass to `magick`." - [system args] - (let [cmd (into ["magick"] args) - result (shell/exec! system - :cmd cmd - :env (get-imagemagick-env) - :timeout 60)] - (when (not= 0 (:exit result)) - (ex/raise :type :validation - :code :invalid-image - :hint (str "ImageMagick command failed: " (:err result)) - :cmd cmd - :exit (:exit result))) - result)) - -(defn- generic-process - [system {:keys [input format convert-args] :as params}] - (let [{:keys [path mtype]} input - format (or format (cm/mtype->format mtype)) - ext (cm/format->extension format) - tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) - args (into [(str path)] (conj (vec convert-args) (str tmp)))] - (exec-magick! system args) - (assoc params - :format format - :mtype (cm/format->mtype format) - :size (fs/size tmp) - :data tmp))) - -(defmethod process :generic-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height ">") - "-quality" (str quality)])))) - -(defmethod process :profile-thumbnail - [system params] - (let [{:keys [quality width height] :as params} - (check-thumbnail-params params)] - (generic-process system - (assoc params - :convert-args ["-auto-orient" "-strip" - "-thumbnail" (str width "x" height "^") - "-gravity" "center" - "-extent" (str width "x" height) - "-quality" (str quality)])))) - -(defn get-basic-info-from-svg - [{:keys [tag attrs] :as data}] - (when (not= tag :svg) - (ex/raise :type :validation - :code :unable-to-parse-svg - :hint "uploaded svg has invalid content")) - (reduce (fn [default f] - (if-let [res (f attrs)] - (reduced res) - default)) - {:width 100 :height 100} - [(fn parse-width-and-height - [{:keys [width height]}] - (when (and (string? width) - (string? height)) - (let [width (d/parse-double width) - height (d/parse-double height)] - (when (and width height) - {:width (int width) - :height (int height)})))) - (fn parse-viewbox - [{:keys [viewBox]}] - (let [[x y width height] (->> (str/split viewBox #"\s+" 4) - (map d/parse-double))] - (when (and x y width height) - {:width (int width) - :height (int height)})))])) - -(defn- get-dimensions-with-orientation [system ^String path] - ;; Image magick doesn't give info about exif rotation so we use the identify command - ;; If we are processing an animated gif we use the first frame with -scene 0 - (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) - orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] - (when (= 0 (:exit dim-result)) - (let [[w h] (-> (:out dim-result) - str/trim - (clojure.string/split #"\s+") - (->> (mapv #(Integer/parseInt %)))) - orientation-exit (:exit orient-result) - orientation (-> orient-result :out str/trim)] - (if (= 0 orientation-exit) - (case orientation - ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees - {:width w :height h}) ; Normal or unknown orientation - {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is - -(defmethod process :info - [system {:keys [input] :as params}] - (let [{:keys [path mtype] :as input} (check-input input)] - (if (= mtype "image/svg+xml") - (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] - (when-not info - (ex/raise :type :validation - :code :invalid-svg-file - :hint "uploaded svg does not provides dimensions")) - (merge input info {:ts (ct/now) :size (fs/size path)})) - - (let [path-str (str path) - identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) - ;; identify prints one line per frame (animated GIFs, etc.); we take the first one - mtype' (if (zero? (:exit identify-res)) - (-> identify-res - :out - str/trim - (str/split #"\s+" 2) - first - str/lower) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")) - {:keys [width height]} - (or (get-dimensions-with-orientation system path-str) - (do - (l/warn "Failed to read image dimensions with orientation" {:path path}) - (ex/raise :type :validation - :code :invalid-image - :hint "invalid image")))] - (when (and (string? mtype) - (not= (str/lower mtype) mtype')) - (ex/raise :type :validation - :code :media-type-mismatch - :hint (str "Seems like you are uploading a file whose content does not match the extension." - "Expected: " mtype ". Got: " mtype'))) - (assoc input - :width width - :height height - :size (fs/size path) - :ts (ct/now)))))) + (if (contains? cf/flags :remote-media-processing) + (media.remote/process system params) + (media.local/process system params))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMAGE HELPERS @@ -338,8 +59,8 @@ :hint "seems like the url points to resource with unknown size")) (-> {:size size :mtype mtype} - (validate-media-type!) - (validate-media-size!))))] + (validation/validate-media-type!) + (validation/validate-media-size!))))] (let [{:keys [body] :as response} (try @@ -367,188 +88,24 @@ (ex/raise :type :validation :code :unable-to-download-image :hint (str/ffmt "unable to download image from '%': I/O error" uri) - :cause cause))) + :cause cause)))] - {:keys [size mtype]} (parse-and-validate response) - path (tmp/tempfile :prefix "penpot.media.download.") - written (io/write* path body :size size)] + (if body + (with-open [body body] + (let [{:keys [size mtype]} (parse-and-validate response) + path (tmp/tempfile :prefix "penpot.media.download.") + written (io/write* path body :size size)] - (when (not= written size) - (ex/raise :type :internal - :code :mismatch-write-size - :hint "unexpected state: unable to write to file")) + (when (not= written size) + (ex/raise :type :internal + :code :mismatch-write-size + :hint "unexpected state: unable to write to file")) - ;; Sanitize: strip trailing data after image EOF markers - (let [new-size (sanitize/truncate-after-eof path mtype)] - {:path path - :mtype mtype - :size new-size})))) + ;; Sanitize: strip trailing data after image EOF markers + (let [new-size (sanitize/truncate-after-eof path mtype)] + {:path path + :mtype mtype + :size new-size}))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; FONTS -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- get-font-prlimit - "Returns resource limits for font processing tools, read from config." - [] - {:mem (cf/get :font-process-mem) - :cpu (cf/get :font-process-cpu)}) - -(defn- get-font-timeout - "Returns the wall-clock timeout for font processing, read from config." - [] - (cf/get :font-process-timeout)) - -(defn- exec-font! - "Execute a font processing command with resource limits. - `args` is a vector of string arguments." - [system args] - (shell/exec! system - :cmd args - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))) - -(defmethod process :generate-fonts - [system {:keys [input] :as params}] - (letfn [(ttf->otf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".otf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (otf->ttf [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" - (str/fmt "Open('%s'); Generate('%s')" - (str finput) - (str foutput))])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (ttf-or-otf->woff [data] - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") - foutput (fs/path (str finput ".woff"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["sfnt2woff" (str finput)])] - (when (zero? (:exit res)) - foutput)) - (finally - (fs/delete finput))))) - - (woff->sfnt [data] - (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] - (try - (io/write* finput data) - (let [res (shell/exec! system - :cmd ["woff2sfnt" (str finput)] - :out-enc :bytes - :prlimit (get-font-prlimit) - :timeout (get-font-timeout))] - (when (zero? (:exit res)) - (:out res))) - (finally - (fs/delete finput))))) - - (woff2->sfnt [data] - ;; woff2_decompress outputs to same directory with .ttf extension - (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") - foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] - (try - (io/write* finput data) - (let [res (exec-font! system ["woff2_decompress" (str finput)])] - (if (zero? (:exit res)) - foutput - (do - (when (fs/exists? foutput) - (fs/delete foutput)) - nil))) - (finally - (fs/delete finput))))) - - ;; Documented here: - ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory - (get-sfnt-type [data] - (let [buff (bb/slice data 0 4) - type (bc/bytes->hex buff)] - (case type - "4f54544f" :otf - "00010000" :ttf - (ex/raise :type :internal - :code :unexpected-data - :hint "unexpected font data")))) - - (gen-if-nil [val factory] - (if (nil? val) - (factory) - val))] - - (let [current (into #{} (keys input))] - (cond - (contains? current "font/ttf") - (let [data (get input "font/ttf")] - (-> input - (update "font/otf" gen-if-nil #(ttf->otf data)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) - - (contains? current "font/otf") - (let [data (get input "font/otf")] - (-> input - (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) - (assoc "font/ttf" (otf->ttf data)))) - - (contains? current "font/woff") - (let [data (get input "font/woff") - sfnt (woff->sfnt data)] - (when-not sfnt - (ex/raise :type :validation - :code :invalid-woff-file - :hint "invalid woff file")) - (let [stype (get-sfnt-type sfnt)] - (cond-> input - true - (-> (assoc "font/woff" data)) - - (= stype :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt))) - - (= stype :ttf) - (-> (assoc "font/otf" (ttf->otf sfnt)) - (assoc "font/ttf" sfnt))))) - - (contains? current "font/woff2") - (let [data (get input "font/woff2") - foutput (woff2->sfnt data)] - (when-not foutput - (ex/raise :type :validation - :code :invalid-woff2-file - :hint "invalid woff2 file")) - (try - (let [sfnt (io/read* foutput) - type (get-sfnt-type sfnt)] - (cond-> input - (= type :otf) - (-> (assoc "font/otf" sfnt) - (assoc "font/ttf" (otf->ttf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) - - (= type :ttf) - (-> (assoc "font/ttf" sfnt) - (assoc "font/otf" (ttf->otf sfnt)) - (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) - (finally - (fs/delete foutput)))))))) + ;; No body - validation will raise appropriate error + (parse-and-validate response))))) diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj new file mode 100644 index 0000000000..b53c5a5f6d --- /dev/null +++ b/backend/src/app/media/local.clj @@ -0,0 +1,426 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.media.local + "Local media processing via ImageMagick and FontForge shell commands." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.media :as cm] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.config :as cf] + [app.media.validation :as validation] + [app.storage.tmp :as tmp] + [app.util.shell :as shell] + [buddy.core.bytes :as bb] + [buddy.core.codecs :as bc] + [clojure.string] + [clojure.xml :as xml] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io]) + (:import + clojure.lang.XMLHandler + java.io.InputStream + javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants + org.apache.commons.io.IOUtils)) + +(defmulti process (fn [_system params] (:cmd params))) + +(defmethod process :default + [_system {:keys [cmd] :as params}] + (ex/raise :type :internal + :code :not-implemented + :hint (str/fmt "No impl found for local process cmd: %s" cmd))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG PARSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- secure-parser-factory + [^InputStream input ^XMLHandler handler] + (.. (doto (SAXParserFactory/newInstance) + (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) + (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) + (newSAXParser) + (parse input handler))) + +(defn- strip-doctype + [data] + (cond-> data + (str/includes? data "]*>" ""))) + +(defn parse-svg + [text] + (let [text (strip-doctype text)] + (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] + (xml/parse istream secure-parser-factory)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; IMAGE THUMBNAILS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:thumbnail-params + [:map {:title "ThumbnailParams"} + [:input validation/schema:input] + [:format [:enum :jpeg :webp :png]] + [:quality [:int {:min 1 :max 100}]] + [:width :int] + [:height :int]]) + +(def ^:private check-thumbnail-params + (sm/check-fn schema:thumbnail-params)) + +;; Related info on how thumbnails generation +;; http://www.imagemagick.org/Usage/thumbnails/ + +(def ^:private imagemagick-default-env + "Default environment variables for ImageMagick resource limits. + These are the soft ceiling — policy.xml is the hard ceiling." + {"MAGICK_THREAD_LIMIT" "2" + "MAGICK_MEMORY_LIMIT" "256MiB" + "MAGICK_MAP_LIMIT" "512MiB" + "MAGICK_AREA_LIMIT" "128MP" + "MAGICK_DISK_LIMIT" "1GiB" + "MAGICK_TIME_LIMIT" "30"}) + +(defn- get-imagemagick-env + "Returns environment variables for ImageMagick commands. + Reads individual PENPOT_IMAGEMAGICK_* config values, falling back to defaults." + [] + (let [thread (cf/get :imagemagick-thread-limit) + memory (cf/get :imagemagick-memory-limit) + map-l (cf/get :imagemagick-map-limit) + area (cf/get :imagemagick-area-limit) + disk (cf/get :imagemagick-disk-limit) + time (cf/get :imagemagick-time-limit) + width (cf/get :imagemagick-width-limit) + height (cf/get :imagemagick-height-limit)] + (cond-> imagemagick-default-env + thread (assoc "MAGICK_THREAD_LIMIT" thread) + memory (assoc "MAGICK_MEMORY_LIMIT" memory) + map-l (assoc "MAGICK_MAP_LIMIT" map-l) + area (assoc "MAGICK_AREA_LIMIT" area) + disk (assoc "MAGICK_DISK_LIMIT" disk) + time (assoc "MAGICK_TIME_LIMIT" time) + width (assoc "MAGICK_WIDTH_LIMIT" width) + height (assoc "MAGICK_HEIGHT_LIMIT" height)))) + +(defn- exec-magick! + "Execute an ImageMagick command with resource limits. + `args` is a vector of string arguments to pass to `magick`." + [system args] + (let [cmd (into ["magick"] args) + result (shell/exec! system + :cmd cmd + :env (get-imagemagick-env) + :timeout 60)] + (when (not= 0 (:exit result)) + (ex/raise :type :validation + :code :invalid-image + :hint (str "ImageMagick command failed: " (:err result)) + :cmd cmd + :exit (:exit result))) + result)) + +(defn- generic-process + [system {:keys [input format convert-args] :as params}] + (let [{:keys [path mtype]} input + format (or format (cm/mtype->format mtype)) + ext (cm/format->extension format) + tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) + args (into [(str path)] (conj (vec convert-args) (str tmp)))] + (exec-magick! system args) + (assoc params + :format format + :mtype (cm/format->mtype format) + :size (fs/size tmp) + :data tmp))) + +(defmethod process :generic-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height ">") + "-quality" (str quality)])))) + +(defmethod process :profile-thumbnail + [system params] + (let [{:keys [quality width height] :as params} + (check-thumbnail-params params)] + (generic-process system + (assoc params + :convert-args ["-auto-orient" "-strip" + "-thumbnail" (str width "x" height "^") + "-gravity" "center" + "-extent" (str width "x" height) + "-quality" (str quality)])))) + +(defn get-basic-info-from-svg + [{:keys [tag attrs] :as data}] + (when (not= tag :svg) + (ex/raise :type :validation + :code :unable-to-parse-svg + :hint "uploaded svg has invalid content")) + (reduce (fn [default f] + (if-let [res (f attrs)] + (reduced res) + default)) + {:width 100 :height 100} + [(fn parse-width-and-height + [{:keys [width height]}] + (when (and (string? width) + (string? height)) + (let [width (d/parse-double width) + height (d/parse-double height)] + (when (and width height) + {:width (int width) + :height (int height)})))) + (fn parse-viewbox + [{:keys [viewBox]}] + (let [[x y width height] (->> (str/split viewBox #"\s+" 4) + (map d/parse-double))] + (when (and x y width height) + {:width (int width) + :height (int height)})))])) + +(defn- get-dimensions-with-orientation [system ^String path] + ;; Image magick doesn't give info about exif rotation so we use the identify command + ;; If we are processing an animated gif we use the first frame with -scene 0 + (let [dim-result (exec-magick! system ["identify" "-format" "%w %h\n" path]) + orient-result (exec-magick! system ["identify" "-format" "%[EXIF:Orientation]\n" path])] + (when (= 0 (:exit dim-result)) + (let [[w h] (-> (:out dim-result) + str/trim + (clojure.string/split #"\s+") + (->> (mapv #(Integer/parseInt %)))) + orientation-exit (:exit orient-result) + orientation (-> orient-result :out str/trim)] + (if (= 0 orientation-exit) + (case orientation + ("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees + {:width w :height h}) ; Normal or unknown orientation + {:width w :height h}))))) ; If orientation can't be read, use dimensions as-is + +(defmethod process :info + [system {:keys [input] :as params}] + (let [{:keys [path mtype] :as input} (validation/check-input input)] + (if (= mtype "image/svg+xml") + (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] + (when-not info + (ex/raise :type :validation + :code :invalid-svg-file + :hint "uploaded svg does not provides dimensions")) + (merge input info {:ts (ct/now) :size (fs/size path)})) + + (let [path-str (str path) + identify-res (exec-magick! system ["identify" "-format" "image/%[magick]\n" path-str]) + ;; identify prints one line per frame (animated GIFs, etc.); we take the first one + mtype' (if (zero? (:exit identify-res)) + (-> identify-res + :out + str/trim + (str/split #"\s+" 2) + first + str/lower) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")) + {:keys [width height]} + (or (get-dimensions-with-orientation system path-str) + (do + (l/warn "Failed to read image dimensions with orientation" {:path path}) + (ex/raise :type :validation + :code :invalid-image + :hint "invalid image")))] + (when (and (string? mtype) + (not= (str/lower mtype) mtype')) + (ex/raise :type :validation + :code :media-type-mismatch + :hint (str "Seems like you are uploading a file whose content does not match the extension." + "Expected: " mtype ". Got: " mtype'))) + (assoc input + :width width + :height height + :size (fs/size path) + :ts (ct/now)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; FONTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-font-prlimit + "Returns resource limits for font processing tools, read from config." + [] + {:mem (cf/get :font-process-mem) + :cpu (cf/get :font-process-cpu)}) + +(defn- get-font-timeout + "Returns the wall-clock timeout for font processing, read from config." + [] + (cf/get :font-process-timeout)) + +(defn- exec-font! + "Execute a font processing command with resource limits. + `args` is a vector of string arguments." + [system args] + (shell/exec! system + :cmd args + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))) + +(defmethod process :generate-fonts + [system {:keys [input] :as params}] + (letfn [(ttf->otf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".otf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (otf->ttf [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["fontforge" "-lang=ff" "-c" + (str/fmt "Open('%s'); Generate('%s')" + (str finput) + (str foutput))])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (ttf-or-otf->woff [data] + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix "") + foutput (fs/path (str finput ".woff"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["sfnt2woff" (str finput)])] + (when (zero? (:exit res)) + foutput)) + (finally + (fs/delete finput))))) + + (woff->sfnt [data] + (let [finput (tmp/tempfile :prefix "penpot" :suffix "")] + (try + (io/write* finput data) + (let [res (shell/exec! system + :cmd ["woff2sfnt" (str finput)] + :out-enc :bytes + :prlimit (get-font-prlimit) + :timeout (get-font-timeout))] + (when (zero? (:exit res)) + (:out res))) + (finally + (fs/delete finput))))) + + (woff2->sfnt [data] + ;; woff2_decompress outputs to same directory with .ttf extension + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") + foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] + (try + (io/write* finput data) + (let [res (exec-font! system ["woff2_decompress" (str finput)])] + (if (zero? (:exit res)) + foutput + (do + (when (fs/exists? foutput) + (fs/delete foutput)) + nil))) + (finally + (fs/delete finput))))) + + ;; Documented here: + ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory + (get-sfnt-type [data] + (let [buff (bb/slice data 0 4) + type (bc/bytes->hex buff)] + (case type + "4f54544f" :otf + "00010000" :ttf + (ex/raise :type :internal + :code :unexpected-data + :hint "unexpected font data")))) + + (gen-if-nil [val factory] + (if (nil? val) + (factory) + val))] + + (let [current (into #{} (keys input))] + (cond + (contains? current "font/ttf") + (let [data (get input "font/ttf")] + (-> input + (update "font/otf" gen-if-nil #(ttf->otf data)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)))) + + (contains? current "font/otf") + (let [data (get input "font/otf")] + (-> input + (update "font/woff" gen-if-nil #(ttf-or-otf->woff data)) + (assoc "font/ttf" (otf->ttf data)))) + + (contains? current "font/woff") + (let [data (get input "font/woff") + sfnt (woff->sfnt data)] + (when-not sfnt + (ex/raise :type :validation + :code :invalid-woff-file + :hint "invalid woff file")) + (let [stype (get-sfnt-type sfnt)] + (cond-> input + true + (-> (assoc "font/woff" data)) + + (= stype :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt))) + + (= stype :ttf) + (-> (assoc "font/otf" (ttf->otf sfnt)) + (assoc "font/ttf" sfnt))))) + + (contains? current "font/woff2") + (let [data (get input "font/woff2") + foutput (woff2->sfnt data)] + (when-not foutput + (ex/raise :type :validation + :code :invalid-woff2-file + :hint "invalid woff2 file")) + (try + (let [sfnt (io/read* foutput) + type (get-sfnt-type sfnt)] + (cond-> input + (= type :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) + + (= type :ttf) + (-> (assoc "font/ttf" sfnt) + (assoc "font/otf" (ttf->otf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) + (finally + (fs/delete foutput)))))))) diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj new file mode 100644 index 0000000000..447d5f2e55 --- /dev/null +++ b/backend/src/app/media/remote.clj @@ -0,0 +1,264 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.media.remote + "Remote media processing via the media-processor HTTP service." + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.time :as ct] + [app.common.uri :as uri] + [app.config :as cf] + [app.http.client :as http] + [app.media.local :as local] + [app.media.validation :as validation] + [app.setup :as-alias setup] + [app.storage.tmp :as tmp] + [app.util.json :as json] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io]) + (:import + java.io.ByteArrayInputStream + java.io.InputStream + java.io.SequenceInputStream + java.net.ConnectException + java.net.http.HttpTimeoutException + java.util.Collections)) + +(defn- service-base-url + "Returns the base URL of the media-processor service." + [] + (or (cf/get :media-processing-service-uri) + (ex/raise :type :internal + :code :media-processor-not-configured + :hint "PENPOT_MEDIA_PROCESSING_SERVICE_URI is not configured"))) + +(defn- service-timeout + "Returns the HTTP timeout (ms) for media-processor requests." + [] + (or (cf/get :media-processing-service-timeout) + 120000)) + +(defn- get-shared-key + "Returns the shared key for authenticating with the media-processor." + [system] + (-> system ::setup/shared-keys :media-processor)) + +(defn- parse-json-response + "Parse a JSON response body." + [body] + (json/read! body)) + +(defn- translate-error + "Translate a media-processor error response into a Penpot exception." + [status body] + (let [code (or (:code body) "media-processor-error") + hint (or (:hint body) "media-processor request failed")] + (case status + 400 {:type :validation :code (keyword code) :hint hint} + 403 {:type :authorization :code :forbidden :hint hint} + 413 {:type :restriction :code (keyword code) :hint hint} + 504 {:type :internal :code :media-processor-timeout :hint hint} + {:type :internal :code (keyword code) :hint hint}))) + +(defn service-request + "Make an HTTP request to the media-processor service." + [system {:keys [method uri body headers timeout]}] + (let [client (::http/client system) + timeout (or timeout (service-timeout))] + (try + (let [resp (http/req client + {:method method + :uri uri + :body body + :headers headers} + {:response-type :input-stream + :skip-ssrf-check? true + :timeout timeout}) + status (:status resp)] + (when (not (<= 200 status 299)) + (let [body (:body resp)] + (try + (let [parsed (try (parse-json-response body) (catch Exception _ nil)) + err (translate-error status parsed)] + (ex/raise :type (:type err) :code (:code err) :hint (:hint err))) + (finally + (.close body))))) + resp) + (catch ConnectException _cause + (ex/raise :type :internal + :code :media-processor-unavailable + :hint "Cannot connect to media-processor service")) + (catch HttpTimeoutException _cause + (ex/raise :type :internal + :code :media-processor-timeout + :hint "media-processor service request timed out"))))) + +(defn- multipart-boundary + [] + (str "----PenpotBoundary" (System/currentTimeMillis))) + +(defn- build-multipart-stream + "Build a streaming multipart/form-data body with a single file field. + Returns an InputStream that lazily reads from the file on demand." + [^String boundary mtype ^InputStream file-stream] + (let [header (.getBytes (str "--" boundary "\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n" + "Content-Type: " mtype "\r\n" + "\r\n") + "UTF-8") + footer (.getBytes (str "\r\n--" boundary "--\r\n") + "UTF-8") + parts (Collections/enumeration + [(ByteArrayInputStream. header) + file-stream + (ByteArrayInputStream. footer)])] + (SequenceInputStream. parts))) + +(defn- service-multipart-request + "Send a multipart request to the media-processor service. + Accepts a file from disk via :path. The file stream is closed + after the HTTP request completes (success or failure)." + [system {:keys [endpoint path mtype query timeout]}] + (let [shared-key (get-shared-key system) + boundary (multipart-boundary) + ctype (or mtype "application/octet-stream") + base-url (service-base-url) + request-uri (cond-> (uri/join base-url endpoint) + (seq query) + (str "?" (uri/map->query-string query)))] + (with-open [file-stream (io/input-stream path)] + (let [body (build-multipart-stream boundary ctype file-stream)] + (service-request system + {:method :post + :uri request-uri + :body body + :headers {"Content-Type" (str "multipart/form-data; boundary=" boundary) + "x-shared-key" shared-key} + :timeout timeout}))))) + +(def ^:private known-font-types + "Priority-ordered list of font mime-types the system knows how to convert. + Order matters: when a font upload contains multiple variants, the first + match becomes the conversion source (ttf preferred for best coverage)." + ["font/ttf" "font/otf" "font/woff" "font/woff2"]) + +(defn- font-convert + "Convert a font to the given target mime-type via the media-processor service. + Accepts source font data as a filesystem Path. Returns a tempfile Path." + [system source-mtype target-mtype data] + (let [resp (service-multipart-request system {:endpoint "api/font/convert" + :path data + :mtype source-mtype + :query {:target-type target-mtype} + :timeout 180000}) + ext (cm/mtype->extension target-mtype) + tmp (tmp/tempfile :prefix "penpot.font." :suffix ext) + body (:body resp)] + (try + (io/write* tmp body) + (finally + (.close body))) + tmp)) + +(defn- font-missing-variants + "Return the set of target mime-types that should be generated for the given + source mime-type (excluding font/woff2, which is never generated)." + [source-mtype] + (case source-mtype + "font/ttf" #{"font/otf" "font/woff"} + "font/otf" #{"font/ttf" "font/woff"} + "font/woff" #{"font/ttf" "font/otf"} + "font/woff2" #{"font/ttf" "font/otf" "font/woff"})) + +(defmulti process (fn [_system params] (:cmd params))) + +(defmethod process :info + [system {:keys [input]}] + (let [{:keys [path mtype]} (validation/check-input input)] + (if (= mtype "image/svg+xml") + ;; SVG: parse locally (Sharp doesn't support SVG) + (let [info (some-> path slurp local/parse-svg local/get-basic-info-from-svg)] + (when-not info + (ex/raise :type :validation + :code :invalid-svg-file + :hint "uploaded svg does not provide dimensions")) + (merge input info {:ts (ct/now) :size (fs/size path)})) + ;; Raster: delegate to media-processor + (let [resp (service-multipart-request system {:endpoint "api/image/info" + :path path + :mtype mtype}) + body (:body resp)] + (try + (let [info (parse-json-response body) + detected-mtype (:mtype info)] + (when (and (string? mtype) + (string? detected-mtype) + (not= (str/lower mtype) (str/lower detected-mtype))) + (ex/raise :type :validation + :code :media-type-mismatch + :hint (str "File content does not match the declared type. " + "Expected: " mtype ". Got: " detected-mtype))) + (assoc input + :width (:width info) + :height (:height info) + :size (fs/size path) + :ts (ct/now))) + (finally + (.close body))))))) + +(defn- thumbnail-request + "Shared implementation for generic-thumbnail and profile-thumbnail." + [system params mode] + (let [{:keys [input format quality width height]} params + {:keys [path mtype]} (validation/check-input input) + fmt (name (or format (cm/mtype->format mtype) :jpeg)) + resp (service-multipart-request system {:endpoint "api/image/thumbnail" + :path path + :mtype mtype + :query {:width width + :height height + :quality quality + :format fmt + :mode mode}}) + out-format (or format (cm/mtype->format mtype) :jpeg) + ext (cm/format->extension out-format) + tmp (tmp/tempfile :prefix "penpot.media." :suffix ext) + body (:body resp)] + (try + (io/write* tmp body) + (finally + (.close body))) + (assoc params + :format out-format + :mtype (cm/format->mtype out-format) + :size (fs/size tmp) + :data tmp))) + +(defmethod process :generic-thumbnail + [system params] + (thumbnail-request system params "fit")) + +(defmethod process :profile-thumbnail + [system params] + (thumbnail-request system params "crop")) + +(defmethod process :generate-fonts + [system {:keys [input]}] + (let [source-mtype (or (some #(when (contains? input %) %) known-font-types) + (ex/raise :type :validation + :code :invalid-font + :hint "No recognized font variant in input")) + data (get input source-mtype) + present (set (keys input)) + targets (remove present (font-missing-variants source-mtype))] + (reduce (fn [acc target-mtype] + (assoc acc target-mtype + (font-convert system source-mtype target-mtype data))) + input + targets))) + diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj new file mode 100644 index 0000000000..17dbd80e71 --- /dev/null +++ b/backend/src/app/media/validation.clj @@ -0,0 +1,68 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.media.validation + "Schemas and validation functions for media uploads. + Leaf namespace — depends on app.common.* and app.config only." + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.schema :as sm] + [app.config :as cf] + [cuerdas.core :as str] + [datoteka.fs :as fs])) + +(def schema:upload + [:map {:title "Upload"} + [:filename :string] + [:size ::sm/int] + [:path ::fs/path] + [:mtype {:optional true} :string] + [:headers {:optional true} + [:map-of :string :string]]]) + +(def schema:input + [:map {:title "Input"} + [:path ::fs/path] + [:mtype {:optional true} ::sm/text]]) + +(def check-input + (sm/check-fn schema:input)) + +(defn validate-media-type! + ([upload] (validate-media-type! upload cm/image-types)) + ([upload allowed] + (when-not (contains? allowed (:mtype upload)) + (ex/raise :type :validation + :code :media-type-not-allowed + :hint "Seems like you are uploading an invalid media object")) + + upload)) + +(defn validate-media-size! + [upload] + (let [max-size (cf/get :media-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :media-max-file-size-reached + :hint (str/ffmt "the uploaded file size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) + +(defn validate-font-size! + "Validates that the font file `upload` does not exceed the configured + `:font-max-file-size` limit. Accepts the same map shape as + `validate-media-size!` — requires a `:size` key in bytes." + [upload] + (let [max-size (cf/get :font-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :font-max-file-size-reached + :hint (str/ffmt "the uploaded font size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 79b0bf7cf9..74101eadbc 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -19,7 +19,7 @@ [app.http.sse :as sse] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.media :as media-cmd] @@ -124,7 +124,7 @@ [:project-id ::sm/uuid] [:file-id {:optional true} ::sm/uuid] [:version {:optional true} ::sm/int] - [:file {:optional true} media/schema:upload] + [:file {:optional true} media.v/schema:upload] [:upload-id {:optional true} ::sm/uuid]] [:fn {:error/message "one of :file or :upload-id is required"} (fn [{:keys [file upload-id]}] diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 46a4bc04ac..f4d9b538cb 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -21,7 +21,7 @@ [app.db.sql :as-alias sql] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] @@ -275,7 +275,7 @@ [:map {:title "create-file-object-thumbnail"} [:file-id ::sm/uuid] [:object-id [:string {:max 250}]] - [:media media/schema:upload] + [:media media.v/schema:upload] [:tag {:optional true} [:string {:max 50}]]]) (sv/defmethod ::create-file-object-thumbnail @@ -289,8 +289,8 @@ ::sm/params schema:create-file-object-thumbnail} [cfg {:keys [::rpc/profile-id file-id object-id media tag]}] - (media/validate-media-type! media) - (media/validate-media-size! media) + (media.v/validate-media-type! media) + (media.v/validate-media-size! media) (db/run! cfg files/check-edition-permissions! profile-id file-id) (when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})] @@ -379,7 +379,7 @@ [:map {:title "create-file-thumbnail"} [:file-id ::sm/uuid] [:revn ::sm/int] - [:media media/schema:upload]]) + [:media media.v/schema:upload]]) (sv/defmethod ::create-file-thumbnail "Creates or updates the file thumbnail. Mainly used for paint the @@ -394,8 +394,8 @@ ::sm/params schema:create-file-thumbnail} [cfg {:keys [::rpc/profile-id file-id] :as params}] - (media/validate-media-type! (:media params)) - (media/validate-media-size! (:media params)) + (media.v/validate-media-type! (:media params)) + (media.v/validate-media-size! (:media params)) (db/run! cfg files/check-edition-permissions! profile-id file-id) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 4d9eb77636..0ca38ae7fd 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -21,6 +21,7 @@ [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] @@ -38,10 +39,7 @@ [datoteka.fs :as fs] [datoteka.io :as io]) (:import - java.io.InputStream java.io.OutputStream - java.io.SequenceInputStream - java.util.Collections java.util.zip.ZipEntry java.util.zip.ZipOutputStream)) @@ -96,18 +94,13 @@ (declare create-font-variant) (def ^:private schema:create-font-variant - [:and - [:map {:title "create-font-variant"} - [:team-id ::sm/uuid] - [:font-id ::sm/uuid] - [:font-family types.font/schema:font-family] - [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]] - [:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]] - [:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]] - [:fn {:error/message "one of :data or :uploads is required"} - (fn [{:keys [data uploads]}] - (or (seq data) (seq uploads)))]]) + [:map {:title "create-font-variant"} + [:team-id ::sm/uuid] + [:font-id ::sm/uuid] + [:font-family types.font/schema:font-family] + [:font-weight [::sm/one-of {:format "number"} valid-weight]] + [:font-style [::sm/one-of {:format "string"} valid-style]] + [:uploads [:map-of ::sm/text ::sm/uuid]]]) (defn- prepare-font-data-from-uploads "Assembles each chunked-upload session in `uploads` (a `{mtype → @@ -118,8 +111,8 @@ (fn [acc mtype session-id] (let [assembled (assemble-chunks cfg session-id)] (-> {:mtype mtype :size (:size assembled)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) + (media.v/validate-media-type! cm/font-types) + (media.v/validate-font-size!)) (assoc acc mtype (:path assembled)))) {} uploads)] @@ -128,54 +121,23 @@ (assoc :data data) (dissoc :uploads)))) -(defn- prepare-font-data-from-legacy - "Validates the media type and size of every entry in the legacy - `:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every - entry to a tempfile. Returns params with a normalised - `{mtype → path}` data map." - [{:keys [data] :as params}] - (let [data (reduce-kv - (fn [acc mtype content] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - chunks (if (vector? content) content [content]) - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - - ;; Generate the tempfile from all chunks - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - - ;; Validate - (-> {:mtype mtype :size (fs/size tmp)} - (media/validate-media-type! cm/font-types) - (media/validate-font-size!)) - - (assoc acc mtype tmp))) - {} - data)] - (assoc params :data data))) - (sv/defmethod ::create-font-variant - "Upload a font variant. Font data may be provided either as a - Transit-encoded `:data` map (keyed by mime-type) for small fonts, or - as an `:uploads` map (keyed by mime-type, values are upload-session - UUIDs from the chunked-upload API) for large fonts. Exactly one of - the two must be present." + "Upload a font variant. Font data must be provided as an `:uploads` + map (keyed by mime-type, values are upload-session UUIDs from the + chunked-upload API)." {::doc/added "1.18" - ::doc/changes ["2.16" "Add :uploads param for chunked upload support"] + ::doc/changes [["2.16" "Add :uploads param for chunked upload support"] + ["2.18" "Remove :data param, use :uploads exclusively"]] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] (teams/check-edition-permissions! pool profile-id team-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (let [params (if (some? uploads) - (db/tx-run! cfg prepare-font-data-from-uploads params) - (prepare-font-data-from-legacy params))] + (let [params (db/tx-run! cfg prepare-font-data-from-uploads params)] (create-font-variant cfg (assoc params :profile-id profile-id)))) (defn create-font-variant @@ -229,9 +191,7 @@ (let [tpoint (ct/tpoint) mtypes (vec (keys data)) total-size (reduce-kv (fn [acc _ content] - (+ acc (if (bytes? content) - (alength ^bytes content) - (fs/size content)))) + (+ acc (fs/size content))) 0 data)] @@ -370,7 +330,7 @@ (defn- make-temporal-storage-object [cfg profile-id content] (let [storage (sto/resolve cfg) - content (media/check-input content) + content (media.v/check-input content) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) (sto/wrap-with-hash hash)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index ff8add456a..383cd5d115 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -16,6 +16,7 @@ [app.db :as db] [app.loggers.audit :as-alias audit] [app.media :as media] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] [app.rpc.commands.files :as files] @@ -44,7 +45,7 @@ [:file-id ::sm/uuid] [:is-local ::sm/boolean] [:name [:string {:max 250}]] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (sv/defmethod ::upload-file-media-object {::doc/added "1.17" @@ -53,8 +54,8 @@ [:process-image/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}] (files/check-edition-permissions! pool profile-id file-id) - (media/validate-media-type! content) - (media/validate-media-size! content) + (media.v/validate-media-type! content) + (media.v/validate-media-size! content) (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if @@ -315,7 +316,7 @@ [:map {:title "upload-chunk"} [:session-id ::sm/uuid] [:index ::sm/int] - [:content media/schema:upload]]) + [:content media.v/schema:upload]]) (def ^:private schema:upload-chunk-result [:map {:title "upload-chunk-result"} @@ -386,7 +387,7 @@ (defn assemble-chunks "Validates that all expected chunks are present for `session-id` and concatenates them into a single temporary file. Returns a map - conforming to `media/schema:upload` with `:filename`, `:path` and + conforming to `media.v/schema:upload` with `:filename`, `:path` and `:size`. Raises a :validation/:missing-chunks error when the number of stored @@ -440,8 +441,8 @@ content (-> content (assoc :filename (str "upload:" name)) (assoc :mtype mtype) - (media/validate-media-type!) - (media/validate-media-size!)) + (media.v/validate-media-type!) + (media.v/validate-media-size!)) mobj (create-file-media-object cfg (assoc params :id id :from-chunks? true diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index a26fc9ea9e..ed4d22f445 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -21,6 +21,7 @@ [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.climit :as climit] @@ -280,7 +281,7 @@ (def ^:private schema:update-profile-photo [:map {:title "update-profile-photo"} - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-profile-photo {:doc/added "1.1" @@ -288,8 +289,8 @@ ::sm/result :nil} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 76d9b162c5..9277a803c1 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -22,7 +22,7 @@ [app.features.logical-deletion :as ldel] [app.loggers.audit :as audit] [app.main :as-alias main] - [app.media :as media] + [app.media.validation :as media.v] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -979,7 +979,7 @@ (def ^:private schema:update-team-photo [:map {:title "update-team-photo"} [:team-id ::sm/uuid] - [:file media/schema:upload]]) + [:file media.v/schema:upload]]) (sv/defmethod ::update-team-photo {::doc/added "1.17" @@ -987,8 +987,8 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type - (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) - (media/validate-media-size! file) + (media.v/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media.v/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 20e791e7d0..aac508669d 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -10,7 +10,7 @@ [app.common.time :as ct] [app.common.uri :as u] [app.config :as cf] - [app.media :refer [schema:upload]] + [app.media.validation :refer [schema:upload]] [app.rpc :as-alias rpc] [app.rpc.doc :as doc] [app.storage :as sto] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index e36b04fbb0..a87dd74ccb 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -24,7 +24,7 @@ [app.http :as-alias http] [app.http.session :as session] [app.loggers.audit :as audit] - [app.media :as media] + [app.media.validation :as media.v] [app.nitrate :as nitrate] [app.rpc :as rpc] [app.rpc.commands.auth :as auth] @@ -119,7 +119,7 @@ (def ^:private schema:upload-organization-logo [:map - [:content media/schema:upload] + [:content media.v/schema:upload] [:organization-id ::sm/uuid] [:previous-id {:optional true} ::sm/uuid]]) diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index ed3a3364f0..4a6ef8ec62 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -116,7 +116,8 @@ {} [:exporter :admin-console - :nexus]))) + :nexus + :media-processor]))) (sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::shared-keys [:map-of :keyword ::sm/text]) diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj new file mode 100644 index 0000000000..dbaf8cb889 --- /dev/null +++ b/backend/test/backend_tests/media_remote_test.clj @@ -0,0 +1,593 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.media-remote-test + (:require + [app.common.exceptions :as ex] + [app.config :as cf] + [app.media.remote :as media.remote] + [app.setup :as-alias setup] + [app.util.json :as json] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str] + [datoteka.fs :as fs] + [datoteka.io :as io] + [mockery.core :refer [with-mocks]]) + (:import + java.io.ByteArrayInputStream)) + +(defn- mk-system + "Minimal system map for media.remote/process tests." + [] + {::setup/shared-keys {:media-processor "test-shared-key"}}) + +(defn- json-stream + "Create an InputStream from a Clojure data structure (JSON-encoded)." + [data] + (ByteArrayInputStream. + (json/encode data))) + +(def config-mock + "Standard config mock for media-processor service." + {:media-processing-service-uri "http://localhost:6065" + :media-processing-service-timeout 5000}) + +(defn- write-font-tmp + "Write font bytes to a tempfile and return the Path. Caller is responsible for cleanup." + [bytes suffix] + (let [tmp (fs/create-tempfile :prefix "penpot-test-font-" :suffix suffix)] + (io/write* tmp bytes) + tmp)) + +;; --------------------------------------------------------------------------- +;; :info +;; --------------------------------------------------------------------------- + +(t/deftest info-happy-path + (t/testing "info returns dimensions and merges into input" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 800 :height 600 :mtype "image/jpeg" :size 12345 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}})] + (t/is (= 800 (:width result))) + (t/is (= 600 (:height result))) + (t/is (= (fs/size path) (:size result))) + (t/is (some? (:ts result))) + (t/is (= path (:path result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (= 1 (:call-count @mock)))))))) + +(t/deftest info-verifies-request-params + (t/testing "info sends correct endpoint, method, and x-shared-key header" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[system req-map] (:call-args @mock)] + ;; System passed through + (t/is (some? (::setup/shared-keys system))) + ;; Request structure + (t/is (= :post (:method req-map))) + (t/is (str/includes? (str (:uri req-map)) "api/image/info")) + (t/is (= "test-shared-key" (get-in req-map [:headers "x-shared-key"]))) + (t/is (str/starts-with? + (get-in req-map [:headers "Content-Type"]) + "multipart/form-data")))))))) + +(t/deftest info-no-content-length-header + (t/testing "info does not send Content-Length header (JDK uses chunked encoding)" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (nil? (get-in req-map [:headers "Content-Length"]))))))))) + +(t/deftest info-service-uri-not-configured + (t/testing "info throws when service URI is not configured" + (with-redefs [cf/get (th/config-get-mock {})] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-not-configured (:code (ex-data err)))))))) + +(t/deftest info-service-unavailable + (t/testing "info throws when service-request raises unavailable" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +(t/deftest info-service-timeout + (t/testing "info throws when service-request raises timeout" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :media-processor-timeout (:code (ex-data err))))))))) + +(t/deftest info-mtype-mismatch + (t/testing "info raises :media-type-mismatch when detected mtype differs from declared" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 100 :height 100 :size 100 + :mtype "image/png"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :media-type-mismatch (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generic-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest generic-thumbnail-happy-path + (t/testing "generic-thumbnail returns tempfile with correct format" + (let [thumb-bytes (.getBytes "fake-jpeg-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 80 + :width 200 + :height 200})] + (t/is (= :jpeg (:format result))) + (t/is (= "image/jpeg" (:mtype result))) + (t/is (pos? (:size result))) + (t/is (fs/exists? (:data result))))))))) + +(t/deftest generic-thumbnail-verifies-query-params + (t/testing "generic-thumbnail sends correct query params with mode=fit" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. (.getBytes "data" "UTF-8"))}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 300 + :height 400}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "width=300")) + (t/is (str/includes? (str (:uri req-map)) "height=400")) + (t/is (str/includes? (str (:uri req-map)) "quality=85")) + (t/is (str/includes? (str (:uri req-map)) "format=jpeg")) + (t/is (str/includes? (str (:uri req-map)) "mode=fit")))))))) + +(t/deftest generic-thumbnail-service-unavailable + (t/testing "generic-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :profile-thumbnail +;; --------------------------------------------------------------------------- + +(t/deftest profile-thumbnail-happy-path + (t/testing "profile-thumbnail returns tempfile and uses mode=crop" + (let [thumb-bytes (.getBytes "fake-png-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. thumb-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + result (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128})] + (t/is (some? (:data result))) + (t/is (fs/exists? (:data result))) + ;; Verify mode=crop in URI + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "mode=crop"))))))))) + +(t/deftest profile-thumbnail-service-unavailable + (t/testing "profile-thumbnail throws on service error" + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg") + err (ex/try! (media.remote/process (mk-system) + {:cmd :profile-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 128 + :height 128}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) + +;; --------------------------------------------------------------------------- +;; :generate-fonts +;; --------------------------------------------------------------------------- + +(t/deftest generate-fonts-ttf-happy-path + (t/testing "generate-fonts with TTF path makes per-variant calls" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}})] + ;; Original path preserved + (t/is (= ttfpath (get result "font/ttf"))) + ;; Variants written to tempfiles + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for otf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-ttf-as-path + (t/testing "generate-fonts with TTF as tempfile Path works" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + tmp-path (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" tmp-path}})] + ;; Path preserved + (t/is (= tmp-path (get result "font/ttf"))) + ;; Variant written + (t/is (fs/exists? (get result "font/otf")))) + (finally + (fs/delete tmp-path)))))))) + +(t/deftest generate-fonts-otf-happy-path + (t/testing "generate-fonts with OTF path" + (let [otfbytes (io/read* (io/resource "backend_tests/test_files/font-1.otf")) + otfpath (write-font-tmp otfbytes ".otf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/otf" otfpath}})] + (t/is (= otfpath (get result "font/otf"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Two calls: one for ttf, one for woff + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete otfpath)))))))) + +(t/deftest generate-fonts-woff-happy-path + (t/testing "generate-fonts with WOFF path" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff" woffpath}})] + (t/is (= woffpath (get result "font/woff"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + ;; Two calls: one for ttf, one for otf + (t/is (= 2 (:call-count @mock)))) + (finally + (fs/delete woffpath))))))) + + (t/deftest generate-fonts-woff2-happy-path + (t/testing "generate-fonts with WOFF2 path" + (let [woff2bytes (io/read* (io/resource "backend_tests/test_files/font-1.woff2")) + woff2path (write-font-tmp woff2bytes ".woff2") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [result (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/woff2" woff2path}})] + (t/is (= woff2path (get result "font/woff2"))) + (t/is (fs/exists? (get result "font/ttf"))) + (t/is (fs/exists? (get result "font/otf"))) + (t/is (fs/exists? (get result "font/woff"))) + ;; Three calls: one for ttf, one for otf, one for woff + (t/is (= 3 (:call-count @mock)))) + (finally + (fs/delete woff2path))))))))) + +(t/deftest generate-fonts-verifies-query-params + (t/testing "generate-fonts sends target-type query param with 180s timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type=")) + (t/is (= 180000 (:timeout req-map)))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-woff-verifies-target-types + (t/testing "generate-fonts with WOFF sends target-type query param" + (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) + woffpath (write-font-tmp woffbytes ".woff") + fake-bytes (.getBytes "fake-font-data" "UTF-8")] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (ByteArrayInputStream. fake-bytes)}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/woff" woffpath}}) + (let [[_ req-map] (:call-args @mock)] + (t/is (str/includes? (str (:uri req-map)) "target-type="))) + (finally + (fs/delete woffpath)))))))) + +(t/deftest generate-fonts-no-recognized-variant + (t/testing "generate-fonts throws when no recognized font variant" + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/unknown" (.getBytes "data" "UTF-8")}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-font (:code (ex-data err)))))))) + +(t/deftest generate-fonts-connection-error + (t/testing "generate-fonts throws on service error" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "Cannot connect to media-processor service" + {:type :internal + :code :media-processor-unavailable})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-unavailable (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +(t/deftest generate-fonts-timeout-error + (t/testing "generate-fonts throws on service timeout" + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (with-mocks [mock {:target 'app.media.remote/service-request + :throw (ex-info "media-processor service request timed out" + {:type :internal + :code :media-processor-timeout})}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (try + (let [err (ex/try! (media.remote/process (mk-system) + {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] + (t/is (ex/error? err)) + (t/is (= :media-processor-timeout (:code (ex-data err))))) + (finally + (fs/delete ttfpath)))))))) + +;; --------------------------------------------------------------------------- +;; Status code handling (service-request) +;; --------------------------------------------------------------------------- + +(t/deftest service-request-raises-on-400 + (t/testing "service-request raises :validation on status 400" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 400 + :body (json-stream {:type "validation" + :code "invalid-image" + :hint "bad input"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :invalid-image (:code (ex-data err))))))))) + +(t/deftest service-request-raises-on-500 + (t/testing "service-request raises :internal on status 500" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 500 + :body (json-stream {:type "internal" + :code "processing-error" + :hint "Internal server error"})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [err (ex/try! (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}))] + (t/is (ex/error? err)) + (t/is (= :internal (:type (ex-data err)))) + (t/is (= :processing-error (:code (ex-data err))))))))) + +(t/deftest service-request-passes-on-200 + (t/testing "service-request returns response on status 200" + (with-mocks [mock {:target 'app.http.client/req + :return {:status 200 + :body (json-stream {:width 100 :height 100})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [resp (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}})] + (t/is (= 200 (:status resp)))))))) + +;; --------------------------------------------------------------------------- +;; Shared key +;; --------------------------------------------------------------------------- + +(t/deftest shared-key-sent-correctly + (t/testing "x-shared-key header matches the system's shared key" + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body (json-stream {:width 1 :height 1 :mtype "image/jpeg" :size 1 :orientation 1})}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [system {::setup/shared-keys {:media-processor "my-secret-key-123"}}] + (media.remote/process system + {:cmd :info + :input {:path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg"}}) + (let [[system-arg _] (:call-args @mock)] + ;; System passed through correctly + (t/is (= "my-secret-key-123" + (-> system-arg ::setup/shared-keys :media-processor))))))))) + +;; --------------------------------------------------------------------------- +;; Stream closure +;; --------------------------------------------------------------------------- + +(defn- tracking-stream + "Create an InputStream that tracks whether it was closed. + Returns a map with :stream (the InputStream) and :closed (an atom)." + [^bytes data] + (let [closed (atom false) + delegate (ByteArrayInputStream. data) + stream (proxy [java.io.InputStream] [] + (read + ([] (.read delegate)) + ([^bytes b] (.read delegate b)) + ([^bytes b off len] (.read delegate b off len))) + (close [] + (reset! closed true) + (.close delegate)))] + {:stream stream :closed closed})) + +(t/deftest info-closes-response-stream + (t/testing "info closes the response stream after parsing JSON" + (let [json-str "{\"width\":100,\"height\":100,\"mtype\":\"image/jpeg\",\"size\":1,\"orientation\":1}" + json-data (.getBytes json-str "UTF-8") + {:keys [stream closed]} (tracking-stream json-data)] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :info + :input {:path path :mtype "image/jpeg"}}) + ;; Stream should be closed after processing + (t/is @closed))))))) + +(t/deftest font-convert-closes-response-stream + (t/testing "font-convert closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-font-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) + ttfpath (write-font-tmp ttfbytes ".ttf")] + (try + (media.remote/process (mk-system) + {:cmd :generate-fonts + :input {"font/ttf" ttfpath}}) + ;; Stream should be closed after processing + (t/is @closed) + (finally + (fs/delete ttfpath))))))))) + +(t/deftest thumbnail-closes-response-stream + (t/testing "thumbnail closes the response stream after writing" + (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-thumbnail-data" "UTF-8"))] + (with-mocks [mock {:target 'app.media.remote/service-request + :return {:status 200 + :body stream}}] + (with-redefs [cf/get (th/config-get-mock config-mock)] + (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] + (media.remote/process (mk-system) + {:cmd :generic-thumbnail + :input {:path path :mtype "image/jpeg"} + :format :jpeg + :quality 85 + :width 200 + :height 200}) + ;; Stream should be closed after processing + (t/is @closed))))))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 234bcba89e..4f4b5378f7 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -24,312 +24,6 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(t/deftest ttf-font-upload-1 - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" ttfdata}} - out (th/command! params)] - - (t/is (= 1 (:call-count @mock))) - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style))))) - -(t/deftest ttf-font-upload-2 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) - -(t/deftest woff2-font-upload-1 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data (-> (io/resource "backend_tests/test_files/font-1.woff2") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff2" data}} - out (th/command! params)] - - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (let [result (:result out)] - (t/is (uuid? (:id result))) - (t/is (uuid? (:ttf-file-id result))) - (t/is (uuid? (:otf-file-id result))) - (t/is (uuid? (:woff1-file-id result))) - (t/is (uuid? (:woff2-file-id result))) - (t/are [k] (= (get params k) - (get result k)) - :team-id - :font-id - :font-family - :font-weight - :font-style)))) - -(t/deftest font-deletion-1 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*))] - - ;; Create front variant - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 500 - :font-style "normal" - :data {"font/woff" data2}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font - ::rpc/profile-id (:id prof) - :team-id team-id - :id font-id} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 2 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 6 (:delete res))))))) - -(t/deftest font-deletion-2 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") - (io/read*)) - - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") - (io/read*))] - - ;; Create front variant - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data1}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id (uuid/custom 10 2) - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/woff" data2}} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font - ::rpc/profile-id (:id prof) - :team-id team-id - :id font-id} - out (th/command! params)] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 3 (:delete res))))))) - -(t/deftest font-deletion-3 - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) - params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 400 :font-style "normal" :data {"font/woff" data1}} - params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :font-id font-id :font-family "somefont" - :font-weight 500 :font-style "normal" :data {"font/woff" data2}} - out1 (th/command! params1) - out2 (th/command! params2)] - (t/is (nil? (:error out1))) - (t/is (nil? (:error out2))) - - ;; freeze with hours 3 clock - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 6 (:freeze res)))) - - (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) - :team-id team-id :id (-> out1 :result :id)} - out (th/command! params)] - (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) - - ;; no-op with hours 3 clock (nothing touched yet) - (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] - (th/run-task! :storage-gc-touched {}))] - (t/is (= 0 (:freeze res))) - (t/is (= 0 (:delete res)))) - - ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] - (let [res (th/run-task! :objects-gc {})] - (t/is (= 1 (:processed res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] - (let [res (th/run-task! :storage-gc-touched {})] - (t/is (= 0 (:freeze res))) - (t/is (= 3 (:delete res))))))) - -(t/deftest input-sanitization-1 - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - proj-id (:default-project-id prof) - font-id (uuid/custom 10 1) - - ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*)) - - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "somefont" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" "/etc/passwd"}} - out (th/command! params)] - - (t/is (= 0 (:call-count @mock))) - ;; (th/print-result! out) - - (let [error (:error out) - error-data (ex-data error)] - (t/is (th/ex-info? error)))))) - ;; ----------------------------------------------------------------------- ;; Helpers for chunked-upload font tests ;; ----------------------------------------------------------------------- @@ -399,119 +93,211 @@ :font-weight :font-style)) -;; ----------------------------------------------------------------------- -;; Path 1 – Normal (direct :data bytes) -;; ----------------------------------------------------------------------- +(t/deftest font-deletion-1 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) -(t/deftest create-font-variant-normal-ttf + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") + (io/read*)) + + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") + (io/read*))] + + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 500 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font + ::rpc/profile-id (:id prof) + :team-id team-id + :id font-id} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 2 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 6 (:delete res))))))) + +(t/deftest font-deletion-2 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") + (io/read*)) + + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") + (io/read*))] + + ;; Create font variant + (let [session-id (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [session-id (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id (uuid/custom 10 2) + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font + ::rpc/profile-id (:id prof) + :team-id team-id + :id font-id} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 1 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 3 (:delete res))))))) + +(t/deftest font-deletion-3 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + data1 (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + data2 (-> (io/resource "backend_tests/test_files/font-2.woff") (io/read*)) + sid1 (upload-font-chunked! prof data1 "font/woff" (* 4 1024 1024)) + sid2 (upload-font-chunked! prof data2 "font/woff" (* 4 1024 1024)) + params1 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 400 :font-style "normal" :uploads {"font/woff" sid1}} + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :font-id font-id :font-family "somefont" + :font-weight 500 :font-style "normal" :uploads {"font/woff" sid2}} + out1 (th/command! params1) + out2 (th/command! params2)] + (t/is (nil? (:error out1))) + (t/is (nil? (:error out2))) + + ;; freeze with hours 3 clock + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 6 (:freeze res)))) + + (let [params {::th/type :delete-font-variant ::rpc/profile-id (:id prof) + :team-id team-id :id (-> out1 :result :id)} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + ;; no-op with hours 3 clock (nothing touched yet) + (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] + (th/run-task! :storage-gc-touched {}))] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res)))) + + ;; objects-gc at days 8, then storage-gc-touched at days 8 + 3h + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8}))] + (let [res (th/run-task! :objects-gc {})] + (t/is (= 1 (:processed res))))) + + (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:days 8 :hours 3}))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 3 (:delete res))))))) + +(t/deftest input-sanitization-1 (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) - font-id (uuid/custom 10 10) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) -(t/deftest create-font-variant-normal-otf - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 11) - data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" - :font-weight 400 - :font-style "normal" - :data {"font/otf" data}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*)) -(t/deftest create-font-variant-normal-woff - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 12) - data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - params {::th/type :create-font-variant + session-id (upload-font-chunked! prof ttfdata "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "chunked-test" + :team-id team-id + :font-id font-id + :font-family "somefont" :font-weight 400 - :font-style "normal" - :data {"font/woff" data}} + :font-style "normal" + :uploads {"font/ttf" session-id}} out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) + + ;; (th/print-result! out) + (t/is (nil? (:error out)))))) ;; ----------------------------------------------------------------------- -;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype) -;; ----------------------------------------------------------------------- - -(t/deftest create-font-variant-legacy-chunked-ttf - "Upload a TTF via the legacy :data path where each mtype value is a - vector of byte-array chunks (4 MiB each) instead of a single byte-array." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 20) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - ;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked" - :font-weight 700 - :font-style "italic" - :data {"font/ttf" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -(t/deftest create-font-variant-legacy-chunked-woff - "Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks - to exercise the SequenceInputStream concatenation path." - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 21) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - ;; Split into small chunks to exercise the SequenceInputStream path - chunks (split-bytes-into-chunks full-bytes 512) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "legacy-chunked-woff" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (= 1 (:call-count @mock))) - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))) - -;; ----------------------------------------------------------------------- -;; Path 3 – New standardized chunked upload (:uploads map) +;; Chunked upload (:uploads map) ;; ----------------------------------------------------------------------- (t/deftest create-font-variant-chunked-upload-ttf @@ -606,8 +392,8 @@ ;; Error cases ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-missing-data-and-uploads - "Neither :data nor :uploads is present — schema validation must reject it." +(t/deftest create-font-variant-missing-uploads + "Missing :uploads — schema validation must reject it." (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) font-id (uuid/custom 10 40) @@ -674,49 +460,6 @@ ;; Font size validation tests ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-size-exceeded-normal - "Direct :data upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 50) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - -(t/deftest create-font-variant-size-exceeded-legacy-chunked - "Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 51) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-exceeded-legacy" - :font-weight 400 - :font-style "normal" - :data {"font/woff" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :restriction (-> out :error ex-data :type))) - (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) - (t/deftest create-font-variant-size-exceeded-chunked-upload "New :uploads path exceeding font-max-file-size must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -738,72 +481,10 @@ (t/is (= :restriction (-> out :error ex-data :type))) (t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))) -(t/deftest create-font-variant-size-within-limit - "Upload exactly at the limit must succeed." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 53) - font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - font-size (alength ^bytes font-bytes)] - (with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)] - (let [params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "size-at-limit" - :font-weight 400 - :font-style "normal" - :data {"font/ttf" font-bytes}} - out (th/command! params)] - (t/is (nil? (:error out))) - (assert-font-variant-result params (:result out))))))) - ;; ----------------------------------------------------------------------- -;; Font media-type validation tests +;; Font media-type validation ;; ----------------------------------------------------------------------- -(t/deftest create-font-variant-invalid-type-normal - "Direct :data upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 60) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type" - :font-weight 400 - :font-style "normal" - :data {"application/octet-stream" data}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - -(t/deftest create-font-variant-invalid-type-legacy-chunked - "Legacy :data chunk-vector upload with a disallowed mtype must be rejected." - (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof (th/create-profile* 1 {:is-active true}) - team-id (:default-team-id prof) - font-id (uuid/custom 10 61) - full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) - chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof) - :team-id team-id - :font-id font-id - :font-family "invalid-type-legacy" - :font-weight 400 - :font-style "normal" - :data {"image/png" (vec chunks)}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :validation (-> out :error ex-data :type))) - (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) - (t/deftest create-font-variant-invalid-type-chunked-upload "New :uploads path with a disallowed mtype must be rejected after assembly." (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] @@ -836,46 +517,50 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; name with < should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation))) ;; name with ' should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil'name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; name with } should fail - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "evil}name" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (not (th/success? out))) (t/is (th/ex-of-type? (:error out) :validation))) ;; valid name should succeed - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id (uuid/custom 10 101) :font-family "Source Sans Pro" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out)))))) @@ -887,12 +572,13 @@ data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))] ;; Create a valid font first - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id :font-id font-id :font-family "ValidFont" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (th/success? out))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index ff38aee470..4669ad929d 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -380,8 +380,41 @@ (t/is (= :validation (:type (ex-data err)))) (t/is (= :unable-to-download-image (:code (ex-data err)))))))) -;; -------------------------------------------------------------------- -;; Helpers for chunked-upload tests + +(t/deftest download-image-closes-stream + (t/testing "response body stream is closed on success" + (let [closed? (atom false) + ;; Minimal valid PNG (1x1 pixel, red) + png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]) + body (proxy [java.io.ByteArrayInputStream] [png-data] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 200 + :headers {"content-type" "image/png" + "content-length" (str (alength png-data))} + :body body}}] + (let [cfg {::http/client :mock-client} + result (media/download-image cfg "https://example.com/image.png")] + (t/is (some? result)) + (t/is @closed? "body stream should be closed after successful download"))))) + + (t/testing "response body stream is closed on validation error" + (let [closed? (atom false) + body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)] + (close [] (reset! closed? true)))] + (with-mocks [http-mock {:target 'app.http.client/req-with-redirects + :return {:status 404 + :headers {"content-type" "text/html" + "content-length" "100"} + :body body}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "https://example.com/not-found.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :unable-to-download-image (:code (ex-data err)))) + (t/is @closed? "body stream should be closed even on validation error")))))) ;; -------------------------------------------------------------------- (defn- split-file-into-chunks diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index 348a978fc2..2aca502e9f 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -199,6 +199,25 @@ (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] (t/is (= 0 (:count res))))))) +(defn- upload-font-chunked! + "Splits `font-bytes` into a single chunk, creates an upload session, + uploads the chunk, and returns the session-id UUID." + [prof ^bytes font-bytes mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-") + _ (io/write* tmp font-bytes) + mfile {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + (assert (nil? (:error out))) + session-id)) + (t/deftest touched-gc-task-2 (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) @@ -229,6 +248,8 @@ :name "testfile" :content mfile} + session-id (upload-font-chunked! prof ttfdata "font/ttf") + params2 {::th/type :create-font-variant ::rpc/profile-id (:id prof) :team-id team-id @@ -236,7 +257,7 @@ :font-family "somefont" :font-weight 400 :font-style "normal" - :data {"font/ttf" ttfdata}} + :uploads {"font/ttf" session-id}} out1 (th/command! params1) out2 (th/command! params2)] @@ -250,7 +271,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 5 (:freeze res))) - (t/is (= 0 (:delete res))) + (t/is (= 1 (:delete res))) (let [result-1 (:result out1) result-2 (:result out2)] @@ -271,7 +292,7 @@ (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))] (th/run-task! :storage-gc-touched {}))] (t/is (= 2 (:freeze res))) - (t/is (= 3 (:delete res)))) + (t/is (= 4 (:delete res)))) ;; now check that there are no touched objects (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])] @@ -279,7 +300,7 @@ ;; now check that all objects are marked to be deleted (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])] - (t/is (= 3 (:count res)))))))) + (t/is (= 4 (:count res)))))))) (t/deftest touched-gc-task-3 (let [storage (-> (:app.storage/storage th/*system*) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index ddfa667165..9988c1a9f8 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -178,7 +178,8 @@ :stroke-path :stroke-per-side - :custom-shortcuts}) + :custom-shortcuts + :remote-media-processing}) (def all-flags (set/union email login varia)) diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor new file mode 100644 index 0000000000..bc83e9e5a5 --- /dev/null +++ b/docker/images/Dockerfile.media-processor @@ -0,0 +1,86 @@ +FROM ubuntu:26.04 +LABEL maintainer="Penpot " + +ENV LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + NODE_VERSION=v24.18.0 \ + DEBIAN_FRONTEND=noninteractive \ + PATH=/opt/node/bin:$PATH + +RUN set -ex; \ + useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ + mkdir -p /etc/resolvconf/resolv.conf.d; \ + echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \ + apt-get -qq update; \ + apt-get -qq dist-upgrade; \ + apt-get -qqy --no-install-recommends install \ + curl \ + tzdata \ + locales \ + ca-certificates \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ + locale-gen; \ + find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete; + +RUN set -ex; \ + apt-get -qq update; \ + apt-get -qqy --no-install-recommends install \ + fontforge \ + woff-tools \ + woff2 \ + \ + libgomp1 \ + libheif1 \ + libjpeg-turbo8 \ + liblcms2-2 \ + libopenexr-3-1-30 \ + libopenjp2-7 \ + libpng16-16 \ + librsvg2-2 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + libxml2-16 \ + libzip5 \ + libzstd1 \ + ; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/*; + +RUN set -eux; \ + ARCH="$(dpkg --print-architecture)"; \ + case "${ARCH}" in \ + aarch64|arm64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \ + ;; \ + amd64|x86_64) \ + BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \ + ;; \ + *) \ + echo "Unsupported arch: ${ARCH}"; \ + exit 1; \ + ;; \ + esac; \ + curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \ + mkdir -p /opt/node; \ + cd /opt/node; \ + tar -xf /tmp/nodejs.tar.gz --strip-components=1; \ + chown -R root /opt/node; \ + rm -rf /tmp/nodejs.tar.gz; \ + corepack enable; \ + mkdir -p /opt/penpot; \ + chown -R penpot:penpot /opt/penpot; + +ARG BUNDLE_PATH="./bundle-media-processor/" +COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/media-processor/ + +WORKDIR /opt/penpot/media-processor +USER penpot:penpot + +RUN ./setup + +CMD ["node", "dist/index.js"] diff --git a/media-processor/.prettierignore b/media-processor/.prettierignore new file mode 100644 index 0000000000..2d0c064480 --- /dev/null +++ b/media-processor/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/media-processor/.prettierrc b/media-processor/.prettierrc new file mode 100644 index 0000000000..5ebd5018e8 --- /dev/null +++ b/media-processor/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 120, + "endOfLine": "lf" +} diff --git a/media-processor/esbuild.config.mjs b/media-processor/esbuild.config.mjs new file mode 100644 index 0000000000..698edc38d4 --- /dev/null +++ b/media-processor/esbuild.config.mjs @@ -0,0 +1,17 @@ +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/index.ts"], + bundle: true, + platform: "node", + target: "node24", + format: "esm", + outfile: "dist/index.js", + external: ["sharp", "pino", "pino-pretty", "pino-loki"], + banner: { + js: ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +`, + }, +}); diff --git a/media-processor/package.json b/media-processor/package.json new file mode 100644 index 0000000000..7c08c70c0f --- /dev/null +++ b/media-processor/package.json @@ -0,0 +1,40 @@ +{ + "name": "media-processor", + "version": "1.0.0", + "description": "Stateless HTTP service for Penpot image and font processing", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "node esbuild.config.mjs", + "start": "node dist/index.js", + "start:dev": "tsx --env-file=../backend/scripts/_env src/index.ts", + "types:check": "tsc --noEmit", + "test": "vitest run", + "fmt": "prettier --write src/ test/", + "fmt:check": "prettier --check src/ test/", + "clean": "rm -rf dist/" + }, + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "dependencies": { + "express": "^5.2.1", + "multer": "^2.2.0", + "p-queue": "^9.3.3", + "pino": "^10.3.1", + "pino-loki": "^3.0.0", + "pino-pretty": "^13.1.3", + "sharp": "^0.35.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/multer": "^2.0.0", + "@types/node": "^26.1.2", + "@types/supertest": "^7.2.1", + "esbuild": "^0.28.1", + "prettier": "^3.6.2", + "supertest": "^7.2.2", + "tsx": "^4.22.4", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } +} diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml new file mode 100644 index 0000000000..be8c8eec4e --- /dev/null +++ b/media-processor/pnpm-lock.yaml @@ -0,0 +1,2649 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + multer: + specifier: ^2.2.0 + version: 2.2.0 + p-queue: + specifier: ^9.3.3 + version: 9.3.3 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-loki: + specifier: ^3.0.0 + version: 3.0.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@26.1.2) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/multer': + specifier: ^2.0.0 + version: 2.2.0 + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + prettier: + specifier: ^3.6.2 + version: 3.9.6 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + tsx: + specifier: ^4.22.4 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) + +packages: + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-loki@3.0.0: + resolution: {integrity: sha512-9TyUW5syTjp2nT70QcijJtIWUzdYUj+olQ7+fWNfm1/HrDGEWt86Q4ACzClH6DM6GBwtQimRDgneNczP+p4ypA==} + engines: {node: '>=20'} + hasBin: true + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pinojs/redact@0.4.0': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 26.1.2 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.1.2 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 26.1.2 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/methods@1.1.4': {} + + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 26.1.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 26.1.2 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 26.1.2 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + append-field@1.0.0: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.2.2: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-copy@4.0.4: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + help-me@5.0.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + joycon@3.1.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimist@1.2.8: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@3.3.16: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-loki@3.0.0: + dependencies: + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.4 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.3(@types/node@26.1.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 26.1.2 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@5.0.3: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + zod@4.4.3: {} diff --git a/media-processor/pnpm-workspace.yaml b/media-processor/pnpm-workspace.yaml new file mode 100644 index 0000000000..5ed0b5af0d --- /dev/null +++ b/media-processor/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/media-processor/scripts/build b/media-processor/scripts/build new file mode 100755 index 0000000000..94ec8e856c --- /dev/null +++ b/media-processor/scripts/build @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +pnpm run build diff --git a/media-processor/scripts/setup b/media-processor/scripts/setup new file mode 100755 index 0000000000..c7be37d33d --- /dev/null +++ b/media-processor/scripts/setup @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +corepack enable +corepack install +pnpm install diff --git a/media-processor/src/config.ts b/media-processor/src/config.ts new file mode 100644 index 0000000000..c6229ae343 --- /dev/null +++ b/media-processor/src/config.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { hkdfSync } from "node:crypto"; +import type { AppConfig } from "./types.js"; + +const envSchema = z.object({ + PENPOT_MEDIA_PROCESSOR_PORT: z.coerce.number().int().positive().default(6065), + PENPOT_MEDIA_PROCESSOR_HOST: z.string().default("0.0.0.0"), + PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).default(10), + PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT: z.coerce.number().int().nonnegative().default(180000), + PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE: z.coerce.number().int().positive().default(367001600), // 350 MB + PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD: z.coerce.number().int().positive().default(10485760), // 10 MB — uploads below this use memory storage; above use disk storage + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS: z.coerce.number().int().positive().default(128_000_000), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT: z.coerce.number().int().positive().default(16384), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM: z.coerce.number().int().positive().default(512), + PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME: z.coerce.number().int().positive().default(30), + PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT: z.coerce.number().int().positive().default(120000), + PENPOT_MEDIA_PROCESSOR_SHARED_KEY: z.string().optional(), + PENPOT_SECRET_KEY: z.string().optional(), + PENPOT_MEDIA_PROCESSOR_LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + PENPOT_LOGGERS_LOKI_URI: z.string().optional(), + PENPOT_LOGGERS_LOKI_JOB: z.string().default("media-processor"), + PENPOT_LOGGERS_LOKI_ENVIRONMENT: z.string().optional(), + PENPOT_LOGGERS_LOKI_INSTANCE: z.string().optional(), +}); + +function deriveSharedKey(secret: string): string { + const key = hkdfSync("blake2b512", secret, Buffer.from("media-processor"), "", 32); + return Buffer.from(key).toString("base64url"); +} + +export function loadConfig(): AppConfig { + const parsed = envSchema.parse(process.env); + + let sharedKey: string | null = null; + if (parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY) { + sharedKey = parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY; + } else if (parsed.PENPOT_SECRET_KEY) { + sharedKey = deriveSharedKey(parsed.PENPOT_SECRET_KEY); + } + + return { + port: parsed.PENPOT_MEDIA_PROCESSOR_PORT, + host: parsed.PENPOT_MEDIA_PROCESSOR_HOST, + maxConcurrentRequests: parsed.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS, + requestTimeout: parsed.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT, + maxFileSize: parsed.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE, + memoryThreshold: parsed.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD, + imageMaxPixels: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS, + imageMaxWidth: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH, + imageMaxHeight: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT, + fontProcessMem: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM, + fontProcessCpuTime: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME, + fontTimeout: parsed.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT, + sharedKey, + logLevel: parsed.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL, + lokiUri: parsed.PENPOT_LOGGERS_LOKI_URI || null, + lokiJob: parsed.PENPOT_LOGGERS_LOKI_JOB, + lokiEnvironment: parsed.PENPOT_LOGGERS_LOKI_ENVIRONMENT || null, + lokiInstance: parsed.PENPOT_LOGGERS_LOKI_INSTANCE || null, + }; +} diff --git a/media-processor/src/index.ts b/media-processor/src/index.ts new file mode 100644 index 0000000000..ac05201659 --- /dev/null +++ b/media-processor/src/index.ts @@ -0,0 +1,59 @@ +import express, { type Express } from "express"; +import { loadConfig } from "./config.js"; +import { initLogger, logger, logActiveTransports } from "./logger.js"; +import { healthRoutes } from "./routes/health.js"; +import { createImageRoutes } from "./routes/image.js"; +import { createFontRoutes } from "./routes/font.js"; +import { errorHandler } from "./middleware/error-handler.js"; +import { timeoutMiddleware } from "./middleware/timeout.js"; +import { sharedKeyAuth } from "./middleware/auth.js"; +import { createQueueMiddleware } from "./middleware/queue.js"; +import { loggingMiddleware } from "./middleware/logging.js"; +import { configureImageLimits } from "./services/image.js"; +import { configureFontLimits } from "./services/font.js"; +import { configureUploadLimits } from "./upload.js"; +import sharp from "sharp"; + +// Auth is enforced via x-shared-key header (sharedKeyAuth middleware). +// When no key is configured, all requests are rejected (403). +// This service MUST be deployed on an internal Docker network only +// — do NOT expose to the public internet. + +// Disable sharp/libvips caching to prevent unbounded memory growth +sharp.cache(false); + +const config = loadConfig(); +initLogger(config); +const app: Express = express(); + +// Configure resource limits +configureImageLimits({ + maxPixels: config.imageMaxPixels, + maxWidth: config.imageMaxWidth, + maxHeight: config.imageMaxHeight, +}); + +configureFontLimits({ + mem: config.fontProcessMem, + cpuTime: config.fontProcessCpuTime, + timeout: config.fontTimeout, +}); + +configureUploadLimits({ maxFileSize: config.maxFileSize, memoryThreshold: config.memoryThreshold }); + +const queueMiddleware = createQueueMiddleware(config.maxConcurrentRequests); + +app.use(timeoutMiddleware(config.requestTimeout)); +app.use(loggingMiddleware); + +app.get("/api/health", healthRoutes); +app.use("/api/image", sharedKeyAuth(config.sharedKey), queueMiddleware, createImageRoutes()); +app.use("/api/font", sharedKeyAuth(config.sharedKey), queueMiddleware, createFontRoutes()); +app.use(errorHandler); + +app.listen(config.port, config.host, () => { + logActiveTransports(logger); + logger.info(`media-processor listening on ${config.host}:${config.port}`); +}); + +export { app }; diff --git a/media-processor/src/logger.ts b/media-processor/src/logger.ts new file mode 100644 index 0000000000..aede1219d2 --- /dev/null +++ b/media-processor/src/logger.ts @@ -0,0 +1,135 @@ +import pino, { type TransportTargetOptions } from "pino"; +import { loadConfig } from "./config.js"; +import type { AppConfig } from "./types.js"; + +interface LogTransportProvider { + getTarget(): TransportTargetOptions | null; + getStartupMessage(): string | null; +} + +class ConsoleLogTransport implements LogTransportProvider { + public constructor(private readonly config: AppConfig) {} + + public getTarget(): TransportTargetOptions { + return { + target: "pino-pretty", + level: this.config.logLevel, + options: { + colorize: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + ignore: "pid,hostname", + messageFormat: "{msg}", + levelFirst: true, + }, + }; + } + + public getStartupMessage(): string { + return "Logging to console"; + } +} + +class LokiLogTransport implements LogTransportProvider { + private readonly host: string | null; + + public constructor( + private readonly config: AppConfig, + lokiUri: string | null + ) { + this.host = lokiUri; + } + + public getTarget(): TransportTargetOptions | null { + if (this.host === null) { + return null; + } + return { + target: "pino-loki", + level: this.config.logLevel, + options: { + host: this.host, + json: false, + batching: true, + interval: 5, + replaceTimestamp: true, + labels: this.buildLabels(), + messageFormat: "{msg}", + ignore: "pid,hostname", + }, + }; + } + + private buildLabels(): Record { + const labels: Record = { + job: this.config.lokiJob, + }; + if (this.config.lokiEnvironment) { + labels.environment = this.config.lokiEnvironment; + } + if (this.config.lokiInstance) { + labels.instance = this.config.lokiInstance; + } + return labels; + } + + public getStartupMessage(): string | null { + return this.host !== null ? `Logging to Loki: ${this.host}` : null; + } +} + +function buildLogger(config: AppConfig) { + const consoleTransport = new ConsoleLogTransport(config); + const lokiTransport = new LokiLogTransport(config, config.lokiUri); + const transports: LogTransportProvider[] = [consoleTransport, lokiTransport]; + + const instance = pino({ + level: config.logLevel, + timestamp: pino.stdTimeFunctions.isoTime, + transport: { + targets: transports + .map((t) => t.getTarget()) + .filter((target): target is TransportTargetOptions => target !== null), + }, + }); + + return { instance, transports }; +} + +let _instance: pino.Logger | null = null; +let _transports: LogTransportProvider[] = []; + +export function initLogger(config: AppConfig): pino.Logger { + const result = buildLogger(config); + _instance = result.instance; + _transports = result.transports; + return _instance; +} + +function getInstance(): pino.Logger { + if (_instance === null) { + return initLogger(loadConfig()); + } + return _instance; +} + +// Export as a getter so consumers see the lazily-initialized instance. +export const logger: pino.Logger = new Proxy({} as pino.Logger, { + get(_, prop) { + const inst = getInstance(); + const value = (inst as unknown as Record)[prop]; + return typeof value === "function" ? value.bind(inst) : value; + }, +}); + +export function logActiveTransports(log: pino.Logger): void { + for (const t of _transports) { + const msg = t.getStartupMessage(); + if (msg !== null) { + log.info(msg); + } + } +} + +export function createLogger(name: string) { + return logger.child({ name }); +} diff --git a/media-processor/src/middleware/auth.ts b/media-processor/src/middleware/auth.ts new file mode 100644 index 0000000000..72e1ca743b --- /dev/null +++ b/media-processor/src/middleware/auth.ts @@ -0,0 +1,27 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +export function sharedKeyAuth(expectedKey: string | null) { + if (expectedKey === null) { + return (_req: Request, res: Response, _next: NextFunction): void => { + res.status(403).json({ type: "authorization", code: "forbidden", hint: "Shared key not configured" }); + }; + } + + return (req: Request, res: Response, next: NextFunction): void => { + const provided = req.headers["x-shared-key"]; + if (typeof provided !== "string") { + res.status(403).json({ type: "authorization", code: "forbidden" }); + return; + } + + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(expectedKey); + + if (providedBuf.length === expectedBuf.length && timingSafeEqual(providedBuf, expectedBuf)) { + next(); + } else { + res.status(403).json({ type: "authorization", code: "forbidden" }); + } + }; +} diff --git a/media-processor/src/middleware/cleanup.ts b/media-processor/src/middleware/cleanup.ts new file mode 100644 index 0000000000..ec5796154a --- /dev/null +++ b/media-processor/src/middleware/cleanup.ts @@ -0,0 +1,25 @@ +import { rm } from "node:fs/promises"; +import type { Request, Response, NextFunction } from "express"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("cleanup"); + +export function cleanupMiddleware(req: Request, _res: Response, next: NextFunction): void { + let cleaned = false; + + _res.on("finish", cleanup); + _res.on("close", cleanup); + + async function cleanup() { + if (cleaned) return; + cleaned = true; + const file = req.file as (Express.Multer.File & { path?: string }) | undefined; + if (file?.path) { + await rm(file.path, { force: true }).catch((err) => { + logger.debug({ err, path: file.path }, "Failed to cleanup uploaded file"); + }); + } + } + + next(); +} diff --git a/media-processor/src/middleware/error-handler.ts b/media-processor/src/middleware/error-handler.ts new file mode 100644 index 0000000000..f7834c59fe --- /dev/null +++ b/media-processor/src/middleware/error-handler.ts @@ -0,0 +1,55 @@ +import type { Request, Response, NextFunction } from "express"; +import type { AppError } from "../types.js"; +import { createLogger } from "../logger.js"; +import multer from "multer"; + +const logger = createLogger("error-handler"); + +export class ProcessingError extends Error { + public readonly statusCode: number; + public readonly errorBody: AppError; + + constructor(statusCode: number, body: AppError) { + super(body.hint ?? body.code); + this.statusCode = statusCode; + this.errorBody = body; + } +} + +function releaseSlot(res: Response): void { + const releaseQueue = (res as any).locals?.releaseQueue; + if (releaseQueue) releaseQueue(); +} + +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { + if (res.headersSent) { + return; + } + + if (err instanceof ProcessingError) { + logger.warn({ err, statusCode: err.statusCode }, "Processing error"); + res.status(err.statusCode).json(err.errorBody); + releaseSlot(res); + return; + } + + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + logger.warn({ err }, "Upload size limit exceeded"); + res.status(413).json({ + type: "restriction", + code: "payload-too-large", + }); + releaseSlot(res); + return; + } + } + + logger.error({ err }, "Unhandled error"); + res.status(500).json({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + releaseSlot(res); +} diff --git a/media-processor/src/middleware/logging.ts b/media-processor/src/middleware/logging.ts new file mode 100644 index 0000000000..8c7ecc6948 --- /dev/null +++ b/media-processor/src/middleware/logging.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "../logger.js"; + +const OP_NAMES: Record = { + "POST /api/image/info": "image/info", + "POST /api/image/thumbnail": "image/thumbnail", + "POST /api/font/convert": "font/convert", +}; + +export function loggingMiddleware(req: Request, res: Response, next: NextFunction): void { + const start = Date.now(); + res.on("finish", () => { + const path = req.originalUrl?.split("?")[0]; + const op = OP_NAMES[`${req.method} ${path}`]; + if (op) { + const meta = res.locals.opMeta ? `, ${res.locals.opMeta}` : ""; + logger.info(`op=${op}${meta}, status=${res.statusCode}, elapsed=${Date.now() - start}ms`); + } + }); + next(); +} diff --git a/media-processor/src/middleware/queue.ts b/media-processor/src/middleware/queue.ts new file mode 100644 index 0000000000..f6e6485548 --- /dev/null +++ b/media-processor/src/middleware/queue.ts @@ -0,0 +1,34 @@ +import type { Request, Response, NextFunction } from "express"; +import PQueue from "p-queue"; + +export function createQueueMiddleware(concurrency: number) { + const queue = new PQueue({ concurrency }); + + return function queueMiddleware(_req: Request, res: Response, next: NextFunction): void { + queue + .add( + () => + new Promise((resolve) => { + if (res.writableEnded) { + resolve(); + return; + } + + let released = false; + const release = () => { + if (!released) { + released = true; + resolve(); + } + }; + + // Store releaseQueue callback on res.locals so route handlers and error handler can call it + (res as any).locals = (res as any).locals || {}; + (res as any).locals.releaseQueue = release; + + next(); + }) + ) + .catch((err) => next(err instanceof Error ? err : new Error("Request processing failed"))); + }; +} diff --git a/media-processor/src/middleware/timeout.ts b/media-processor/src/middleware/timeout.ts new file mode 100644 index 0000000000..3b261a4082 --- /dev/null +++ b/media-processor/src/middleware/timeout.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction } from "express"; + +export function timeoutMiddleware(timeout: number) { + return (req: Request, res: Response, next: NextFunction): void => { + // Create AbortController for request cancellation + const abortController = new AbortController(); + (req as any).abortController = abortController; + + const timer = setTimeout(() => { + if (!res.headersSent) { + res.status(504).json({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + // Abort the signal to cancel ongoing processing + abortController.abort(); + res.on("finish", () => req.destroy()); + } + }, timeout); + + // Clear timer on finish (successful completion) + res.on("finish", () => clearTimeout(timer)); + + // Clear timer and abort signal on close (client disconnect) + res.on("close", () => { + clearTimeout(timer); + if (!abortController.signal.aborted) { + abortController.abort(); + } + }); + + next(); + }; +} diff --git a/media-processor/src/routes/font.ts b/media-processor/src/routes/font.ts new file mode 100644 index 0000000000..885b6192c3 --- /dev/null +++ b/media-processor/src/routes/font.ts @@ -0,0 +1,55 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { convertFont } from "../services/font.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; + +const VALID_TARGET_MTYPES = new Set(["font/ttf", "font/otf", "font/woff"]); +const VALID_SOURCE_MTYPES = new Set(["font/ttf", "font/otf", "font/woff", "font/woff2"]); + +export function createFontRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/convert", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-font", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const sourceMtype = req.file!.mimetype; + if (!VALID_SOURCE_MTYPES.has(sourceMtype)) { + throwValidation("invalid-font", `Unrecognized font mime-type: ${sourceMtype}`); + } + + const targetMtype = req.query["target-type"] as string; + if (!targetMtype || !VALID_TARGET_MTYPES.has(targetMtype)) { + throwValidation("invalid-font", `Invalid target-type. Must be one of: font/ttf, font/otf, font/woff`); + } + + res.locals.opMeta = `src=${sourceMtype}, dest=${targetMtype}`; + const result = await convertFont(input, sourceMtype, targetMtype, signal); + + if (!result) { + throwValidation("invalid-font", `Conversion from ${sourceMtype} to ${targetMtype} is not supported`); + } + + res.setHeader("Content-Type", targetMtype); + res.send(result); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/routes/health.ts b/media-processor/src/routes/health.ts new file mode 100644 index 0000000000..e598130a69 --- /dev/null +++ b/media-processor/src/routes/health.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express"; + +export function healthRoutes(_req: Request, res: Response): void { + res.json({ status: "ok" }); +} diff --git a/media-processor/src/routes/image.ts b/media-processor/src/routes/image.ts new file mode 100644 index 0000000000..ac28c55b4d --- /dev/null +++ b/media-processor/src/routes/image.ts @@ -0,0 +1,95 @@ +import { Router, type IRouter, type Request, type Response, type NextFunction } from "express"; +import { getUpload, getFileInput } from "../upload.js"; +import { getImageInfo, generateThumbnail } from "../services/image.js"; +import { throwValidation } from "../services/errors.js"; +import { cleanupMiddleware } from "../middleware/cleanup.js"; +import type { ThumbnailParams } from "../types.js"; + +export function parseQuality(value: string | undefined, defaultValue = 85): number { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) return defaultValue; + return Math.min(100, Math.max(1, parsed)); +} + +export function createImageRoutes(): IRouter { + const router: IRouter = Router(); + const upload = getUpload(); + + router.post( + "/info", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const info = await getImageInfo(input, req.file!.size, signal); + res.locals.opMeta = `mtype=${info.mtype}, size=${info.width}x${info.height}`; + res.json(info); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + router.post( + "/thumbnail", + upload.single("file"), + cleanupMiddleware, + async (req: Request, res: Response, next: NextFunction) => { + const releaseQueue = (res as any).locals?.releaseQueue; + const signal = (req as any).abortController?.signal; + try { + if (!req.file) { + throwValidation("invalid-image", "No file uploaded"); + } + + const input = getFileInput(req.file!); + const width = parseInt(req.query.width as string, 10); + const height = parseInt(req.query.height as string, 10); + const quality = parseQuality(req.query.quality as string); + const format = (req.query.format as string) || "jpeg"; + const mode = (req.query.mode as string) || "fit"; + + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { + throwValidation("invalid-image", "width and height must be positive integers"); + } + + if (!["jpeg", "webp", "png"].includes(format)) { + throwValidation("invalid-image", `Unsupported format: ${format}`); + } + + if (!["fit", "crop"].includes(mode)) { + throwValidation("invalid-image", `Unsupported mode: ${mode}`); + } + + const params: ThumbnailParams = { + width, + height, + quality, + format: format as "jpeg" | "webp" | "png", + mode: mode as "fit" | "crop", + }; + + res.locals.opMeta = `size=${width}x${height}, fmt=${format}, mode=${mode}, q=${params.quality}`; + const { data, mtype } = await generateThumbnail(input, params, signal); + res.setHeader("Content-Type", mtype); + res.send(data); + } catch (err) { + next(err); + } finally { + if (releaseQueue) releaseQueue(); + } + } + ); + + return router; +} diff --git a/media-processor/src/services/errors.ts b/media-processor/src/services/errors.ts new file mode 100644 index 0000000000..7afdf93e37 --- /dev/null +++ b/media-processor/src/services/errors.ts @@ -0,0 +1,26 @@ +import { ProcessingError } from "../middleware/error-handler.js"; +import type { AppError } from "../types.js"; + +export function throwValidation(code: string, hint?: string): never { + throw new ProcessingError(400, { + type: "validation", + code, + hint, + } satisfies AppError); +} + +export function throwRestriction(code: string, hint?: string): never { + throw new ProcessingError(413, { + type: "restriction", + code, + hint, + } satisfies AppError); +} + +export function throwProcessing(code: string, hint?: string): never { + throw new ProcessingError(503, { + type: "internal", + code, + hint, + } satisfies AppError); +} diff --git a/media-processor/src/services/font.ts b/media-processor/src/services/font.ts new file mode 100644 index 0000000000..16e4a0a091 --- /dev/null +++ b/media-processor/src/services/font.ts @@ -0,0 +1,313 @@ +import { execFile } from "node:child_process"; +import { writeFile, readFile, mkdtemp, rm, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { throwValidation, throwProcessing } from "./errors.js"; +import { createLogger } from "../logger.js"; +import type { FileInput } from "../types.js"; + +const logger = createLogger("font"); + +let fontProcessMem = 512; +let fontProcessCpuTime = 30; +let fontTimeout = 120000; + +export function configureFontLimits(opts: { mem: number; cpuTime: number; timeout: number }): void { + fontProcessMem = opts.mem; + fontProcessCpuTime = opts.cpuTime; + fontTimeout = opts.timeout; +} + +export function execCommand( + cmd: string, + args: string[], + timeout?: number, + options?: { encoding?: BufferEncoding | "buffer"; signal?: AbortSignal } +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + const effectiveTimeout = timeout ?? fontTimeout; + const encoding = options?.encoding ?? "utf8"; + + // Use prlimit on Linux for memory + CPU resource limits + // Matches backend's prlimit-based font processing protection + const isLinux = process.platform === "linux"; + let finalCmd = cmd; + let finalArgs = args; + + if (isLinux && cmd !== "prlimit") { + // Wrap with prlimit: address space ceiling + CPU time limit + const prlimitArgs = [ + `--as=${fontProcessMem * 1024 * 1024}`, // address space (memory) + `--cpu=${fontProcessCpuTime}`, // CPU seconds + "--", + cmd, + ...args, + ]; + finalCmd = "prlimit"; + finalArgs = prlimitArgs; + } + + return new Promise((resolve, reject) => { + execFile( + finalCmd, + finalArgs, + { + timeout: effectiveTimeout, + encoding: encoding === "buffer" ? null : encoding, + signal: options?.signal, + }, + (err, stdout, stderr) => { + if (err) { + const error = new Error(`Command failed: ${finalCmd} ${finalArgs.join(" ")}\n${stderr}`); + if (err.killed) (error as any).killed = err.killed; + if (err.signal) (error as any).signal = err.signal; + if (err.code !== null && err.code !== undefined) (error as any).code = err.code; + reject(error); + } else { + resolve({ stdout, stderr }); + } + } + ); + }); +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), "penpot.font.")); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function withTempInput( + ext: string, + input: FileInput, + fn: (dir: string, inputPath: string) => Promise +): Promise { + return withTempDir(async (dir) => { + const inputPath = join(dir, `input${ext}`); + if (typeof input === "string") { + await copyFile(input, inputPath); + } else { + await writeFile(inputPath, input); + } + return fn(dir, inputPath); + }); +} + +async function fontConvert( + inputExt: string, + outputExt: string, + input: FileInput, + signal?: AbortSignal +): Promise { + return withTempDir(async (dir) => { + let inputPath: string; + if (typeof input === "string") { + inputPath = input; // Use path directly — avoids reading file into heap + } else { + inputPath = join(dir, `input${inputExt}`); + await writeFile(inputPath, input); // Write buffer to temp file + } + + // Ensure input path is from tmpdir to prevent injection + if (!inputPath.startsWith(tmpdir())) { + throw new Error("Font processing denied: input path is outside expected directory"); + } + + const outputPath = join(dir, `input${outputExt}`); + try { + // Escape single quotes for FontForge's string parser (not shell). + // execFile passes args as an array — no shell injection vector. + // FontForge's own lexer uses doubled single quotes for escaping. + const escInput = inputPath.replace(/'/g, "''"); + const escOutput = outputPath.replace(/'/g, "''"); + await execCommand("fontforge", ["-lang=ff", "-c", `Open('${escInput}'); Generate('${escOutput}')`], undefined, { + signal, + }); + return await readFile(outputPath); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + // Detect resource limit kills from prlimit (SIGKILL = OOM, SIGXCPU = CPU time exceeded) + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err, inputExt, outputExt }, "FontForge killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err, inputExt, outputExt }, "FontForge conversion failed"); + return null; + } + }); +} + +async function ttfToOtf(input: FileInput, signal?: AbortSignal): Promise { + return fontConvert(".ttf", ".otf", input, signal); +} + +async function otfToTtf(input: FileInput, signal?: AbortSignal): Promise { + return fontConvert(".otf", ".ttf", input, signal); +} + +async function sfntToWoff(input: FileInput, ext: string = ".ttf", signal?: AbortSignal): Promise { + return withTempInput(ext, input, async (dir, inputPath) => { + try { + await execCommand("sfnt2woff", [inputPath], undefined, { signal }); + const output = join(dir, "input.woff"); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "sfnt2woff killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "sfnt2woff conversion failed"); + return null; + } + }); +} + +async function woffToSfnt(input: FileInput, signal?: AbortSignal): Promise { + return withTempInput(".woff", input, async (_dir, inputPath) => { + try { + const { stdout } = await execCommand("woff2sfnt", [inputPath], undefined, { encoding: "buffer", signal }); + return stdout as Buffer; + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2sfnt killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2sfnt conversion failed"); + return null; + } + }); +} + +async function woff2ToSfnt(input: FileInput, signal?: AbortSignal): Promise { + return withTempInput(".woff2", input, async (dir, inputPath) => { + const output = join(dir, "input.ttf"); + try { + await execCommand("woff2_decompress", [inputPath], undefined, { signal }); + return await readFile(output); + } catch (err: unknown) { + const error = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + if (error.killed || error.signal === "SIGKILL" || error.signal === "SIGXCPU") { + logger.warn({ err }, "woff2_decompress killed by resource limits"); + throwProcessing("resource-limit-exceeded", "Font processing exceeded resource limits"); + } + logger.warn({ err }, "woff2_decompress failed"); + return null; + } + }); +} + +function getSfntType(data: Buffer): "ttf" | "otf" { + const magic = data.subarray(0, 4).toString("hex"); + switch (magic) { + case "4f54544f": + return "otf"; + case "00010000": + return "ttf"; + default: + throwValidation("invalid-font", "Unrecognized font format"); + } +} + +async function convertFromSfnt(sfnt: Buffer, targetType: string, signal?: AbortSignal): Promise { + if (targetType === "ttf") { + const stype = getSfntType(sfnt); + if (stype === "ttf") return sfnt; + return otfToTtf(sfnt, signal); + } + if (targetType === "otf") { + const stype = getSfntType(sfnt); + if (stype === "otf") return sfnt; + return ttfToOtf(sfnt, signal); + } + if (targetType === "woff") { + return sfntToWoff(sfnt, ".ttf", signal); + } + return null; +} + +function validateFontSignature(data: Buffer, expectedType: string): void { + if (data.length < 4) { + throwValidation("invalid-font", "Font data too short"); + } + + const magic = data.subarray(0, 4).toString("hex"); + + switch (expectedType) { + case "ttf": + if (magic !== "00010000") { + throwValidation("invalid-font", "Invalid TTF signature"); + } + break; + case "otf": + if (magic !== "4f54544f") { + throwValidation("invalid-font", "Invalid OTF signature"); + } + break; + case "woff": + if (magic !== "774f4646") { + throwValidation("invalid-font", "Invalid WOFF signature"); + } + break; + case "woff2": + if (magic !== "774f4632") { + throwValidation("invalid-font", "Invalid WOFF2 signature"); + } + break; + } +} + +export async function convertFont( + input: FileInput, + sourceMtype: string, + targetMtype: string, + signal?: AbortSignal +): Promise { + const sourceType = sourceMtype.replace("font/", ""); + const targetType = targetMtype.replace("font/", ""); + + // Same type: validate signature and return data as-is + if (sourceType === targetType) { + let data: Buffer; + if (typeof input === "string") { + data = await readFile(input); + } else { + data = input; + } + validateFontSignature(data, sourceType); + return data; + } + + // Source is TTF + if (sourceType === "ttf") { + if (targetType === "otf") return ttfToOtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".ttf", signal); + return null; + } + + // Source is OTF + if (sourceType === "otf") { + if (targetType === "ttf") return otfToTtf(input, signal); + if (targetType === "woff") return sfntToWoff(input, ".otf", signal); + return null; + } + + // Source is WOFF: extract sfnt first, then convert + if (sourceType === "woff") { + const sfnt = await woffToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not extract SFNT from WOFF"); + } + return convertFromSfnt(sfnt, targetType, signal); + } + + // Source is WOFF2: decompress to sfnt, then convert + const sfnt = await woff2ToSfnt(input, signal); + if (!sfnt) { + throwValidation("invalid-font", "Could not decompress WOFF2"); + } + return convertFromSfnt(sfnt, targetType, signal); +} diff --git a/media-processor/src/services/image.ts b/media-processor/src/services/image.ts new file mode 100644 index 0000000000..302f41ddf5 --- /dev/null +++ b/media-processor/src/services/image.ts @@ -0,0 +1,204 @@ +import sharp from "sharp"; +import type { FileInput, ImageInfo, ThumbnailParams } from "../types.js"; +import { throwValidation, throwRestriction } from "./errors.js"; +import { createLogger } from "../logger.js"; + +const logger = createLogger("image"); + +const SUPPORTED_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +function orientationSwapDimensions( + width: number, + height: number, + orientation: number +): { width: number; height: number } { + if (orientation === 6 || orientation === 8) { + return { width: height, height: width }; + } + return { width, height }; +} + +let imageMaxPixels = 128_000_000; +let imageMaxWidth = 16384; +let imageMaxHeight = 16384; + +export function configureImageLimits(opts: { maxPixels: number; maxWidth: number; maxHeight: number }): void { + imageMaxPixels = opts.maxPixels; + imageMaxWidth = opts.maxWidth; + imageMaxHeight = opts.maxHeight; +} + +function validateImageDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "image-dimensions-exceeded", + `Image dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction("image-pixel-count-exceeded", `Image pixel count ${pixels} exceeds maximum ${imageMaxPixels}`); + } +} + +function validateOutputDimensions(width: number, height: number): void { + if (width > imageMaxWidth || height > imageMaxHeight) { + throwRestriction( + "output-dimensions-exceeded", + `Requested output dimensions ${width}x${height} exceed maximum ${imageMaxWidth}x${imageMaxHeight}` + ); + } + const pixels = width * height; + if (pixels > imageMaxPixels) { + throwRestriction( + "output-pixel-count-exceeded", + `Requested output pixel count ${pixels} exceeds maximum ${imageMaxPixels}` + ); + } +} + +export async function getImageInfo(input: FileInput, size: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + let metadata; + try { + metadata = await sharp(input).metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + if (!metadata.width || !metadata.height) { + throwValidation("invalid-image", "Could not read image dimensions"); + } + + const mtype = metadata.format ? `image/${metadata.format}` : undefined; + if (!mtype || !SUPPORTED_MIMES.has(mtype)) { + throwValidation("invalid-image", `Unsupported image format: ${metadata.format}`); + } + + const orientation = metadata.orientation ?? 1; + const { width, height } = orientationSwapDimensions(metadata.width!, metadata.height!, orientation); + + validateImageDimensions(width, height); + + logger.debug({ width, height, mtype: mtype!, size }, "Image info extracted"); + + return { + width, + height, + mtype: mtype!, + size, + orientation, + }; +} + +const FORMAT_MIMES: Record = { + jpeg: "image/jpeg", + webp: "image/webp", + png: "image/png", +}; + +export async function generateThumbnail( + input: FileInput, + params: ThumbnailParams, + signal?: AbortSignal +): Promise<{ data: Buffer; mtype: string }> { + // Check if request was cancelled before starting + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + // Pre-validate source image dimensions using the same sharp instance + // that will be used for the resize pipeline. Sharp reads metadata + // (dimensions, orientation) from the image header without fully decoding + // the pixel data, then reuses the instance for the resize operations. + const source = sharp(input); + let srcMeta; + try { + srcMeta = await source.metadata(); + } catch (err) { + throwValidation("invalid-image", `Failed to decode image: ${(err as Error).message}`); + } + + // Check again after metadata read + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + if (srcMeta.width == null || srcMeta.height == null) { + throwValidation("invalid-image", "Could not read source image dimensions"); + } + + // Validate source image format + if (srcMeta.format && !SUPPORTED_MIMES.has(`image/${srcMeta.format}`)) { + throwValidation("unsupported-image-format", `Unsupported image format: ${srcMeta.format}`); + } + + const orientation = srcMeta.orientation ?? 1; + const { width: displayWidth, height: displayHeight } = orientationSwapDimensions( + srcMeta.width, + srcMeta.height, + orientation + ); + validateImageDimensions(displayWidth, displayHeight); + + // Validate requested output dimensions (important for crop mode which can enlarge) + validateOutputDimensions(params.width, params.height); + + logger.debug( + { width: params.width, height: params.height, format: params.format, mode: params.mode }, + "Generating thumbnail" + ); + + let pipeline = source.rotate(); + + // Only flatten for JPEG output (which doesn't support transparency). + // PNG and WebP support alpha, so preserve it. + if (params.format === "jpeg") { + pipeline = pipeline.flatten({ background: { r: 255, g: 255, b: 255 } }); + } + + if (params.mode === "fit") { + pipeline = pipeline.resize(params.width, params.height, { + fit: "inside", + withoutEnlargement: true, + }); + } else { + pipeline = pipeline.resize(params.width, params.height, { + fit: "cover", + position: "center", + }); + } + + switch (params.format) { + case "jpeg": + pipeline = pipeline.jpeg({ quality: params.quality }); + break; + case "webp": + pipeline = pipeline.webp({ quality: params.quality }); + break; + case "png": + pipeline = pipeline.png(); + break; + } + + let data: Buffer; + try { + // Sharp 0.35.3 does not support cancellation of native libvips operations. + // toBuffer() only accepts { resolveWithObject: boolean }, no AbortSignal. + // We hold the queue slot until Sharp completes fully, then check signal + // to throw if the request was cancelled during processing. This prevents + // concurrency limit violations and handles timeouts gracefully. + data = await pipeline.toBuffer(); + } catch (err) { + throwValidation("invalid-image", `Failed to process image: ${(err as Error).message}`); + } + + if (signal?.aborted) { + throw new Error("Request cancelled"); + } + + return { data, mtype: FORMAT_MIMES[params.format] }; +} diff --git a/media-processor/src/types.ts b/media-processor/src/types.ts new file mode 100644 index 0000000000..22c702f5f7 --- /dev/null +++ b/media-processor/src/types.ts @@ -0,0 +1,44 @@ +export type FileInput = Buffer | string; + +export interface AppConfig { + port: number; + host: string; + maxConcurrentRequests: number; + requestTimeout: number; + maxFileSize: number; + memoryThreshold: number; + imageMaxPixels: number; + imageMaxWidth: number; + imageMaxHeight: number; + fontProcessMem: number; + fontProcessCpuTime: number; + fontTimeout: number; + sharedKey: string | null; + logLevel: string; + lokiUri: string | null; + lokiJob: string; + lokiEnvironment: string | null; + lokiInstance: string | null; +} + +export interface ImageInfo { + width: number; + height: number; + mtype: string; + size: number; + orientation: number; +} + +export interface ThumbnailParams { + width: number; + height: number; + quality: number; + format: "jpeg" | "webp" | "png"; + mode: "fit" | "crop"; +} + +export interface AppError { + type: "validation" | "restriction" | "internal"; + code: string; + hint?: string; +} diff --git a/media-processor/src/upload-storage.ts b/media-processor/src/upload-storage.ts new file mode 100644 index 0000000000..1677e3efe7 --- /dev/null +++ b/media-processor/src/upload-storage.ts @@ -0,0 +1,89 @@ +import multer from "multer"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { Request } from "express"; + +interface HybridStorageOptions { + memoryThreshold: number; +} + +interface FileInfo { + destination: string; + filename: string; + path: string; + size: number; +} + +function getContentLength(req: Request): number { + const cl = req.headers["content-length"]; + if (!cl) return -1; + const parsed = parseInt(cl, 10); + return isNaN(parsed) ? -1 : parsed; +} + +export function createHybridStorage(opts: HybridStorageOptions): multer.StorageEngine { + const memoryStorage = multer.memoryStorage(); + + let tempDirPromise: Promise | null = null; + + async function ensureTempDir(): Promise { + if (!tempDirPromise) { + tempDirPromise = mkdtemp(join(tmpdir(), "penpot.upload.")); + } + return tempDirPromise; + } + + return { + _handleFile(req: Request, file: Express.Multer.File, cb: (error?: any, info?: Partial) => void): void { + const contentLength = getContentLength(req); + const useDisk = contentLength < 0 || contentLength >= opts.memoryThreshold; + + if (!useDisk) { + memoryStorage._handleFile(req, file, cb); + return; + } + + ensureTempDir() + .then((dir) => { + const filename = `${randomBytes(16).toString("hex")}${getExt(file.originalname)}`; + const filepath = join(dir, filename); + + const ws = createWriteStream(filepath); + + file.stream.pipe(ws); + + ws.on("error", (err: Error) => { + cb(err); + }); + + ws.on("finish", () => { + cb(null, { + destination: dir, + filename, + path: filepath, + size: ws.bytesWritten, + }); + }); + }) + .catch(cb); + }, + + _removeFile(req: Request, file: Express.Multer.File & { path?: string }, cb: (error: Error | null) => void): void { + if (file.path) { + rm(file.path, { force: true }) + .then(() => cb(null)) + .catch(() => cb(null)); + } else { + cb(null); + } + }, + }; +} + +function getExt(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot >= 0 ? filename.substring(dot) : ""; +} diff --git a/media-processor/src/upload.ts b/media-processor/src/upload.ts new file mode 100644 index 0000000000..3ba22fb899 --- /dev/null +++ b/media-processor/src/upload.ts @@ -0,0 +1,50 @@ +import multer from "multer"; +import { readFile } from "node:fs/promises"; +import { createHybridStorage } from "./upload-storage.js"; +import type { Request } from "express"; +import type { FileInput } from "./types.js"; + +let _upload: multer.Multer | null = null; + +// Hybrid storage: small uploads (< memoryThreshold) buffered in RAM for speed; +// large uploads streamed to disk to avoid heap pressure. +// Default threshold is 10MB. Disk files are cleaned up after response finishes. +export function configureUploadLimits(opts: { maxFileSize: number; memoryThreshold: number }): void { + const storage = createHybridStorage({ memoryThreshold: opts.memoryThreshold }); + + _upload = multer({ + storage, + limits: { fileSize: opts.maxFileSize }, + }); +} + +export function getUpload(): multer.Multer { + if (!_upload) { + throw new Error("Upload not configured — call configureUploadLimits first"); + } + return _upload; +} + +// Returns file input suitable for sharp and font processing. +// For disk-stored files, returns the file path (libvips uses mmap). +// For memory-stored files, returns the buffer. +export function getFileInput(file: Express.Multer.File): FileInput { + if (file.path) { + return file.path; + } + if (file.buffer) { + return file.buffer; + } + throw new Error("File has no buffer or path"); +} + +// Returns file contents as Buffer regardless of storage backend (memory or disk). +export async function getFileBuffer(file: Express.Multer.File): Promise { + if (file.buffer) { + return file.buffer; + } + if (file.path) { + return readFile(file.path); + } + throw new Error("File has no buffer or path"); +} diff --git a/media-processor/test/config.test.ts b/media-processor/test/config.test.ts new file mode 100644 index 0000000000..019cbd4947 --- /dev/null +++ b/media-processor/test/config.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("uses defaults when env vars not set", () => { + const config = loadConfig(); + expect(config.port).toBe(6065); + expect(config.host).toBe("0.0.0.0"); + expect(config.maxConcurrentRequests).toBe(10); + expect(config.requestTimeout).toBe(180000); + expect(config.maxFileSize).toBe(367001600); + expect(config.memoryThreshold).toBe(10485760); + }); + + it("accepts valid config with all fields set", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080"; + process.env.PENPOT_MEDIA_PROCESSOR_HOST = "127.0.0.1"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "20"; + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "30000"; + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "104857600"; + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "5242880"; + process.env.PENPOT_MEDIA_PROCESSOR_SHARED_KEY = "test-key"; + + const config = loadConfig(); + expect(config.port).toBe(8080); + expect(config.host).toBe("127.0.0.1"); + expect(config.maxConcurrentRequests).toBe(20); + expect(config.requestTimeout).toBe(30000); + expect(config.maxFileSize).toBe(104857600); + expect(config.memoryThreshold).toBe(5242880); + expect(config.sharedKey).toBe("test-key"); + }); + + it("rejects concurrency=0", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "0"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "-5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional concurrency", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "2.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "-1000"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "1000.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "8080.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative port", () => { + process.env.PENPOT_MEDIA_PROCESSOR_PORT = "-8080"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative max file size", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative memory threshold", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative image max pixels", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS = "-100"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max width", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects fractional image max height", () => { + process.env.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT = "100.5"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process mem", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM = "-512"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font process cpu time", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME = "-30"; + expect(() => loadConfig()).toThrow(); + }); + + it("rejects negative font timeout", () => { + process.env.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT = "-120000"; + expect(() => loadConfig()).toThrow(); + }); + + it("accepts concurrency=1 (minimum valid)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS = "1"; + const config = loadConfig(); + expect(config.maxConcurrentRequests).toBe(1); + }); + + it("accepts timeout=0 (edge case, might be valid for testing)", () => { + process.env.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT = "0"; + const config = loadConfig(); + expect(config.requestTimeout).toBe(0); + }); +}); diff --git a/media-processor/test/fixtures/font-1.otf b/media-processor/test/fixtures/font-1.otf new file mode 100644 index 0000000000000000000000000000000000000000..9326ec78445b2edab3c105cee4666ad6c8f43606 GIT binary patch literal 83280 zcmdqK33yah(m#9xBx!;KQ5!|0G@C%njvyd921p>XB$8ZE8O0C+M6!{D%@uWg9lg$| zqk_2WsA%`-xbKRJ<1Px?ZiwK58@P?j?a+by{eD&V<~E2k^UnADpa1iHBGuLRoKvSx zojO%@>YUpYmz5QpT(jJ?HK&cuFD_roWY6ql|#b7Qf|=rGxs;onavwZ?u{v++ul zUvEstJ;kMihR!?r+N*$3WYm&;C2#{4w}G~u z;Nfk}WK-qg?aV;)k%woR^D_?j@GKL}*zV!k=9xAFJv?NNZ0pl$Zw_jEtM|Nv8QS(` z5AWFX;dTdlc&?c^DsDZDXXQ0S~63-aNFZM$HC%t(>>0x*W(53}>(zj=x7^TE?5P zrT|zvb)U9U9Kf|n8p@k0 z=R~S%BTe%wBUWt{zm+!OA)43D8C2I0sY6Laq@r$7ZBs*4Wg~j{Zw73ByLXz=a#>Ch zESiBB*FwWYEagMX{d)l%>1UN z`q6_1Em^W;U^$0Bu%fPJP}8z{6pe%M>QRrP^9{pLsNoR&?GGjt9}4Wh8E?e&{a^6g zPkLM;Dli{C(!w<2Zvz%nCB_#qh2WMG9Whfe;u<`Sm_ECydmj;9J{DKCQ1C}Z721;? zg`B=r{=cZ`9Zinm-!jNqmuVm)j%_ZawFI(i5Lsyr8Xj7C+IN*Jmk$>;my^F3OR;Px z<9o5_5RG9#na(SqKt0);S)}p&HGjMJ?#rBuxE@rMADWSBJXsoH~;4VwLGe9}R*KE%0Jj_cu=7&0u2POy*AG`- zt;B4!1LTjTS6g3;8q_4(N@_AWWVzO?!E;iiMpGUU@gS5$ zFfwvX=~kegj>GSN* zdp$g9%*EKaz4RsI^TgSr7OF3yb>J+r-mhmgeHBOc8oy5j9j*}IU zP%WYlq}=_@o=R0MP$Ov4hWk3A^Xc30bh|;T!qpgL7lIMe#?UHo#9RTp$7Ja%WB0jM zi$fb2%uSD9MfmA4tG{FTt-0`5NBVkigyzv#?{2XA%s#^s!DsZ@c@#-S-f`HCBI~_GT+pQ(59$WJW^l#kT=?O1|O>Ifr2Mzgi8`kX$y z`64^Yqrvd#v5EWOHih%B+idY zAl>hAUDFbif7T|ax(=xQdq!X?0e}8}TULPtxKo>keM}L&1NU$A6A>h&X|aiq|CRqJ z5snzMH{y@KcGaqKj_3(=4pjo=(~p>fI9#jQ zJJ)Xd>QjyJS14!G*DuXAa7Pf-*E!Pz9*v+iQ|EK7t@jpt4+NDm(K$w=q{wml5~5D{ z`#_BXch^xrCM2KKwE_6lJt}F@QY{6fZ}MYX%3?me1U01=^(k?B>mZj${nt!}#>}dJ zs-1xhxI3U9BCJAv_NDVnNwRk3Ua8amkX!c1Pvwm|IY-uvzoYc~^GBdu%ofUw0;om7 zs3>R7(cbpwbtoZktR3{TyPS3JMj4SV*DF1)N__W-OKogJ+WHyhSr2-$f455io3)}n z2KqM*d~rnoDnFmT|Ij@Rr9)YAPe ztf{i}y*j1LdGtr+&vtt5>PVSyU9$mAU*fxa?SD7&pjG-lCOwJ^YVf$v=j&hjB+cNd zPxg27YHxKkw52*qUBUFsoPSydY5{WrT=`rxy6U)EH3P!g&=Oh?u9bhM@k(_mG_DeQ z!CBD0X>k{T2JpyklHQIn$E`FqAAu_i}~;NNJ^QSz`0PpU@!b0$lCWcTo;;o(v|n0 zui%360hQ%!zrKFmM0*;|yMGtD(c z|4Uy`iX>@`fP3!2O0gfC4Bm@i zKe%I4-X{WQ?#Mr7D+N}F@y$R@o>1{hLn$E7tBP^ zR@_6Epl{Zg0KUqE3--j(u_mQbhH~COVte*FNuDZalnG~4Y&p*$vw6iOk#0?Y_0=@Yi6_E5jac@k8bs@;^TUsq+QnGDX~37AclyVBcp z6dczKky#*hl`}b{p0GzhOA^d6`y|gkKh#<3lD`rHsc@B%6ZXWp<~R1hl|W4@hD=%J zuR`s^pGQmD`c&C&clr9O#joeL`&Sl1W#sow+QHbV9h9YV$XQlt{Cm1;z0A0}`ehAr zb8qP2A%puz0Ehmot4)3PdIhLo|HzW6run%3)>zrF7uQ@KQ*oIz(nqh`^%)q+ zuda?vV^@u>^#(?~+kpSau2*%;>6lYCALNCj(z>}#OUfH6BY03us-3r}3SC!#cumbBT-3u|Je@o61nP^)M|os!LnS^iqjgk^xed^0u(^OY{FcnG zs+cd_G)8L5mtp!Njq@?6IjR9QB!VxfSzq4JREx`)^Q-Dr59AJDqo+%dP~l{#3zVl( zhR~{*919-Rk@O;HYJUi8(Hy8+|42>UoT|B0I`W1-F_wm^*^7AHkStd(iuTrm z6SIKt5O-}|Q>3x38mhXC4{I7Ls~6)6VuW)ho%IL171ij24QrQSA{wd|tJpbgNT{N` zmh8;NqNye$l{K>~=gc9tI~U$+2zX)D`*FRV)%JqLCQNqDG3A%R(m?#POx7A#H%F z7kDqAXlhYeQSsE$jyXLFsq?<^>H#+9LY>$NDK%EgDw$hV4HjAls#6>Z?E6Gad}Tx5 z#(sNapK4SA5jB8!UT~#mHAWg2Rm`VEFivW?>IOR08{E=if2cwgG|IDNIvL)mWetj! z_xQDANFN17rSslcUs>TPwss#WpWEbFXP8;fRWQDE(siu&UbfYTwSKQ>Tn* zLSQdw6ENAkiIWzfzOEM3pmvRs^riIMGSptD_^Z&nS)JaiOnH4`Z<58&xIP-Eg`cI*c>>my4uFkj2MRWi%hLV1m-Q3-I#}2YVZ}O z3JOq#mT+&2Fe8Bu(eg)HR}mWQsoSutzL)bn2xwJS&y|@O+V5Yt-RtOldab@BU2x*r zcwc_+9z;}(KH763mF4ioGJVt}OsKk2L=A`-_*`RL7-$4_Vx*dA)zYngBPyzM^X%N5 zh$m56!X{YA0p;+h2wEzaHbp$+H-Ax0dF=p%8*?Pes58Ui#m>|i4B=XGG#Yvb_@?^Xrdy|}7! ziF!Y3ev#tIEo~AKUxwIbgt7?*U-GuN6D=KXndh3tI zMlEmyd^`-T*uQbDQ=)Fk^D?LbGvL|jT1GMOrd%DKO@JWbogfnWLwXEa?0U3ZmBm{U^$zDu>Zlp_0a7|qkY(r)3oVo@` z4pUb?2LVV^6~55a?75aub!kOqy)5hUiUqZGOAt%VQ<1k?LagmHKK8~loi;_A#&m-Q zn0Shq;$KXVXvJ`q&Td2W%osyU3?nd8VjmdDdb$mxU5z2vENYbaD&1>hE)YCn@_rpA zsX3HJo)FU;YJG8W7(l~rs}olB|9nignlcG^Jn~s81}=En8`EVcCrQX$6s@(n!g) z;^{@>3&ux!2jAt}p^-#!Up}{INxo zi^@*wA1N#3e6ih9cmOr_Fq_m`9 zToDn7u4r1pI53Es5H|*or=Wx&qqN`z3lC7w?*el$u|QY{&-wU&9MwZdJQX7(owDL- zkb%d_jH1$l{*nA?MWvjC!fC}*Kpay68F7pjM2MdRVte_Z~fa^*+2$-+s{}`VSa52#K~~M-Crx)W}hz zj~;W(vA;Vmf9yEy`X)>)I)2jRDN~C}PMB6&W=)@Q;z=jZ{QW7X&N{8!{Le)f+gDz5 z<6m#R?T*`5-xZJj?e4$dbMJi*JoMnhk3RC`6Hh&D8s=11ZCZLu{esUHpKktmEsjOZ zyd}b5^;zc5$Iq*%<(HbXKbn31g)5(1n|SAgjqkqqn0fYj^XZn4KmWokd;df8hf6Q` z(uI8R>u{v`9rCqPkSm>uGeN$?u>>l2p}Evt zX|6Rln%m9a%md~zv&JMd+GKRi=sT}^S^fNBNCgcZJb1i(gA(#*V@_ zq|R6cA4BwmRq%~f@NuMm4A+kl`f-$gjMNX0O1^v>qQr(Mv7v?XaCl+Cq6WMzS~YZd z;W&I0j+M^vF;qAnB5>$`{gQ9+=C+rL^Lp!{tp9RU! zH|_C$SqCibj+li`W?$3U>}U4J3+!RMUVorD$aFOan?o==hvG%{ZdlIUajMnR^fJA1 zm$46Sm-oZ09RUL|0Cs1Pyf-@33^PaKmF5w6qim!Zh1+dMn=$4Xb1Y`|ICB=P=3TSVyl37wADR!$P0-no@oN8O zv&DR3ZZ)5pKbxz}3()6tGya@$S;plVS7iJpF32b z9SGrEv?p{1x((dx+?r|g`XHFbIMTO|*aJW;Z-bZ+V5>D^|`o>4jD4<}X4 ztUmRCQ(r&z%Zhs{o|?C_>b0s5tF~ADT=P)P^K~2RcGS14?^u6e{Z9)YUii$ymlwX> zbVSq9O}|I#<;ulV7tdb2c=5%HuV4J|lFyfpUV8k}6PH#lZCHBY(yN!=x%9E68FjQ24?4Tx?6Pwfo_FVYPoMYpd0(C9o}YKYs*7H{ z=$(tU{n7j}{Kr9mJnoOBf1LBjv+Zqm^QHS-8o6}XrG=NC`lt1m550Ws<wdg$*YyWn-}CyzukV+76QFi8&$quOuRO*nr&3;_)-vdY0RE{&&o|{^mDzr152fMJ*U->;V z_h;vj@FTAKUM%jKBNLYEZdz_!a}2(^pIBBp_B#}~-z28HCLaZEvt@-+r#hXSitKM( z(~)(j5F8!_rIE4tD?ZHQ(~{-J{>Dz6?V2&mP2J;lkGt-N2|VvZqKQ}>-4UMRnnO_C zUB@;0 zU_)Xt*K}s-v9Y+l7R?4?9PZCj3oYGkY~HIC>&?-U+0U_h+$PGb7q zuDgLv@<1UCdx_HRjLzJrNjeJyab~4DrOLBi_jWe@f#4BH+5LcBykc2XeYlkm9!OZW z>)r+?UH2)=TIISgN8_tcTYVaa#PW^oAZl4P3LkUbtz=_J3}s&ev|^7nNDV0u1*k`6 zODw*6-s*WEzd^cs8A5d3HDvA^g4amzgIK&$;k2dTOsYI9@C6HMLY;75W$y>Gc7VM> zJ|dPi>%LHKbKYA!3d22eoc+7>%yAa%nicMqlj^f;rvE6{ne)2yeHfjC1=qAsfadE| z!u?58L>yp;u=Kse^uN3AX3J9eu;;v=syOvct3F9ID9Do^3#e1IJ9LHw?_8LpaCZ^AFO##tf ztV6y{FY+i1V0A!p3L|`aSQO=40Gw<{&qsABn;xvwS!fUhSSW!W`eIHVv1M+cxqNaQ z$hqrd@h5$_2J>JJ;%NRRN(vF$Y8G54VL>&&69okneQ@2cSobr2{ge!~qOSWjI&eRV z#m}ld3+FC!JN9AI2Avz7zQRCOD3$@wyevLbJW#gd(Cyw&V+@|`_YaPJ^(wE zu%5W@iE&U_^7dH5ddYR4h{Y8`zoQ`hEafN0{DoM&qG~o2XSpd~T8_m`y>x$JQwkNL zf&!z{O<=`j#^MWH)4{S7!rbl~i#OiV2zCAjWX-|k_n)kJ7^wtUGfTV2Vz%f{$Qjx^ zDQvI37VKNrUGhw!OulEahq7NYfOBD}RAL}?@qYs9X_%OXO{7k5X4k+)CBVpMyXi=h(`zJ6df*umG)c-`W<#h9FJ(Kx7VBZ2Mgs~@rNQOERU#h>ui^dZpz z+Yv>*!=wHXb6f84eR^)oQO=m;>tQ=J5&EA1rIXoy5Zliu^#B_ijiq{VX`^Tf*!$^8 zSde>J=X^eTK4CeLIj*_dvJ{HBam^}mcQ=Tr03jQ)il_HWU4^bWItoIUTCg23d#o@Q){F3YShEYy)Xs2BmY& z%2-_CUtM#1!m^!-wrd`Qir{N3Hq$jLVsVA&jib68ZP~qZSg0pSk}vnHSo{UoZNySA zhjY}Yvu1$p&pLBuwr7iKR`3C)DK)@#-zUxntyqbY5-E~&C^t39>5$hh7Isb^l?tWG zHWfR)o$*fZ)cB1rrm~ze=j2T_U!~@qyws`dV=qlDOwD;ZGk1M58BT^;dOBT`J(KO7 z{ae~6d!@Rz^lAwqw!33n>d)ccIk_!;HYcs{;Xv+ix-#0!O}%?l>cgtMUuvMfrilq}?2U*vmA6eb~!*f&lAGjici=T`cQig?GT?@ri%~SU3TicDo$-Xb{Zgrx@gOz9l(RkDcKq8NRkQOY>y z@0yWXlbxH$YgZHA7R|ix5e?`VyGZDw!eS8PVMDpDI1?(t% zhtv?7&AfKzwO+`UO@YCt*`Z@FHIJi$`#r&0GLEI2j=e;EBFpaUmNhfk>~alSSELJ``z3ZXmSaugAc z>`Ss9UpOODuRFPmc4a+98*tOf0Hq@HUKWB&1_3Jdau0n?8?$;!Rm_;T1H2dkq zCa!-PHsuHIK!M4XM&TLn$Wx^Rg%@RcISJUPT_4~=O4*w zm+QFH($d(MZVT*qP}WECkCSMZX@;V?*t9AiBb`-D`Y)^w;{ zcTqmT(?BdS{bbpSD#VOXl%Z(6lwJ!{LWyu1QzSz7DtqR1Y`HlHCFscg4idg6d0_I- z$VDB#v%7uT&h}e5QLd^8qII zTXY|$VxjnAz@sheoL6mGt$;&l7<15=yCgVc(hsn{EX{+o=q+?x6`-pT21tEsK^g{X z&x7)@BI=7!=6)0f)k*+*>vcZ7oqTtvTClRA^M40^sykvUpRJ# zG?If#!y?vnWV7f@IOUhV_Hs-0V7Q+p#jFge%G;KfbE|^N0=&!Fy3Z`9Z`Vy_!yf}=r`e++`Y!)4Oq?m zU}%SBwVl-^S`&7C40W4HW3#aTH?UF!$-zFp?!!+o2c{g1GU{~Aa+>R{dF?I^H+RdL zGz5wejSrKQ+;AU$u-pt4ErEofD}$O`_eS;(W8u212z+e0>t4nw-ofGvVN0BGS;#c7 zpL6iYvbGd~tr2$fD)-+F1KQ&=^RRRueo3N(|8V=xO&CI~QQKg_Ao`1{|4 ze%kp_cJ5~9ldwHw1)|<)9CrV0%Tnl?LqSn8vDCH&qJ0YVpnj6;K;F|vI6n0x9C-Ff6>aE&dWKq^=l(hp)R{Q`7dxnTY9D#DTnZrV;bPmiB ztb{;`2H@-{zOUKa!K@izUnD>n8T0y^>u;uaM~(gPh$%41+=Q;mh8xOdOX+?;J`Ym_ z9^9WzFpleGi7C_w!5oTC#R94?rY%$~lbuE&kh^Wqayw*wV8S}%;WHlgG@C)U1{sGz zmjYC`?M#xKdi|-_WBHO;hzo+Do9sUT*Ri#OI^u#Aq9vGhzoMQ)kv$9LWAFn!bl?g= zu1n!BthFIZvadt!@bNeYfvBS7UQv97@Sp7e9i%p@LKkFOnAwUjvMKfy~r&ME-CA z$oF%7oBr$sF$zTeO)RePKJkiJ)Zj|t?XYq%xHUH-c8AHGhfX1YCnyqbOx>53o8OD! zQ-}>r6wH2|K)Jd~4HCHhlHI>X{0vXhuusS!w&GYdU1){$TxUCjAbu$iPT3LHIPP zVjuPshl9z-a9JPgJ{|jr1@A03vm`u#eu}5=#7ZATW7p}_G548{ZBC%~{c#9&OC4+o zctHe-2obTPLMSYApmFdE&O*7HcJ#y$-DRMvuJ*cUeBH!#$Pu2*?mxmbn1wRD5z=Tx zEOxB~3J7)a1aMF+hHw$SV~w{%UO|q2re>~2XVi!BKKz_dUqD|Pw7n(EZVT%Jork9^ zde>sYLE+o9Dy?~7SpeYnJ&+L}&9v~1k{kxy%%@$+HGPWMyZ+rX|6U=M@N}`3h;Cpx z7rq3`RUm`P7kDhqh`7L`DTjmE+{d2rQW?ly`~wCA@ZNXC5Pjjpj;vlM(z!sEu&LrO zeu~Av&=qK=qK2$R#kNqW`Bd|(UvlbR^wt41 zxSp?5!>*ROD<6uD7VIE?ML@k;LTQESu{5B@rU&Fp1&-xB(hl-C)H{6ivAd$)(bT!% z3Ca%EB*eiW*y;^_-AWK(5_BU}Dz}+JTZ=+HhrtSYQq~EetnhKz+kT9Ev6l*MUeXTv zP(M|A?~Zqw9%WW_N1N1kY#ds~JHt|U{L{YDx#NfAc;`qw+JzEkUb}_+x25v z&8Y97e%>yAqz+GxPaWyp(c8Wtbw{t3@u^`vOMgGF-K*hk!>|Q`96Z0`Sun5!6x7AjDH_BVk~0K9XmHmcv5xVSw#IzT!%8 zlOd-McD*~OVd6I0a+F7aOC~6wa0(?gE9ZE~3*9X_A;5!Jw>#^~dW@itG)y!Q2QtX< zM`Ce>P)=6wK|8Q7tM_8Z0Tx1L(H1Gg&6v3;6cL#cvi}U%x4;p1y6)8wYQBxVSd=4u ziLsXl*P#m3hye}@d|KAsX(Q+u$=w);>5m}#;2Iyo6oh_o!L9hsQ^ zv4qwN0l6LY%NcjIBcW2@VWP;R#6-g!BWpla<#}6J-V;BaZ>b zX_u=MG+$3)ObjHs+rxr`pVp0f8YbDSx7d@cPFaZoCJ`U3{(_|o;5aw4y|_%+TdK*Q z@G*Czh}StXv@tm#)y{4jb&;Lv*%!ug$Tfl0u3YQlJUj7OfNf!EHd*`-qA0d8Is77F z;~T8{DaR0C@1Qj063!9j#G9|&^>SqlZ+g;$>kz};=+b2WRiiIdi0(T zEy#W6iY|R~QW-n%4fo5*bw)XpyCjc;W=ul!HzoThi7C%*U`s97vz|K|Kw_K%x@r&W zk(dsNNHD~RCJoyMEaG63him8>5Oze^dOO{?1!nF667FG5?l4enlewz$%V&&J|c zOt3>Oy|SER^HLMT$;+K{vR$)S`Z<~FSB{1t7aaX7WO(C3#V`kdV>Mn5TY&6P!kTp2 zq|=6oGNwW}6F8^2Q#g4%?(4Y>knIo7lwf>;53w1WE+0Q7BeK2Z;QlE*$CWm)CETu8_ zwgJR`iGnYj!`gCbga(<$my!27H{&oi7FQ@+7^I00mktO5Y@l?{UPgeT8u|c38UP_} zEdGZ528fTetaG7oa_>Z;%stW(cZ+2UZF3B4oNVg*p{4tJG=>xjR-lAb>Dc}3wlnMe z6*|LHhr5@XGml$v+yZQ+613Zp4o>40zMZhfRu8Wpj!9c?qISw)$vi2#(%i8*b7x2A zxV+5YrH*TPac5@o#niF5I&v{e`{(4AzT!*^d)Hs;$;O!gyvOH^!Fbe!jkKo2ro&sp zzAHSO;y5gd>Y@iCtN;tcwo?d_uzT8v*{nVy8V6Y{q0o+rspEDXm5p(&;vl~zc$m5f z2cT(`RtzQd6z(Gs#7s26U^R-C<9Ll0-e-3bY;9sGaHb2PW-*^bV4nA{`9M zJM9u|Cn-LKHCG(kxx^X4Svw^=wPNR-0jbsJ+x?u?scV|&q)q{+&i!C)04(C$KR1sK z56p22x(vd%h>rG7=YO&i5cN2+83Iu=hJXr{h8+njW4f~72=U_z;WHlo>zco=S?OoF z3c*hTihXR97pjKJJ_pcC74^kAY%Fvd?A5CYQ124O2%aO&j;s=3PqJ@BWBKqkgJC&$ z4htt^&O#W@CeX*G;~TV8^D4_*46R^!pa$o{0Ockzpf~fur_`3mqp@kCE()>J01v7z zmw+m!U&Mo!H?aE-Y|2Q)!+H!JOUSk`4U-!MgE2CRDwr7q_Y5$I0u~U()*X8cg&m@JaH0(FGzi``r^n)FZ^K1R zI6H;1Wx|@VrsGO0s3`POIRikCF>fn`{ku+X=y5v}mszJt)GP-oY6Q(D9=4XH8!${J z)x8~PfYIc-kHlhit{aQR6`rl?1Y?P{1CGTt$T8rgX#B=?H)6whFy%86Z8iHXhoC+9 zRj_^(sw8+24|uL2z(#N);2K`UO&PI#xIMu~gUR0KY!F})x_uap-6L7Bkbc}D5!#It zZ$xI6Y`G667(M(A;vMd7_aKs`g-Z+TOXiijXwd9USFr_4@$nfwU( zu9+Mdmeku1du~#qXmOK*9b#FVA%rT?j{*vSi3>A&xoHxQu243z_vU70V6SiJH;aV@D-pG%6;}@XKYy4i`umUsvAs%hDYv)L(;2B48t5j z*11>Ld{2N$EFVD?hGeUK_zC-9_Kn;2n|-*El=kDbb{;u|QqwlUDlwch>{{RQ=#a1$ zUbFC;#%mW{zvz0%nWg)ayy$ut)(fy^mL3vgI2Mt)ImSW{tYij90R~vsJw6;m#pHEL z*{8w!{7yv3#mKzlQ(^%XN|S2Yc1my+`od|GInxuzKN1#FY)`O{!`L>!9wPBh;K2R6 zXaZc7jU`9sooM_-Q5uC_K=MTt?ctFY`0x`F{2&@%EC)FXk*KE}-xPNR1H`NbvWB|P zbl+y5`?62*YylP~?aj*#7A7RDP{S*I69MvCDpFgDdf$OZ$uaZpK*%XODiaUCXD5fU z?;n;M*beN16=G%bDavm;kOC#au4c z8RZpv1dwD{Sicv&ou9SPi)_Clb447;Hwe{z$dzr5=Rnzn3pbQn&|H<7n_Txe`VBkx zk&9Wjb8_=juyGyal4A!p8cKSMCFIqnbPNn=DOSiD-v8oKLH-Fl4o3TEk;4wxvBLnP z^JMAD8F)2o=TlC(?Zi3qk#?%b&N_@7(>x9fiNkx8b$>+P`nF9P2BzFckQvU$$j?l8 zT2jR7-=R7jFUr#}4rBt^`i{&La`aQ1>r#X5)Kuvc`hq^cXDjr1ru3)Kdy2k9tJvSU z=_2OUWIUoI%}ZM*p}R|-LcH;itO6R41LVrrLjg57dR^VnpY z=H97^cB&9ZJUVnem&9o0Y#l)XR<%N}d{Yc!-_CMG5g6+dFZ%lyoiKN!;*hP7h)c2W zSc)4GxURv>k}vpsc!oy^#IXlCy57gN86|Cc)@`JF`$9B3pE&p_GyVCwHb(?cA?mG6dQ?EA1 zQc>(A&blqtJDkegG26+^&fVaAn3wr*cxX=URZa(IMR-__GkRtC$ei5m&X_LFA@-_o z{(^msIf81zpvrK6ESD1WHNUUZh_A{#czF13-O<6yAZFsECQoAPq+>k`)Kj4eTw8gLoZHrYKj2Jf|w z$x9s(PTq)E13@p&&k0UsAMj*iQNSbz{s3<5bhj+%g2F@O_846G0&(qto!Hy^)b#ci z?w5G59s67tjgvLsM|sx=U!qq2Am!jGK$$c{iKOEhWye$R#a(pfM0R%R@J10A;G;AG)u4eZIbm-shkx3Uj`WhJ){}EstD3wjFZF~_@*K8@J@<{>}mFeeLP~|Y|8iT%3Cg+KhM+)s5Jws!f&K*hvgZIprs!5LtA8kq5odW>t*)fh z3p>GwTd#*ANCm;+VE7?DdDpkY=p*GateXFx_^LT`-c@7NBD{y3awnYnHcNep$!0Bt zAXlIU+sjf9u+PbL8`eQM2yr5k1bia_f=&WYH?WA_ycMeeAvBU}Q2Js4VtF}5sYkTQ zhTGZZZGr&%C5k%hBv^%oVX(Ru6*PDA3L44zyT0^%u-_Z5D8huuYu^+XIm89Eh08F( zWB>~Y2TEIFafQ0qV-CeGw<|Cetn*RA!fs(JZ}<^nr6#}@-aU}XldwYWylo(TkkTy` z)*)X`z?xBb4op=-?ydE`1y)BdV}_z2C-;;0;7pCcUL=nxb)CFxM zt-@@^#gfwiJRmXs5(zr&ki!F`e9nQvj!QNO^!?x&JIi#eGbwErd9Fuw@R{UzVTsQY z$td)qSlL@bdY&l`Fo@@@IkMapV%kUr%YCLDVlwzKe5E}`5(Vo=A$s9c+`0#sP}0yd z&F@euMda`R7LAm1tG*WAuaV2)7z>NQvRGgr;^~qHD;~sLq180WDt$mEcaVpVVzK2C zBPo=r!pP`UM60%mH}X;J(%6VAgfc)ILMWur`ji6YL9$(s14mXw=?WjRrTQFFGh#sk z3<2A1%du2Rlu${+NpN@B3rys`!-!=}b0uaDmO#LxNDWiR;6=Q|XMhc0oqhOb)9dyV z)J5JAe21?I6XJUQlUV%&LM=amW`}IsIRK-k_)Y_mb8wSSF5Vy+!5&~wvejOJC0u}! zu9Z0YHQ7NbTo*-?_i=ef;)~D~2>ms*$Zh$Xb7)@Xq2bXvvAi9hhL48#h>dC)5k^QW zDjT+}3%$j%gI$VE1P0f)lcQ6!S|KW1h+fR0e67kHEr+>)o=WgnGUPBVwByUvm|b5o zBae-+XJy_5C0dy(AL0@zu6H1KvX`Oaw;X>4Yom^9+IqD;><5;%#Z8#YLeA8@tmB}I zvXr@#!p@A_e*KM$07Gs7;Ua0luz)WP;tqA$nkG1aWvs8sQc$|xtEQ$~t|Xa*sf}NO z8+}*X$Dm~G!4sL_#&xr3-)9zkqT76MoOKL2j7~S z-wG*@^$BZ+1W(urjo|~_ZhZVSiER}k{mS0xYUS3h#dh*38g_ z_E}EPR5u9fLrCYCoZJlzmX5{m#&r3e>^E9Qh9SVuvMzGQX5uo$hakrd1JcP{tExKz zHjtApx}#ysC=TR*xz(_yBN!NKSql9vhb)mYm=ybc2|L#7R)gsv{4fVNfogbL4Ii8R zo7)V8O(NkJ=|=v^*?WaRY=3~Qqu@!5+m1>RRTf3%jM;f@__&lBS$bNlJm*wu85_dS`B)$3epli`m&>x_J) zg!OX$6CQA5EDnfs3f-#ditLE$av6+v9g560IaF?m>vH%kz!;&*MqXDhQu#2%+t?vc zxDgiTCeQ>M1cZhPoAH2A3WkJX10K#Xu^NBcL%qMod5i-TqcxBhNxgT@j*y0Zqv!&*FQ&cA?>I?2Ar87#kQ-VHT3)Z8ipgkMKZ zh2V~awnNH$?mHN1;Tdg96td_kHxdrvSm?^I%|gjf3Dm)}0n5c27R4 zS$NG7VznR(tuPBz7~RzKlT93+ScR97(W=pZ%z zI1`eb3Sp?pl|!1tMJX`THYAz=k+xuyzZ`_D2g?KOP?mD%Lb*xjIHcX*&mcClA6SG7 zq01x=`;m@q2UXe_hD>S=-=lVld;E(1ASnbsTQQlRK@!Fv15=WG%DLG&G%rwDt*kuStp-$ zvNsnzwPKB#p$o7h{lUVosGu4K$wObp>rb{qj(SKGv`CO=Orcas@@1sS$34le??Trn z+D;^SC-Q5D;{6*IE?`$HkcrX1Q3f$Vq6yd>lnG(1K3gt^zf@|IlPx> zI^c&ZR?ZCJqEnm+@f$I;VYqnT@)m~nvqa24gI|}|gLo+rsTqaxbOSh$TmgSfNQNlt zFBb794hV6C?5`eeh2$B@%yXXzr3NS8V(xu_^u={SDyT@J>l4$Li`ptgFW%EV`V+gW z7Pz%PFo^yXl1)vXVle2lZ}Kg?L$KeSU7Yc@bBJ>$gt;%z!*%ZpqYqaAeO7q=ok)sc zy@amHl7x?EfcK-Jk+ZzcF|f+zC%+2nbt@ph_X ziU$o)1TPl(fy4UDvd*eItB$I{=PaZW=#D=N=}fE!oG2Xsb6e%`vZZY5pJQ(3bnMlD9NpWpCX%28r)Olk@DBTXtT>WcK>x zHDSKUm&fVF#Tpnv$W0-(KyVD#oOR7vo*BU6miJk7AI&#gJgg1b#*wEjPR6dfM1rb| zWecDX3zKztS<>CfcGw)iCHbgV3`dCSAIY2hfFSds>m=!eJonZvFD!;M zmd~*#Lzrc&12*mC=fpztb{rtGuDP)oPWcaDdROegO`D6}s@NOvO+SA)UNxg1(S4B)}V5SP$$)<|M${vjApAd^obJ z@Z^9r8~XDhU4$uu0FzXFJ48oz=$GsX@`gREAnTA2vwf{lvbs<^TlNt4`ai#R#fw+G z_-DD6iD#2US&w9^6q!3BLAB*cp9g7uXiTuLJOG2#j|R#zUUOB=Ro)h`9cSlV&L65no3hn@dgB2hqWM^GKXE!rSjf=5t@ zSG&&F1rp*x0&kx2bO-r2sFOkzgCO3|h8=>!OL{dl;7vH8FWKeW1W+!B78|A|3zyWL zS7VEx1N6%C0s(ok27*G= z0Kq9lF@Gyd-s@tl8uEWXcSq;&IQ)QaDkqGs@I{W*)O;-tI+}J~i(8nTal;+-9-$~8 z%i~45s#>}chLS;G2)=mSVj(Y}P%h{o%`5Sc?gaE;=X2aqI#UjHB+(a2RsHPDgRy~S z&_e=j*CDZvY&t|T6$<7kql*}nJ0`pN$V!i1=NIl}hnb*2ybO!c%CgE2+~Bd;DK zR1Gzkp-&KjJ(X9tMJo`6z~vI&>CHJjyigBqVqfr`cfrANvJVE}Spz(_!q>}@pTX~Eptb7$9Luf5zXE?rzIoN}?oZMnG_!q(s3 z3I#}5@5m|Fi$27=T6pOYmgk1h&av6YKrh(S+61I`I5bZW2gmSP0v+=m3&@D2leqGj zcEcUgj3jr+&-)?BT(iH9rlutTwEZx@XSeY-t#9;SoUd5VDrz#E{bpb+MbPhsQv_9d1J z;A_4i5=SHECk^B72|v|?v3W$&FZx7*D_PT%mMe1N)@1ebwU-Ckfvn4?!5WuoC>3#f4H@Jdn92jiuUL+?4?h9U==;v8I0wg(c@oL zY(JQuYwkh?jMr-hWds>Qs0(-zokO|}ng=L;4Z49G#1~Kq{SeZ%5XDC#5I|29I6sBT zyvwPUdq+YKuRAi9gz71je0l96F^CEwHjYXR!6#CU5bv{*9XWu&UIY3Is+1qaUUN;& zH8>r?v@DWoQmCnT*mrbDNhtJ&1^KAFLbPO7yHuC%y!8WSV27>MUx`80THwj!u9l?8fwZx+Ws~L6(XQpq#&8=@6gyG)x(h zwk#EvV1zx45jsbmt}f!9~1$C ztSZa8QPvvZMhdGd-t87W4fP%byAWj;l)f28J4av5Q#?HJc`@8Zn}`zW0sBp8<7jU;&Ti#XExxI6^s0p+ZhBYSTQb zi^dqRXcFv(!_geAbo~a&V&i}?p%s%1E)T!81-xZCCZxOYwE&)vam^ za1hiVD-@&nD*FUhf}|MvhZV-pf}AQD=Sqq96~dITm{+{@rSUH)i>(BLZ6`yY!gwq= zXMtyeVBu`hv5*ZPEr}Bx=Aj?ULTpWl{3>sCa1Xl(r+%N38ilXFA_UttODq`RcXY4C zA*b((u>H^q+2O)SBSKUtfupB~@R_$fq+FQ+&j^Y z$RgBNNUzCDA-3e{xJ<=(QAcI@@G2tW0)_JXN#e0&Ekj+)l_~wny)JA7^}%&4!c#Hu z1fINuE+igH!#2SJn64ByEr2RxfQduKE0ItHS~*;-#FgHz;qf`nPhARfa+6EHYmU;a ztzg^E?Bf=6&D70NJ{-iSkIK78&-1MveYdC()!Yp*4f?79r#ubgkMq0Vp`pCF>BBOT z##-Abt56}%o%j?#_@$?~-b+4XNbhJ#<4zz7{om}v&U|nx1?|1cF^{~+=I|7%tk|kj zKJ&4e8xU->o6%^RhCLRGKYFw{YlV2nhqODf)@oTq-s>50(*2$!|4txLMu36Lso2f` zL-zE5vfsy!APO=FLq_`WTRylI_N-@UdlztjqU1eFu=joYuD4-|sNlz1*3+=LvA9Ap zw+O)!V17K^jR%XO@m25J@7r&m{YKpzuoy7Pu;!pI*A@Bu!!Q6aeF$O!B#Pk+o_CGC zYb?&r{vM6n{+lPaBf09j_eA40V{67raN(sWUL(m@2?FeSQbhbQp>{$oDkIr(jQ-B) z!(=d*TKI&#@1>B{+4qUCrxIubqP>hWF&!(XNY)P^?Dh$m(XAn;vWq=QeAKiXPrC6W zZwrU``0n|4Bk#cgm$u|lgfo~tf!=AsFT6CP_Yl{J(eQp@s0)UmzcnP7xF#gP?o($s z<>Sm6tV)?ti6mf4&JbGyh}8_+$@o!z_Z9FT)OhhYZ;|YgtPo&dQ3?~FI9qLKcNc!k zXSD$YoQ*VW45iSO1(QV6sNyzskra~&1{O#RD1?RKQ@NK0TchTB!$!nNVAiwXat}_9 zP~Rm@4po2TNw7D_HteXsvw=~up{23-`KK&91-VDGfeaYFX-yUp`xKdAMxgWxMCa57MPh2EhJRbQ@%KEz*jXfMCEgJAh?37I{c z9EQ=LEfqtEcUM>a?g!gW_RfSbSTv3U`+z^R??LF;Uq^w1SD-%F70~cN6J&6z z9UP{IL31HS1UidCPYg$pb1+KuZPW+_)QLu@H=pIQs{oUq2$B$olf)fRgkAgqpeGw} zXX%(kSQ}ZI0ne1`aW;Nbo=kiWvifhyU&E4is@S!N{!7{*)z&RK7xkdlLTxZD1OFqg`#-6jkNY90|E9kS;SpL^oN0AI){Ie zJSkk5p5=Om;@M&9mWXvfz>7JA{~C8fN0=gw zLR84cIP9!9sxh2O$M~x=E`McG7Ajv-fu9kZ1Qf9eN6UedN!&G22-dj+_l|*+QH<^_ z3hjG#Ky#Ibi4EpI#ihFZDXWN93&yJ;?pF{3vshlU0HgnNLB8A$(PE1sq}U{vs$P8~ znklvgBS9JoY`~r9iBTH%H3iMz-VxR`xj}Xn;}A@LSvT0wg`B~uS>er2&&^pIQa!)2 z2RO%SVg)y0B&mX=l9x&elLP}rBN&n@x7uRv00-l-_Qq`xJrbfng&5-1v+z?NgR(ZK zD#Fg8&m&FOO_m0J<={d@{c^uTVHSAftU&!F|a~!&c6j!CSuR^A+}T(>MxD~yF#9> z^r2Yx&al?vokIEn2_+zsLm+9Di)_k7LV|%EtV)qIqIF1#J2y=|_2RhxDd#>4nctv+ zXq0V=LRB8qnJUB0u^cd^VG_4OWH@8vTZjDWHlt77Q~$`XY{rvbPY=J1#u`PG3gKDM ztsY8)jRnFIAhToKoY>GqOv4Xd?5F&_G05uyC4GG>b}#gjB?!|oOhPzNQMo)+lK@5P zDh3C|e+%h#RGTT0g6!qJjgbU1vR;7UYM89E4d4aZz>m4!5?~d{RB4t18l|L|`?H@2 z<*x72Cnx!bcF9o6#hY8M_Yc2x#XtPgmH+Td>cLHT9V|Qn2k8&Kwd4BmMBJTBj?Qbh zB3y*4fzFtgfxI~RRMvUU6s!ltAy6zi+xb%Z<*0t51Skf@WP81Xr8?Q%$TD=K(c@Dw zgwVHi>65tQn1 zfc4=}U_c+TAHv-7o4PiMw%{+kxJxing5m&h4e)+Fyp4~dgjS;$>+DMoqO22OjCx>H znL}X!Akgz{gUJJIG z&KRG>r{3C=XzzQA@9$AAn!cda(l9B2!2nD0^1nid58jBzZ3(hMPPmJmz#u^$|70C_ z5hHm7Dy3m?OzDO^k|YAAiLI zP8g;CPjzPk7oQ>rakpZ59Y z+5X)v?ur%@>MkyIX;oB8UCW4yh=_;_iX!!wWCFkU`JVgxWhNmB(B(byX(2< zo_p@O=Po}jlTc~>*pQC-8VSnEj)&`n!$H=EkD}>NFN0P=s$z8M2ic{-^6D3ZF5Qc6 zg5x+RT3e$RAdc*w{dvW)r9qE-D#j2YTOZ*EKEmLQ$4lT;ulj4vfe4)7!Hw4|*#!-f zWrut@tRmfy`ejl~?1xrXoe;xRl9$ivRuK5%Xv6F<5f83;0TSwB20=tMO3~wgNbI_b zNtV^zn(%$qd=(7mEwHGM`7Y_Xred0qWyc7mFoEruB-RR%yFVb$1q0gND^T$a+EpAmtU0oq;)HJ3MPA_M6OUG$7|EIw4X{*}ag_c?WLcjm4oyhWbbV=Uu;KPdJO0yj|)#92HzpaboF`O~1w~+rIYK731Rc{xZJ0 zZne);q>{eOHxCYaBXd|o_ zLuPqTXtUK@P~KDEeIt(^bFQ!SxFn@XHM6+H8mau&lA1=kuAKejLu*4uv)xPIPV2yl zJUV;*zE*g+SS%Tdak8n!mqB2UPt^-GjK*5vwL>1 zOpT(*w%@43-+hnqVGN2i06tGiVn#8KC7HNR%zreEDh%+N2Xo@#Dj^+DCI;qJTf zgLHBB|7~QzX&+p{;7?Zz`Y#Fk+j4QxKXL8GL%41sx=ye9piS3l0P?rX1)Z(&y4V130WgMv4M_k-87@2_5yeKUBEr=@#+nteU{o}Ts`7`&BT%I23P-`rU7`S5^k zF>u--Ed{v!=_^_heMJMV-T(^376+SqMRTML04Lf2Fj@k#ow^#9==m``M7{&nFU)Rt z2puBNh1i6t^?oEbURFB6-`YDu(t%5)zoZy2&`nfyE)_k71bMC%3LQ~diP8TcM0wCB z7`?||vz@aCG2GmqO05jT|3o{<_MvZ93CZwbby_qCyPhf~o!>A$eCo`$Xk4rpS&CIZgd#UcVOA#=`V7Bq_ zF`KGAjm*-oNV4NKI0wDB75Y}VDJKMjfqyA0VE^sF0Q zQT!L$oYoz{;wz zr;KG$Z=Uucg#QJ$?1SoMC#-6(<4}e)x!bv3XSRF9mKZ$b4$U34KPXm!lPY1IE!HCw zu&D!C#FwtFR$iiD8!OpK0q-z+dWim#Bxt1+J_!(QU}wyXodgR>tyKDLY%3J!OXX&+ z@1VT40Z{}t7~mTO$$)rfWKcANL`TKPHP?n}^tqI_TzJS%TfdMh^C>&rJw)f9dEZ5e z`z|60xWJo?@g81J(Me`JwrM7+nRcXx?{crwR~OhkQ=2T%f&`yquO&^Ieg?v`oO-P((Kp9X#x%1J zY66Gs@Q`Ur)tm?CVEkyUa5yggcq08L+9L_AY~RFMTT1XyQ((7~&CPePU6w#oH_19# z3HGRP9!9>{o2J|{sO&9B!9 zy4H4=@uuOWJ6GYE%JbZ%cHfHerMGR`RTuKqN8Piwie=^NH{FhJ@Oka+6)Z2;2Ogx$ zdzRMDsQ?@J&e{pt-RO>l%5WkWnV+pBXPNfn;i+a{(RzX;xQW> zeGt3vi-U5sMKfe zIKnW+{D6f1TG%&~eLn@~TrVgToBA-jFav?W+9QdldUzt6ml=zuBwow5DQ2*QNuzD? z)b!~)pJtEDKEg7Z@D8w!WxAk=w5}@4L|V?(DFaSyL%tby)$uS}WpJg6xmvjwQ(O!S z<;z%?wowFr-cugd&_7l-OH--j3CR=C#8mtF7V#R!TwVT^wstVtH_uB&Z}X6R^scUA zvMy=}>!!wJvFi!?Jtc_iIcnnJ;VA@dTlpR*L_s-mQfphXJ&Qf=NibtrVz=M=E+|Sb9qLgl; z1fnrKWXj2JB`u<>vx3Jqeyq88gBzpjrq*t+!J?sO`|5QXD;j!UhbLo2L(e;EE~wve zR5yRFzu9ea_u5f*pU8Z@j$Ug9*>3e-v*eR1{dv8|^k~=8xbC%UNX1nB&l+IfVN?9X ziuACEjL)dvM#dNG32LrhJnG~NJ(Ho-!>G4^n1pJWyJp1jfD}h7qcLT;+R3)#YajNX zjL|$aX{g>;$?kO6IN^WpTX=_EckLl;Lc_aJ`u?%8cPc6s2r*UY!n@+=%$SH zNmfooO<~8a!5>mp?&Gk7)vu6U__rjxesn^|LtCz3$1!5g7G~-Dv6IC~6=z+Z_;wMV zrHowUG&*g^6cWKcozqYHz0i7@(@myZdKfJv_7&2|GL$tzel!A`df}G_OnGQWF<5x% z{|& zc81l;XAy{)^}+7l%yWIEB5l8}Ny7IPIptz)R*H@-rE2t}jTY4X&(p4X_h7Kt@{CZXlI;E)iXj67&mB zME`4WPs+=+6C@z=uTBXDav?nOA>Z#VypRt0URTwOY@FE*HP6(G3%w$Z*Pkt`8^Hje zX%!>r>e-Ibl$P<(OMl;HJ*_1@+)?dYs!CN*QE5u~u2NBj<3VZD{DCB*;kC_1TQt3n zlIL1YBl%1FK7`T`TtA+C((h)Ne&^wAT}41}qGkqmjejB1RfaItppp@)s)R>hE|0WA zw;pL)=f;Yq$FB>vFJ5kI7@spJVZIfmh8TGd5@E|kBgRoWm_W|8XNFK)bU?YH?+D~v z7O=n4c1@+Y`rX5}@;I8tkd!<+mAX{d-;BS|c7W>q<-xWO7=`iN#}h0ebshFnzihkW zGkBeaPS9!h>x5e-Fv-&}tNRf&tAF0wXQBGm25|5zqt>lnp)}vYv@MR;))AVqLwFwA zi|4GjexjJqDRs{EEygRdpHKUyhh2a4?I+xR!o4T`{*=V?LAHXoaQb=dKC|7Nr{+8b z7YU7c6TT)QNBY{Lnx^ioJMR7HQR7<&s7$?v*Rb-zPHrahzJ zuZhDO@3Au2F|iHV*D9)ys=g0XXfLG^_UipZzAco1x$`N#CTQK$Ta8d8CmF(G;;_jipY2WT((8~`&mYZ zbfYoUU7d4RqAq zqv#hz5SdW}shEc-gYu>8|H)*7hcBA!csU&$ln%;6H@33s-O$}5^7XH*8sZlObe-N} zX2Mj!|FGBmbKbCRui`mGl_^yMG|Xt;n{%Ny#`E4|ZQiT-tCvjgs{QQ3K|%X8n}&CPw4nz`@K7XEBuWK#2U zUL)oTgG(zZk2&weJ0~(eLuN*7FN={1VEc52`h1p2jWOyc{0~~H$p1XI{0}jnX6SiM zl{qw7b&QQ49{S}EY<*Pr5qLlg054IX7x5GpkuT@6u7Zd^{p8cxBO_vXMa=fdARHOV zW2f!%ie-Cnn3iFY3}(^BxJ*jfyOHT0c4X}ETlP^je{bq0(9SaLs8oFfOuFY>dc%dE z!R;zq$XE!JudW`5%vE2;sr`oM9ul4ycx_^HE^6lvXn~lMw6RpEOp0MihT1QN?~Noe zVQP|G{H|?sk)V%V7a`==kgFH+IoC%TFipryZ}iseoJ)tQ%~GPNy0oCTtxb*F+L(On zARSdlfc>6B?JkZCOg3*3t^EvUZDu_4P@&_`>E&FYEK4f1Pt=^Uq#`%xMVn{7>jy*s zJ`@d$a=pw1NI4w=R`HzoVBuK|qE|$`%UCFH{u{5rROYS9ycIm|=b`MEysLZB zT~buDeamYuk6_)H^14;!8)w@83*{S!ly5pLw@12uvvj@V#1+*)s;GG(NNjw8S2}&6 zr|!ki28r&q!)-@ao7yKUe!$}Oy)3S#Do*mQ>=e1OLjoTf)O98bk3I^IKD5H(`Nz8b zmRCO@NiRE^e?K`I9CKtd#dz3Gt;<~W4y2V{2`mV2CGlMc$C+iwdR3mwSh1EUQkK*A zrLn^l>!gB|K@Y7PAcE9N70QqaA}kv_3CWB{2?7OM;>dTEC|^uXv>$U_5B*+DPMX4# z-&PX76ClsUEh@3Pt|GkVYcAt6>NxersW+Z~^^}~#4u4;6Y%wc_Yciz7*z)g|-b3@i zzN11l?&Z5`6#oOWoFK0g<+VEo$fWraegu09TR(c%KrYG^rQ`Ivnf-WW0 zTNyt<#x^g}p14mdBK!y&T_-La< zPN0nhU4im#1G%f`uB*ER0*Zh2cE|@rSx{ZKPyjTzntqOvHAAO&<}9W7)hRo82*jOFWVK=9=?`@ zd>yBF0~1gF&9!CM&a;P&sry5E)5wAonB$>v~w z$tA&g*QJA9>Uyu?mE+aDuTB3VdtN#_KWM|t=*qLZr3Yqbj?3@2?cehhVH@MIhxPNn(6tpe)L%}PB4;DUA_{YL!t&S=lTzo?D zgyN4|4^5;?JCxp8dVkw)ZI_p?-~P$=liI)C;gXI=c08_QO{dDvhjw{>hb3L_@7A^3 zCEa^=f4=)WJDu9As@LPaf7g3i@2~qL`gHBnzt7%%j_Ld0&J+8U^y}IGkX?H3+7&v| ziD5;ScJH2k_w;$ZFgvygFrwUeH=i3#R^ctV^a!ivJ#DqTi{EK3ny&)5GT2a)eeEPa zf7Wz9wB?7!5*TL^Wmjj9pP&8woAmtP^Q(j7i_6m4?zS@1$Jln5hZ&Z|CsB;4*ubNe z^UfLVPQ>XUqV`-ZOS%8Fh`+K|o1Md|){+yKpytAlP6w83y2lWGHz*9Fn(}&uH*d!9 zV=?ZHa8npwB>c#5vPzSw$Q>uv?xjb`mwML%P38#Rk32O-+sJCliQ@V`pK~dv@IjN5 z*OQ>PEtwo_MDnBpGUcB31BvH9Q<+oAKI4NZLpegv$hA&*9B=go%|uzyI$blfd+|@Q zt<%M2j}BnId!pj@6&R+td+XKMa(Z|-%7djoiGoZ_(pMCDPxaSNltO9qT+0>O9*Ji! zB>IRL{`?zb3!{XGHbAUOlB4)Y-SS;yr;jq@nb)n=X$L{Cp7=#==##!aeT_*smYC?O z>^<~L@~>sIMHrHJh`~1lM#}}za(|L2t3EHNs5`Huj0smq4rG-D!_z@U^}y<1q=Sxi z1A|fNx`}#Y&p1f3I5-WO?4Wv$66lhoMMp`NGA82^Qh6?gj!qKLiAj#$Y{T@XB9j_w zkzh`u0+mVln35`E-Hw)6oIS0$jCxoiQT>}W{`SA znZ7AN!^C@gUT2@r*kAZfL&v0T271#;_ndSOBP}u2(HH`i&mbktxpYoz+u!e+ta{sz z@4Ce<-_^ac>P5CZ4ARa9f2d)1Rc`0h_~*fi19I74(Ljmk{b#-~W_W~7Q-PpF;@qdGiE69FIOV))_b4L@)6kH#N2 z9<=GzeC~7{cFZ*gU*p6 z{zer#l12)%DwKV8IoB0v?MYCq<(*iGYuQtpomYLy#*esvlIxy_X7}d|$0!s6y&2P9 zMR9oal8Ob&vj||;CS84Jaei}T;<9wMUr@!%RabDMB)pk*Z1%a$HvUjgw*LVAi4lY) z?W#$HKNOyG>2z-RCU^-Lf2HZW7rq-y?$L~w2)<6EgJ0-oOZ%FhzRW+vNtE#5?8b{(AIegVm7d7 z%dfD6WBi7eJ;jeUf|zS{vT7jf;0KmuyWmcYpZIgh9Lo=yM{p+BIa(K|Tncg15y~)S*;{Cc>?y5@o zAEbi350=cZQNvV^oXh5H6Ec|9T%u-P_P~wv^^yj3Rlg;I&3|<7b*kAS`Q=x=KD7-V-{{6tZ<9vy_+3%5jIO zrm7_TkC~aY6_Sxe=x9?zzAI3o!z-mp9S<35=4TIWT~@uz#>MHnTY|&b%zjJtt~HCZ zBh$RHx*SFP3nBHI^1!}l3zk2%C0Gx!JdvMXViVb*m>}_*wn$2wWcgguW-kqF%F2&1 zIoIbxk$aD45JxkB;nn5EWx>T9%c@9cM+c80$+lrDPdpJk2vl8pvg&zLK|J)^N3=+T zYP1Vw{U$w!yN-HNOgwo`lhHx$OMeK1;)Z+L~Ipm+d?C}pb&Qy{jQ zc=&Q^JTn`X7aDChflQvm_Re6Jq#!l1%1X0#q4O zhkFu7>pAn-GP0xH)d2)|3TbYi39HlX70ifDA`eHZ*=?VR3CmJ)&(3Q-5gp|%Auiqx zGZYnmrpKJiLN{bm(aGtwtZmf0tq}dd5K*F>YaNey8%D|+S-n+^HQ)dh^H*h%bD0@e z3twg*@WcfBh^#}Xfj0&XgFw1=cd(K-y9T(oYHU~gVp;GkHZ*8e%xmAoRiPd4rq7>g zy0M3d2=Ur?GHTlt8EBULHc3)gpYI@iaxUh-C`)R~)heX7LUXP!l2wnel0DE?vVpde z9h((Z0i|i30qjcwVj(QM9%}EUx9oPadn!EaMM-}0(_cwDkfj~y@-Dkys*M7uG1=3r zfPGaiVg|UbvdTkx|L5HJ@{`00?pwlCDkd{-)V6X{TbVMTyaHdYtYl{TES>C_^cZUZ zxD`J45d99!{p^6%2dvv3E|>43qz0_#Y+F0<7qMBp46JMo=o)!Ywawfhu@wMr!^I-G{+R{4*M}y@868ORLIm*a$*KmEGJ_9vP z_iPKu878GXq-R(AZhME-L5DYdJHJS5T`;C$Ja(BqFQsg1w9KBU**CZPl79u*NF@tw zvFR%%Rx|orkIeOn`rKYg;(TE1FdpiNgWct+TV7~oe>J`q0fmY*_v*SC5^Gfxjrl-Z^~uU)$`@Z!RR)GZI+ z!agbMVCWz47XsBoW{rwt4A@K6D;LPQY~z5&f6dal_z10fwwtyfwSZW*S^6QX$8QEnk_5Wv7S8qo42%6t*)!R^Y^gK?My=FZ1gT zc4rHMVmvxUiCJ2J+OkAet&*f+bb)@i7!)(=s)b2*l2; zNQ)FzC-oQhm@?2APp=?w0|_Pv#p$K&BPd_Lrl^>HFKy06Zubxssp1oO(;-_#$Vv?B zIx72|%VxH1g!c-P(D?d6PQy25uCSqhYd0UZup;=F!9qd&9+dJlZ9){skwS zDyyy#wqMV7C9~2{NlAze(f~`G=gKlQNgG0m?nU2mf**{(Pj6&y(OpE6iVqE|e4C}|A6#qvT zhv}lW3&=#g6imPE#9S*?tSu1BJmwdObFO!jME=e$L(tX)J-RmXQxv}}oO3BFufbX4 zn+BJ2fx?y-h|BD?$IO==N4%>rW(^CBJ3NFlRS24@2#Ox~PkITmEWI+Nd1=Jx8BJVY zlONNSXrj0lrZufSkAN{zxAi5+(e@5>*3(Q=_0Y6FI7+O-gyEq?8JNuyv3E#<#ck@W zW`THCemR$!vdn5QSv@6MIX7!+yV>s9`Ok6^c26>Oy9r2v*7P}XP2Tk?huHNhrmQx) zVnt^%z-P5U&h?fMTHoNR74_Oq6re#fnvUdL-;g|a?TY5+eK*bg>9FDpBhs%@!dFG% zWu*2ON#$H`D&Px6d5H?nO8CXgNJAus=_A4^@G>mMmsa!CMe2J2-+)GnoWE+!#Vtb<7y>bp*Y->((vEo!7U zEAj9o>Wy+8WCvehgr7z1V7>#B#3t6`iR{VMe<&_X+m2#pADHD^SQqPQ_4jiCG^km& zQpPeGlb2Pt!fQ%J-g?+dC9tIu6|pJ>LWl=c@eVlst-U#yaWLa~?$r=oRD(7;PNkjc z9^%2yuCy)SdWZy4rzbKOBhYl8QaOJ;0tKcMQz&^28Tm7xU)pgi4-s4z+oNV($)m`9 z7UZ+m&T#=PZ_uQB4|=c6+NNlX0hc!#83G6-CMGW<+;8T=5|?{g@ThhAu0O-)avg$W}2CEDEAGq#;>))R01 zj-vhAZz8GYrDI5f(c`ESQ|{?^PRCS$0QZ{#{33iQ4f$N)vIXhE*?lQqJmQBS>FVTaCI=FnnWK(52>) z-V9}y{0jvk9?6J{i+?fY7hXB?>+|sOWr?!4PT;DAw^$d?{yunf#F*gn?2_WL>VoXK zK`D1Nlzo;htDc?Rr6l`t-5kv9?Smh!4-UFEeOY#Tn)g%4?mjcxJ$cCfkbnbOX8a5*gC?MU2SO>e7N^a*bq-^A`(zc3!-xfyJ?x;W z*NSH72(vxBHBv~#$G$`5T%ANkxaH2-cM{u{ge*0UjC|690a@B^X`oMd7&ZRB zDyl@`htiIFK*w5o8x5GDtkt}e(FPz7krwj(PJqFVhV#&Rwgi)`PB(ZsGcv<5dPP(J zoJ-0V*6PX`#@!x99Bngou>-OJm6;-JboheCG((ez(d=SzlDfkO$0W*#s8;{y>I`g) zHhp1-q|R_ba}Q&^L8Vh@zTP0psGg01k!(#Sm7bD^>OWI=5UQdosQSXa-efcf@mPO= zjH0Ztj`at0Axq(5q(?-k2HT6L#~4qK;UPO7InxWC{=5Wfa)j=t&?xj!`c{|>UXC~y zF#<1o*d9?FZXn1b^sO4n5HeRsb!Q7jz>&s7Hc(kkvRCa56JQzaq{^8HXK4byj-4t7 zImWS?sFMvrEbLw~>xc`^k;GnS6}5$VJI?1L4aXXWTI32DDCM z>#)|Vq-Pld_KV~Twod40q6Vk3Vto9ISf6fVstOH!oM}#88SwTyN%uULuHCg!Alm4S zE25q?ChHVJ#=#V_K7kP#*bFe)737E^Yg^?7!To*0dKszu=rZ&(rrsTUmlp=&KV}p9y21TIR z6#gbezA&IPITwOzDv=mYZSSMXEzJ<)6XI$tLlAk`HCg$~crMUd(VSuwS62Nuqh)~+ z>jh;k#`LSXWO0^d1Vrih@ej^TlsP^HeB1Kv>~?b6oLJyK<6DGJVHBzuK3=IC<$mf4 zM~oaj&Xxb$X+J%mG>QqwxKBdB^3GO0u6}bPf-&XEH_hXDsZDMzH)6Yjq)gP<;3v0 z6oU88ZU|4mbT?7@9CwG?%RSHEf$l~A29r~Pl@X^Y#OHJ?MnVKOlKNuWWd`A`h*u$cTW+Opq&ufUdv&jGFWEZU?@tcwS7b#gwts^1YI$N+_{icq#c_>4vII zYFUJ9vB!pXn-)F+Ey`)}0d5~Caj+Zee&{B;Gu&Bjrn?lrxziQyGe;ENvvjY&5OxGT z>D6#-xDYtmjo{tr8^H8?^MNY=ho5joIG5r_{2!vwWYOStSGYEOinjXReCQ9~lh6My zRl=XE+VsdKKjG{AJ=|o_KixAN6W-6?C^DY8dFJ7z$OaeQ8$N+6vw8UcmQVPT|E-d? zRL=JZlbg1L>%StLF=(I7^JQGI4`Yi9{}fIlj>fTn$p>y-Nhzz8c~~8;3V&n8^kf9u z%NH1a4VF${eTcCkWaOhyO$u-DeoevwjDJ1Iw>P@LfN*hmLwKbxrODXJS+xZNg(F${ z4Qp}sHceK3s~E?lk0hO8^#Z?8rbX(s1%uv5q)vJ1SGl%vjp968{xy86!LJ^NX5(*h z>{cFIkq-rg%T()dz17iZ(y&xoVQsk3`x!k}`C8F3J)r*{a2J##>K? z3&K-LeJ}OC+M<<&&;xB*y?Kp%sN332i;yKF_&dPjv;fF=k)?Vz92K4#E`UxhygGcU zg(MAvDCg1ea=g^WEq+?y*yXgWpUmqSi&L-7Gfll@#m;{*JwKlDc2 z7w6*RI^OtXVFSP1gL)PcNh6JYeFz*5U5w7SOb74*O~LS2-Zxww&TA?>-!INH-!DoH z{!RIA?ic&S9HNnmKC%kUWFP9%7QK9BIFis6rjcAo=zKyX7j4vmawFWyJQLE?CtSn~ zR=60C_%9I?#WEI=9+`jyWq7ix2<++zb*H1lHqot$9hdRhs1`ZNbSX zw-g?d1$4jguef)k_IHH0Y;HS258$J@&L;Gi+y|~+4`07P4_&~YXv@bm%k|-ua8C4m za5zr(spdb%Cu@-z%AwSi#yQ0)qSmvqKyK?}Pk}Kb5gO_iKJ|R!N1Ia8*gPsq*02?j zPDrSe>Bqh{jP&s*t`F&jLtNXHgwMifj$_Pe#0g58uT@Uv+33xh!$?UvWPLQkZI%SkgU^8F0?Wp(zLMVZ#u zPGMf3mJC!5w5Qn@S|~m*DA$Xr?Y(gsnx*DN+A62)PW}^qWw}Z#uupxxVvlY3ZJ1UZ zOAT{Uek*c{rM2hgmJ-zS)>hO#OdBR%Hq@WMo8rtROBi(@hzDs_(Fm2RTzd@TJ1{zNE=>})bk~`*^IG}~!k>2)z zK8AF%{H(XeDI$)wFbn@I4r%^(>&Km#)4b&_%CS?^IeT7&QybtH#m&i+WvYYs_4+CG z^3u3IPSg2EY4ZEQ;&`Utu#|{WFSL|T<1kOh#=cEGH|}#y#c9H?ug$n38+pTU=&_sm z^l*Lc_e@|*w(^F_u^_rr-2|I;G!D`8*#Z%tz#TF5#5u*RSu^{0nsC4jX2lOyfo+32l+k)yX;b%dr3E1CHvysfV4HG-a^BnI`d4BiyUrFq*5j5plzMRz zjUI5Wjet$Zh-0?=yRB(WPw50@+cqBQ!UtO}sX=I4Rtwa=YM~sT&RJnCP$sWKRb5A<;?<`k}+}Kkyc>qQ{FEuHT_<5*lyt* zmqAyt#&1AD;kluFoODW zOeffEK507KyT&C;!!>ZsyG`m25vsB71P<7(|K1PSH=#YWM3Y#Gt3;!~dRE+Jf%2$_PYX|g0*#iP3mtELunRy5*7UvU0G`h*yF56Sb6!juI94L%cZ#}E%}3YQy6ba zXY*OkZ7FAkZK?lnDSgY~HutU$XEq<}A1UALEcG){z)(FT5v&EL#JmuE-NF!-D)-+FH8wv6)BaI3`v&T;0KU*hB;@J8% z$@@xEEs7*;eDqjBy{JvZc& zT#Uw+DUX?AZ!U>gQxxQj;W-G{Lx=pSeW(|49*EF_4$8tEAwc%lw^ljD5b9pp$ zAuG%pE#LYU(n*UCI4_W@kDGGGMeYHtOb>8n^&$C=T{%DFKjZjm>$Y>-b3)`0PEoXX zhq+{h!+v$Q>U9>m|C z{Oyl@WM8bm`@0`--sL3bga_rNFH{zdK4W=4zLB3EOCrA$qu?ll$Sck^k(+PkX-cIP8CSh(f); z{5o(7r4_q!i)f8v*Abr{_2(igcPEpih(eEDMp8LzyB9~(UPVG+de{#2q{)#P91*e;Gf4&xiU86r| z_{aXLr|c5?YeV`r*i*;2f$+!4aM~IC9m@WeDa4w}-(j2sm`NEw=WjTt ze{Q1H=eXZ<9_SJG1mRC{c5H^rxOd!a_nuqExwjAayTX0wR=QuoTYhz>h*X?E9$|LbS8tqs4<)!aude)6;ablN z1-BB3Z{cz7{#IwZxvgs4QG6$t+~|%fTH%HlY;>)hmG+=V;xywnXXx|82k?up_z`0l~?IO5+N{oX_TBi#6+qut=* zUERY)@NUV`Zcx#Vu4k*)!c9fTxk=RHPvG2x-=W}eaglRfi^dlmRkVYt?Gb;~7S`7M!h4fT7Ep%xV|+nhzU8fEI}@kzZy}>~!5{f1{nvfSuU2Kw zfEKhx{DnWqm7HyKp8=m=sxLI2!B@YR7M}0Mw!Yr|QE3ZzgBN#mQQUQRQ`3CshJ9Z- zhu_cPch<7N(i&DMSn&H4e$QCec-QBi4PX8te!t6@_rqnMySw_I2QU9QW4^yd-HE$C z=Pq5zM(d7#uxj1sM%>?J zY*7|t;>uOaH*G*Y-dFKeoQ=zl--@T(|t;4WdEBt?$ry z^42e3w=(DLm+;Pa{(-T=s~h?@J?Js+n!^Oc-=ja5H$1R%!#kh<_$4O9OL)F#%gV0q ztH!(6Fu|0L=k<(WX~dtFnVGnH#mc@1`qr&yVxXqJnVHuve{emUj=Sg(n20+~Ob8w3 zPfcA*_u=?i&)v_^`L(ZqL)-$0W zS^Q2G4y$*kG0oz(rFkrENmDbvo0~p^Zx+2Su37oOdS>3V`impG1L0GEg&93|F)tud&K9$K*%#PKwu)_IJJ|E=&zR^rKoFf9U-DCS4_k`4 zc`e)&K8NpK7GO`~?V0LK{4Y2GD*uB=_?_@!ygu+h*o7%yjeb2Jem{H~bM?RGzgvWR{i}`Uk4BJT;C@63O~SGSomzX9rxJ4gZ@hnVCygOmMheF_z&SL z;l2Ddx!^!0$_v9EqL+;R=)fJn4_D%;*Te1Mr4u5uejXhtg+lo`0iqqvNIjnZAWL+iuP3#L9Wz7HXdDTi(9dnVR$SX@5~nTpIb zHC)3#=51J*I@XCTs=o?xwqx{kj?Q8Bl=t#V$N+%1f{Q3$3SWlA$FNpEBAium_zk*M z86m-z_x^cQ-K;0MzfPana!_wgt6VZZ~@GSQLus0L$uKXe|n zB!G{P-*b~Mw0oXE^-{Pl+!;OzII-})@ada8GT{>5GZG#|70vNYkDFlZ+n8CRJJe3} zKu>OT@HziIeMb(^IurVy3Cg6b+wp7Tv|kyMNr6@5xk=|-V2A@4VGq9@p^~^m(LWo4X96np9?&|vqWoc9@k6b-$%@2n#4Swd zXD05ZvT}eEg@ZI}bf66Ga^nN*4DO#%ot#101T8V!H{h&vA6hF~#0UKoPd<&moxDaxPg!TV=xEFO>Lm^zf946|@pc53qQo^__K1KWC!SGJf3ee7_@LS3a z;TOaGXs;a>2nOwLXSh4E8mDLgRpAWvxpU!$sqcu^D!q#Sj+Vd`FsjK1Xxl9gWrZj= zp@kHDFr&cT%d}2V;`ExvGWiIfom`{-Heu#SE~3A2JR$iba{t6N9I3^-ZoZzzsT(~3 z3|fa%5$i-4+KjdkrQ3jb5vYlm8Hqmtf9V6c5#?>s`%!A-cyew+UGxX7 zEKnrkH2hDw0t^RSIGj=9GJtYns_*blr8fMl@X@KR$JRyKjIC3jM*pX(y}m9|6TuMT zR9GWdAeneD78B(1UxsUN^&KveY{AvzxI%Q1a~(h~z+hPoI=saWlr$y zd92T^q(5L)9uFT19|pW(ubuh`S1a{ceQi_U5v{#3e-FPs73M|ge6)qw647<^78r~1 zb9~@exlDsAkg(7vlIGsz)aFX~XLKDfB#q{Vhq~I7Ia1{#w2l&>!l8P@N5?M^9U{0! zad`aFb%hAcy6yr=RFJQfmZDd=E*1Sbp`>bF(7%);1}c%N>&~h?cU}GU6_M5ydGGq` zu{A1>fmVZBqDE65bakTr1haXQxrsC~30J6fi}J?E9h1)-k~inAF^_y&Y8Lb8O^V=h zmw-cL0riI8K-q=S9|}Krz3l{Y01nc1LXcmg2eA4h;Poq5p(pVn*y7-l$IA8as_1gA+kQy~Qz(CknTT$1_}yQXiW* z3!qM*P#svu%4oppBS&OBc%J$Y8SO7|y&p9P7>9u6CIv+rnB4k!i@YV$tkCW#=ocTe z(lfQu@zK#WMV=-aNG)JKxqaa#uaCJDUBwvfk;pqpOO3hdwu)qTQjYKkyj7A4_%S}N zNX4f(Pkfdz79)&O`P*nILah~SzS#rgxOE$255tp*o+z>@*^xMnR$7m=gX=1mMcztx z-40|q=b%;g9<`yaH`914Q^*aGYdB9_SuqzT6p6l)b>nsqBeRqJ0{w|u6XRu-L-NLK zQ_8CV4#@^s?}f3?g?Dr4(z<~(gF7JMs7EF+ ztDnjdoQYhx$r)aE{>1AFennPmHdGeaO zoo8^5lWSb6jeO!WfCqRqj%?gT#|r%2%vR2Ciutm!PS(b?j+n~vVmFmkJQeoW-$>g<39M(`kdYgC?~yU>#p(Q7ER zbKZKx_t3q(CovRc!t*K=B6SKb;VqorMPLxQ8ab0ZAL*%b1}r>^E)%)u)@P;l8*!E} zrwS8VF+=hq?c(aEeN${JM4$Ma(u#|&+uK@ck#^9+1nDqR|7p((lqWqE?bDQC?* z!dyl3BAt^C(D!w)%UyDEvwZxIKfIp+6HWf z;hr1Za$D80d?WIJqCZA{-TF+L+Kq7c)=x+7h|%{;%O1+|lY!Rr;^&3E6dSaqt!Q_C%@|d4#wJFqd<{sqcu~dGqt9eU{59DS)hLGN%h8 zp6#{|6TAg9zcfQpJc*WWqoJtmqxfcSnfEt7c3pQOz{RU^QN*Fk52Gg0XL@ogg- zE=CKOipE6RR;2>&pHk|=@m#(q9X2z30Q9XBrK9+JJ@lWBKp|}gF>A1t;S8%;q!&}4 z!{n900Y1nj;HmGrbuB?-@^Ncx`Q#`5tAF3#yhr;zc@=-U{!PUx6`FLTc2tT*Gxq_< zuRXA!rH~aZj7UxJ=eIDUy zi0p(uz;6A=9_alP(!~gM-uSNn%=@{WW!xge@l~Cf8^4dPa6H*F^*s?8ZYn&S`iajFy{oZmXS#>fMgFlfpXj)C5hZkXHGdL%*X9h%ZYx1*jE21 z%i)w5E7Bj`kqCxOZs11qG?f)C^id>k7Cwe|rn+Nt>r?%nh$qoICR?VIp3&XcQzG~j z$(lh{%&BYz(MCU2DcZA%IHQa-VuOy*;b_ymAKa4uX(jV$8xu>Q72Fz4wc|4+K>q$m z7{3x*n8{|UKbvtju|$@H9FIlFHn6fPWE?DkRG)%U6>68^ zFP%+eS;#K=49jD8vV7#OMBYNp%wTi5el`nstJ$iSR*ZT#K6CMz5BtbGSbyiU&$9)r z6*}Rgg@o87^9$18XrR%&h%mU%TYi1 z><;!>^ga5Sg;C5>Kk5r`QpP~zn2TRD8s+0h*^jr)0z7VYG-5QR$bDhzH^nb7c!xfR zxzn?M2HfV4H$kNUpiS9MW%!%A^(B}i_RQ=zH`N9YwT70Y!>VQ9e}Gs2?4_DV4>3lu^)khWbH@m^wD9rE!v7f)}LeFLq^aG z>@@Crk^P8mW|!E{*;m*v*xT%@>|K1m#{P+2VM<U460u4e)KVLJ zn3n|flUA|@nOQ$Ue`$sV_MzH_8RcobM#~;!&#)7WGNK}HqpM_bP@Yk0MXZvv@uvj5 zFZL>BQ%!z`ep5Xibgzhh&j8FBdUgPBe-3$8CG1sv768IO!aCRBK6*#w8G2#`f0nLH zY)$E{h`CS4H4D9j-x=wZ2pDPDcJ>h4f_e?wwc}2VMRQ4b9PMg#9xZ%D?0|9>Ut`^3 z13HJ56`d%yH~Aw=Zk;#z`B2VcrDxY9M&#dt>`4{ zzs!!kxMSr8>T6&ByDPrB^VwI=UHXS#y#3bC|BAixBlfF*y7I51Y}@63WM6x1aPOo0 zp4k8R*PmfeANhNiUX!*?B*gOs--{oc)=m)}_vS9L17m_UiOJ zsj9KnAywT|ENN%cqYkIzi>+^GzIIP0?rR;({LMX?PKTs(wMsP)-NP^3a}V0n%}#7@ zE0*-L<$U20ygl!c_V!GPp&7$$c{;DE8m}_WwpsK1?qVr!cHVY+$*X8TPtp|J;mnh? zGwLMP*t%=wuH|`DSdi&(+>^PB|7ucx(F^01-dQp&4!k5`cHZCd;h1LUxg~?Ey)`dy znX_hjZ(eKOiY}!Ix-XI5j92IF%3HRpX1Q}$-YzG9ol{~>tx^^4#Q5m7Qq@ZOfoIJ8 zP30HU9gfVri@N{@Jah;8cDLF$^bfZs&35KpRA27QYyHgUG98j|PwOrW=nm&DXWp(m zb~%?*PiQ#wP5rUZG}tf=RvG{mY?B6njyh|c%X>RxM?#M#&&D8j?If_(t#Ix#NO_H| zi!#sPO3LhERwYzbR|}sxWnuR-{!KUEjh^T2YHD?Mq4&<}Oq>K~H6UNr)EWeBSaN@L zP{SQONVC^A_?)jFyU8uYWSEeZ-$@3A_p1rDntAm)Xp3(tglba!9zUCdTWzC~)h(NCqr*vNtI;)L zNo2WNmt=8|B=M4*EVyJMC~};}52>)kH*dbDG%Ynn@64Z(>@jGZniI92lG@r5Pi?_5 zt^GlJcUkSSMZVfvUGbS~S2bLy71?3UCh?4J5cEF*@_?juOL3kNl#O_ozEqG*Zb^4h z7EPm)Xps}}N~391P83{tq4JUf{;IT%f=ik|?R4eNKUA<(SN!S9hyLFSRFI}whQ(=yWY@5Uf9vTRZyS!w zFrtuV8IDg&Kq1vKoS16DZ5IA*<=-jvP50ROJ$NdeKZQ0kBkg2G+R3JShI1l!=2E?; z$|7nA1Qsj7iulHyNrzN5jV8qFNp|ATqjBKRlj`Ju&SZzine1~UJ8FXkCth@~a|@P| zbtT`SAD(q3p^rR$^S&e`6@-2(Jil3RKO}4k4bk62p`UFIbqLSXAL_A2dc(Pze%&=z z%yzRNR5_RA^2l1_XmBQ?HU^F=u)c~r`=$g==iYpGn6I4UKV zN=1C4~DNvdDGg)nzn%zL*Gw(?LvG@ z*-BrEW?*U6^wc+>lO}bXB6q4)pHkBr=cY52k9I2V3p<mQ8X3Qp-GqMMldEf29hZ3eh)5@Xo6cIrTJ)2B1%c;PaRTDgluS z&A_EOqAw)}x6H@Qs7OvpvsY-m^9YjFt~-`{irmFb57cbm?QL1-s}6RSFV8txwCS#z zFE5x=`;DfCm$tM&y|(PnifTI-)O>kS*Y|5{I}bXui<>J8$^!LmLVIBSeRnyV57q@9 zXershrF#AH=9xWb8=GI%N<0?RWdF}`UnjtI!+XFoOml_Tcy{$jiaGw#9ZBF$kxeBkj#7@ z4CYZeNqNd*^V#)cYKqNnm?0kB+rN6t=FMAH_wN<`*iYl!>m}zxRqwwas(Q8LHQ`6R zy@t>;!nxS?G#BOgXnXT*-g#o7FU@APh=ztO4?ei1r~mQC`$Ol?31@!&zHsJT$?Ks- zq2Go+3i*G^+uNdP7k{Y>LL>4pVovcMVxtD4H%V?ub8$qgka7#qLaPBA=Dz(rU*n32G!C}`r)3+mHFNi3D)fT!BhU|c+1izA*D zA4kP_7Uzm^P_M%ZsUg*2by{nL2M2}Cq1}Vxihkko(1!ldMqw|y0J_^5IxIYm-Bqm6 z)q^^$895;Viv(f=fN4$ytudovrZJ*nNCiwz&|$o0j2DRPC3ygZQIQP{oh|q4lS*pq zJk|A&Z@*gD-z=6b^i}q@51u27j=-9Qv!Y9^#%j%{@zP-FPXIp23@lWW&f>H#S*M_b z*OA&RToTR>4A8TKka4!6p9$2@7zrn~%mEaYc2q#b$}WQusU;$>@P4I)PKiO?3H0ku z;3dQqw4ninH5OUGREwaI=o4}8a`$f*5YyI5Y>p((k(fo+=sqU#MbLdL4ns}T5cdSj z6lfg?9w9|^ZX0_$bV~mpAH&olvbOMhnnRFR5@4&URz@Nxfkb7vWlbDFk)zOUP$$ss z6uez;8D1hqx111 zkj6-oqz7o}^b!pOZay!#K4=@9KOmTQYhGDvnd|su!*0C04(~n)7-hpY6ojz>ZD>ZN zX;BE-A<^1KgLXZ=)o#U003jxuNn%Z~fgn$_4l}(S;vRvh0xTS|E2u9@SP<>>`Dr`m$)84lFMiXo!YTS`+*yunaNBg+BBgcOpa zp%D@<@wSv5+`Q%MuC7B5_Uvgb*xJ)HRCaIu$;~Z~_xN}8maV$ewPf>iZ4Cpx&4<>P zcMPoeFRz(CP*C0G>Fld3>#c1sZr;|oDcGh+jL|7d2Eu6~ z^Q26Wr^$bCQ`zc<;%E8;on>tV0}fy!C%im`=g@ItX@3u zWFw7PKol_39LC(5u}f4017(4R2^NhBHKvY6P!23J7UhU*6Us^P7@`likFnLKaiIH*1Io|T97zrgqkt1on1MD#iUKJjR?sMreMVa59AI@%9zsD8%vnN?+*_W17~SlZCDq@;OWS@p7q*0paD z8Y7`_E@S$y$PgGl;-0p6!$k*)cIM7$# z@XhxRo(sKyqOoC7uiNh*eDh#K+xedi>~HKi`)W~rFYrQCWE9O$!Q1WN4Y865sNzVH zYLQ7N+#mu8gN$V3CpiO5V4{@;RBznkGA^}DmR>Z|RI>}M4N;Nh@O~>e=VznR9O=!e z>dPTs*e*)OQaK$jPq#>BLPaj?0COSdkffopfhHtaQ%M)Rsl2C&>Ei_1PaEmzL?KZ7 zok({DRwI}o`v%Bk1!|v~$p6)7)`uEH9|$%<5?4GTv_5gD+qmjmPte$Egx#7E%}ZE) z26!`6>5&T~BtRP7avTR?GNeWUe`x926PoqH?*4vZq`#lwGwGehf*1dU)EYq&Y@rWA zjkw$N#J5%%yAM4PI{FB`vjI4))otcFc#ooikK}SdO6d?ri0I7Zc8J8J(cw&UmKpjq zz?x{0QvpU2+=$egvw*Nk#MehOru1BHV94hvoNDE?D4ld_rcWez$A~LhyK_bb%nqE@ z$7${eBZ(i2; zm31BM_xYM0tn_{Iy9oS6))RU~-=TAXel@d;Oj_)g%OKSyx#dFLs8nc??jXcSL%Go{ z=ipSr#Xdp0n^U&=kdEhD2yNvW6l*N8Nit(^T;ina^|L*(5Pt8)jMRJ~z3$!0g z)6d|?@SXL}WLFU1WMXydVW9D|5%?@M~EqtBrVUb0f|h~rR9{ARW9a8S+*E3 zt@KMtR;j`-H{M~jos4J68MEixaG6MhLt$0(rz`Y;81IZiND*9L;p({E&f2`@xceS|ZHHB7v=wEkm#kM!T-m>Af(Ay(Vj~;6F z6s_p#I4vXxyFS0LQmAnkR(EeB9EGV>+YJAR0jvHqili=SJTMx=2CO$S0j426iKB`g z*pNCZ87#7vR=ItYn|ZYcV4o-$<2YaivUVf_cpm63PK`s8>?nj$g5)o~@Z&SWk5Bf8 z(guzTPgWHyb{KOP7w9Ulo)r#+x<%(}8~<_h=65z=g`Ps6y3nU&c;O02GQsMD0uwjN zyCoOIjgDl)Ux%(`)8d`MQ7Q$}adxScVv*Bn(cl*snhh}H6{5lcF+@&?C+Q@MBoeb< z%C^e!2@sSEY?6U`Y;}wa!vYtE3lxKtOYlYtr-aA5k8NG@z?S`o?|9_JjiJ!LojCJi z@ZWxX;^~72U)NPmZ`j_t;`v;o{m|~NC%Z~^4h(JI+`nZ*U#CJpdSPQ~gpJ8T)Ey}% z;AruqK^<9^Oz{{tLFFB248)4-fLF~QvTbKXOwo_%D z-hr0R=laUa);!zU^+a`N7F z6e^mfOE@Jo3O*swbZGRM69?Y^_4~g*psR%b_`^fjPPFe9GHH8OfjrWfcg-7ke*!c( zMHk!+V+Lg_o?rF3GbyC4*p&R&nCY!eu{TS-v<8!si)jxLT}^m65)KNH~IID z9f{&VH*lZ;d!+^)lW|}qGs~d^US|?-Q1BY)KteZ>${fpRFe8W5p^QxEK!nyAkiUZ_ z9jSRBTdMe-3B-|dU{-_~8d|<&vO#cF1e}S&cLas34rtX8H2T7kU+&!fS7n`^uXT4H z-c(t(@yOcRfuhhA;Z*g-orC}Qt$S5W>Zw@kt&L!kt3c@3Gj<`+s$dhJOKm@&%Sr{N zuugHXN;+7@;`!>;5Yj6%Nhl?j5>~;YiaC`H6PQH!UJ5#>>ytpuM2Awpb&iv_u)sl~c9 z0_z3%xkwF%je?BeOg2JUgRLo6sYYX5bFSQ0ys36w?{Qb_V_nx?YH3_HctG=K*XmoI z-BhjgPYbk%J!Gy5592sIxFrPWY~ny5gNLGE$QsZq!YO>nBJLD4=(k>+dnVK_{L2qR zeg6tL+$VlJ#IBtcPlrAXQ8<-~&nNIM9ikVMbp%<11E1Sl$vOoaf`+dejSiTkm|Z!b zQvu?7UF9cD`^MYSWuUDL@(iNN1s|L;j|Ptbn+h$%42a)wZ^Z*2;~=z<^*e)P@03&^ zU%Yi#1dAZP8fTNuNkoB3dy=(gb#Owm2bM0(FDdF88l^KwNVT3iFKD~sIt20j^Jb%Q zZ78%nbgef|Z^fABhJGU?i+_vxztZ;ew$Phni^U%bE)>T=RTbRZj6NsBE~@l7LG5!K z`mE5fWLU_O75jYx`b+{>GWCi&C>gD=M&q1-Uh1aoFtjPm%6L~L;a~O{_2%a#5EP@) ztgF2CLTI%67ro-it7kP0LQ3}=YsSb3BEnDgp7uSg|72)?0TtUaB3!^p3j!h`V48wu z4_=moe#L_!kRX^tEG&ul$C-g*ykfyq6zUworm8Jq1&T&TdNfHUX0f;VnK%0T-*~pU z`B^$Y(=7XYo@i^^x7zPty|1n9i5|arTQmJINa9M#CHK{7H zos_UxrfU{mLWbfhn?VfYxxP~awY57=_I>oW^cTm!_qMKb<`=%+*ZW-eys`JiLxX$w zZ6sKRB%Kzp%tK^VEA$m&Q*-0FE!0L`@N!5x07)o^E)|mVadtUCHFa5-YQoQ;Ej0rr zCffj2Da|j|N6ir__qQ6Nr;bvznZT|6}#nh zOo7?LrvL^|0XAX6q$E%kOR1Ekn1g8wrU3*gWCKnFeIZw-34^zgpEKSsrCTRVjNX6+ z>5QG4!`!-v+Y}7t8FiT`o4{i%&2ZCdda`4&Q-PLyKDA*bYe?=`_?W`t*6M|oZ@fV zSXaNJ)7xIRzAUi1y(Hi%v@~T@HgBwJ+}*Qi4Ii%`>%)TiF(4*12;o{?9So)m4MGBp zFn3;*bs)bwZhY2}kx8#3o~|dmW>gC$zl&dUDYWLeR@%GwlSa4<2%bUEk1c3F1Gb|e zf>dFX0z64vm(1e?8RVUu4+w>jEh<4yB!5#XS#Q9 zxNbn|?j9bf6J45=YyZ0SaF@%~b$F{L{o4D_zdf{Q(Gb!Y1e#oJF8Yy($f;l=`XQM- zL99uTs{{`^qIl;7`=}%+oFM`83WLHxgIQ#8AHi=;oJ1KXEOXHJ)Q|K{IOod7rU zut6V>SOX*fj-zh`v&2W$J(v<K}WG1{98-kr_cV~hyTE@CRm0|2}!t`$iL0>EoCBRBh!w` z4E{ZhzJq8a23Q)ur1b~!XiQzAA1Y7~&p>&WreWFpS(QoDnUc~oHQGdT#x$-qYIdng1*1+>$|j8ygXLW`<7AyKP_r_ssaD5A*n&2cxufxz8+?% z)G*1z6&Bzl$tNU@Fd1>jHrc>KG9X9A^bSJRc&BoOouU0dIGO3riOZgq5gZ9UA?*CY ztC>EBG25H|ig;2S3LSN~mX)=-h1RjYu_mFnWOYUP>eA5Dh|ofispe4fORjPJmILwk8>`~Jsu3LJq~T@ zYju0;>-W|PU%YmH><^-mmY)EM$~0H;&N$fbIVX?}kn^;(2)S`7-c3q(;Y3Xu3y1xIST_{!LJP3u^hc%fTcw|B)SCsn!cZ0Nk`)*S;KD`&jP5on3ch+|^z z5gjjaV#Y3x7}6=EhP=wU#L>tv4SdXaHBlP}{4k!>v%>lhKMbAM|FZMrcRJDcZ1_x1 zai8huB0kd*K?x(na?-#WBr4V*)pPpt>%Xa@6&2$-6*9T!RFgS{=M+mkd#jkgRd<5i zrg&dv=)7>8kEe$5_?Ho_G9CkB5u)QUTm+dO(JMnjxQhVmlmMD-i44pTu@u@N{y}SU zRDSS*ur9R!lHSqzF_4kowN0!+EK)nSO_CWTf&Oh1d`}C${a;&QTs4U6Y1jTu=n*Us z+MnUiWQ0PR_riV9U+hfMz@M!hr9bZ5R>~)Z+NVWHcM|u{r29vN z1eQdB2Hg8RBJu%nbqCH3%s#`c1Mi(et#oQYcQW)Y$#Pr3n|Elgar@*B7DP&fE0{qn zB^OG6l0du#5{1T@kpz`gL!D7%(=3EdWmyn?irUoDQaKsy2C{UDnR~}bRL!FGmOvtO zzBOn}BJoN};lx39!de+*X;2OPvQY_wATyi^9Za4#y?{%ss%tVW)#_A@ZmRL!YSwHK z3WQ3VyP=}>sYOd_{(9@l!@HBTwcly)KXPP6UF}zDw;%5*7R19RobI+7S9Q_m;^ue$ zYV6>avb&$$-*U&2Y9Yl^T)}-oB0CCRYDT<98a#dXtFo+>#y<_q$Ux(t4z?}8DG6sE zBq=+_2{cawPS`En)NKM&gZodmlg6FmmvXJLfjcCp(UL>ObQ+mtuz>(8iX8cnxtWRB zE=-CB)yJTS>V~67wSjLncRsnovvkXIEj3RqHkcjNUnwo`sdr^nt*F_$PZtbr8Yye6 z9X!3M|NQRSs@neeXz^ST-H>Y%l&*qJaO9Sz7tRvgvNguZh- zQ(89q3Kn3p24QTXsnU_No#rD(iZVydo7_?$b~Nlb(b@Hwr@6SZEU>A`UEH{7N$uw1 z`oNL4uH)Nlg%7vCytAg(y|%DqLHC}PmPc3k%8L5RYH9{wA>8XkAI}5!DewxfQ6z|< zjbNW1>1r-u9}n11=de!&pG80*a@ZlxWk}4(Be>MgapEN4Zz&RUZ2y=ztWMgs4I|^R19|)7(-Dk2WIuU@i?Nr;wPNjBtx!N-`BwNaD2< zhcZ!o2b2%J6=EF_&+Aj7f)L5&%+D4SSAO{;d!BMPI}3dN-GBFYM>TEz@dpn`$>xEK zvj>N+9nrwE3u@RN@@hT=?0tyhyNCUZ1rapul4qfN$%Ox(*N+C%sE`3Cq6bb3-ROwp zu0S$o!$Gsr#(8xej01(G663yCI9%(XngG=dZYninVzOX~%W9@EWTlh6F{>G2(Fh5Y z@5RLhFn7Q=EY(@L!C7jyRmG<{rq6PD%9bokCN-wYDrI0E17(=rISZi1Q+R1mqg_TN z2PBn3obR#*6XK^+O@?jQoK@G%6&*W;k&-W0Jt->`JQgMsF3Qh<4>vU>txSmVsuO;5 zuewVB&7lZERsQGBq4tJAfoEx9Nn_cnq57g_*+ScEPPe~nu)d(dnK`|#`^<^&)$BOE zx&4{7i=KRTXYJ;u;#FP8vzIu$C0<8ay~|Z!=GfNSy6vUPO>U$8u0@p{nZ}}_dz*H& zmzWa{;qdpS_z4g}mKflx=k zKEAtM+x*G#XNE2M^J+iUIl6*5nqbc)0>}9!BD(+-6Nno4CAVklNFun-o@uZiJ)#$m zo(=_tZKpzSpVkGh6^N$L#gY5^NI@;E;ap-7U=f)w;mA$o{ zEADMs9l_LR$J~UlI1#?8YX{>>K@R2k+8G~}j0MC)td!Nm$1`d9q^N^1(IAvX1lez5 zw<8_aWB$<7+Ha1pBi_GA-;`CP@WVf#PZMmO5jO86iA1$|g2zW}o)At*ki3!2Gs5W< zRaiK6;7m6dDiiy~)5I;q+D^7d`743bQ;jRX|mibq>o_H-|R zz(Q;PXsBIlM!(Zwt6j;0J0?(z_(jFpX^d(#G$3yz?a7*#L9uXP`hsD1O6t6 zh@1O=yt}r-dm15qilrkdohP`lg_w=Gm}$jxfPGZ5CCh zkqj{*v=ip&>+PQQakHntxq7Ir>;9surXp`|^ZdE3{f!;pT-fO8TikI^LB(Az@4A)+ zbK8end(Up%;H|!QaiO2g;1i+T z;IXg-fZ{?OhTJb}(g7V2fG{PaVBuUXNs$(l6~C(@$8+O6;#jRE&!s#jm_ol_(vn%8 z;V4?}Zd@D4BDqnphen6SF14?)#P^%+r7d5q6ALkp1DLm<<_c(MORPo$hQpDF8*>!N zLp89*p;r@C5sFKVBdr!zHB}K2^r|8P89-rCq-%_8A2SXdYwc>-vh3(?v!UkMoc%~hxTl5Y!zR=mi=7Q{pSeA(91Q4(YHisb==;Ha9=Q6vM-t4JOd(X>V!Wn#?+Z{ z;zheV&1f&Szj!2c=$z)t*oC_NwYB@}L@$>$Fn>rI+Jm+o!t*ScNAqWQc#z$uLDFAR zDPmqhf#M`NM&zE%{nkLyj#WSZX?)~jkRI8KpuzO}%aJI;K zh6aJ%JI`r@SyW1xL%MZR7D8v?49?8@_;mU2{`~`P{+!AzeEQ|-|NeD; z^|pE0Vw?%NaJdE}8B()z zVoj+i3sq?%QsxwUY8LXWZ*ZUb*r&FwmK=Z5l5I9x9Ldife$M8!8ZFtDC%zT>kJt9v za+6V=^60BTg0rKvsSFUmMz+Ach%SLsU3Tta1N3v>58vBzZWD83}Z+w5yJ~tDHn6_04y&(j?zc1696_+_TW2a~oE?+KcF2cXd zq2y5fd&T#Ye`dJj*Ow%eovpp!`<#zNLZvi^alEF4Xn6d*kE3rcKz|jsyjQ zZzbSc0{F(*Z$US;#G!aD$8FeeE5*c6v3PXscVc#%h7Glik-|c4*QoZ}5@fvxlc1uH zm=t?wfqNuhDNzOu>AYY^GcQVUrE)H4t*unbwaCR_?8OwBAIvQ#!kjxD)Htz}GpXES z(6e-|Ua))=vt~j?6|B5UNv)66xz3L(Hr#E!lj~iO_pn6R?iC?4?fV@va;iN&x+N_M%$XMp5yne zXz6_5pl@YIhbPxpOuCc6UJNn81K*nl7#y*PYPdCdL^(xLgg}K~+!Df0B!5um128Lu zjo5?#PbZj4FiTS)<%!scC8t}?Cz!!)sl5oJ28UEuDNl*aEe&dC6121)%HA#IZp@ir z+B{0rNeYmm7_iD$2aA1S@+gtTz;_Ln^*YsGQ&I zi}zJmF8MLYwW2&zBBS=M6(743s{~@8HI2fl{5zp1=k@^m3!=s@D~S} zjZ9Ip27w4cJ(+iDbtneS7;_V)mel!nR+o1+H@J!$oE=nN-h3yO#euFC@8bE37BB6( zd;Vf?8G<5^e;(Q?JP&zE%|F**0gS{=nSXBO>NRJ%NW(B8|J-Uq{<&Fk9GEBOpOd3S z%|DM-bd%u#Otwe!&nf*}tJ#j^b6;6)?I%mmYV_C02`IAd;TJW}>-^XqsQ~*yP_PSf zzVjg#TS|k|@@b~0WniX}IDkA+5_6DwXH24cN>_;laOHwiVo6Z|moC4wbR=C#0yKcL zQV185^QQqtV9rZ|{ndn&5C}GEMj#ePG-tTb3g0DU8=f(Yz=Je9LbkS7_C3?m+qT2q zRMhUR?k%b-d$O^&{h4)TNBcK#+#zo3J-V&l^wt~NrNwIsinU`^T3^8$?^44XZ^qYe zJHBq{g=yl6%n`zYbyzNxS2i2F&hT7eKG507VE4$P4$)KTrKOa8lt2n3k%o&Ht(*!C zKdm&VPo+VV1ADkARS#1bMXKc#QDx3FvXA8u{XnMmY#7s!U4qPEqSY@C|K^)St0m1G zE&QW8Iote{oZqo!F%?tV!4M z66cz!vw;GRd=j4X3jpL9h#;^^MSj@^Nk6EkSRcY*ug??7j!095d|Gtyw~$c{$x$dF zOyTl$`ze}7@u(!R~PHlZf;rdGeWLae&>de6o*D3^iorG24_7~s?aN)aw=!;DxBr#R***{|5&M6=p*g4B42jN*Xh2GBC`1lOiCZ>N z4#<2WF>*d~K=OHbw47=gm3_dXe2|o3!4T&lHDI=NI6f&ekBX_bVT&zYNxCk~!95vF zxlm%o?KVmdK`7v`F+RzPBF4>gi|6|aeReWUQR=BOUL>HBiNDYgwG%3FMBW|F8HM>f z+8*s1DBI%Reec(tMMci9-MibprEEv%quUCr+e%7Ws|yROTT4pXstX18pnFSg?ZfUz z+8?hkEO@;A5%<<*wOibS?RyFf8}9_&e)<(C#%B^RNR ztnq>nl~})TODLSPs64Qgu&2rv%t*>57LWzbj{pM1OwB>;ygn&^maClKl3?Y@y&$;J z%=Pg#rR;bRwN$I-NbHV4795^+#B-3^yBc!y3mYG3=sH;T*^<6MOMP?y6aCH0@666C zY28}eH(b^2+0xY1-u$H}w>Ll8^ra^%%c`2T?W-)S7GG-ns>|`&`QFYtSB-mZk+vS& z@u*kx#YR#%XCV_B`6PXi;e0%TkP<7f1qn|V%kfbzM25~;Jhwv0g_yU{YO9KiPsy1% zmz9)PD5QaV{vt-G$o>Km8L6*YS|4h^oa?kcYEf2F-)XSZK``niv#jt=SS zHx6D4E?*wJc5vq}zWMpjfAjJqgO|V2-29EpgROhJ=Lbp}i(CgD-g(ed*isVkt$tcr z7xkh=?9!ftzLL+r!az7+GJ%c7J)%*-r$>e@32%r#f&FtD;4@7z(`k~yi>>%=wT##l zAYv^|2N21RE5}ioT&^{!Pvo9B8*yuxW8j68li}@^^2osmyEK*r5*?xT#}h5#D}ZDn zo@P7Qm+kYKh5DD@6uq_HjWg$Z9&6mTq1zl+)Lq%$x4qok*n03w;v4HWq*mYUn%3g* zJ-#E4dET^rW5b)G*Be@;4G8ov86vBc^oPZUAZ4JUHLP%~%HHIPjnx zuVB|C8;~tUr*^@9$8UdoN3n7e&h-oB<)Jg7A1o9q#*0Lc`5pt}6~q$Hjbu$h4I|FZ zketoqiNU3i0T$G1s8&lpJtdwPxl|yY$fVkdZ3D(Wq8$VNGdcWP<`(y8Jh~CSHoWje z{sjxpBUy1ipWfiS`P6}VGeP4^5bx-YteSH7$Sef`VpdEA;lRWdGvZaIetxOswa9*i zpcE?=#S>OhCD6j}g()s6!|BlxSuD27b4!5-IX0;Th%tX2E@C~gxpcAIDiw2dxVgz1 zrXh0J=?!*xrBfmMa~;54(X+d;anI^qdVQn&xo=c= z6gfLOmNk1@3*3IKqiz41va&V%+uEM&U9_n8$+o(!EpB(q*1Fneog)?8HE z?(wwO7Ddk8ErCD_{b@Iqch(dX)O41YEe}ke9_U5-*F%HwFWWB$(tPGZSJ8fc+q9zY1jwYy& z2J|AjgF%yfsA=oI;>NXALetpg_QyL)nwEK5oO(yi-8=gIYu0XBys^G{aQ*V)hPD9A zgaWJ8tQGg5f3qmIK7n-Pcx7$PgmV%-CBqESSQmFrIsqh}BASidP^9?5v<~4F1jI>d z4rGNqzl7f@so9Do9uWv;V4DJUj$?Nz7?&JsTMN1?t2=gV?s=fGy12>N=>2lDr@f}A zp(4;z+|c9idPZvu_^Rrb*3>qCw!&ZID_UAso3)_PQ`?zkS=s1ot#Y&y&k%$o+NU*U z9!Hf&^zFuRRABAcxJo#3N_tQGw2(&;R0NNsp>EAfu;JLrLqu_~iYtZUA;?X{MN>sw zP@;#(y2;JP(43+XETkOTNdiknT&tBEk7)`Srdp|leTB3Yu$8Gy$c1*ev)j5FHrIZ4 zw^>{ESWDS``SGD{-S!wea$|e`{J#t#W)f>P+^#u^xIq&#Ua;aRZk`7ZBiBWV=OZ41 zG(R3c4F3paz7RvJ5X%sqh+ts>ooKEb99h#A=zD3XK|Fa}vwQ22p5kkdd)6Jjwp4fJ zD)bxdxgy$xt(p{;Krw1@V8dLeAKzL|$^210=W4cAtfD@iwk+<8C1IKMdPYnSvJzk5PBi;j9mGAyR{AF(Nh@%ich& zkCI^E79~G0!A#ky#Ayu%+KPI2=&rSx3UjahQrrUE5m`CpmE(|Ch-OxC%ZnW=C|k{x z<Q*+h`ZKKY~t{m$q!Es@&L8Qqr=q66ex^9pO!r2Qe=D4y_B>lsOe zXDAUnwV|Z2mgLf4Iw!d47C>Hzx8$9PA_OwkubUkb*WsaomPp&}@huBWgV7!Lf@xf5 zOaoEnDk9AzA}&Cw(yT9#y*{0`xllsOD1j6fVm-1EVy2mP>?u$;AHl|Mg&wcZY1qN% zzDn7p?2OR3f6JDK{`sGc##P#9BIxkRU9>UT2LVwZO6hp>&5p<4IRfqknCR0~m=t0c zUcMth5dZTGqU5lPTLdna=>!+qf{4*cFo0WnN*E9pB?E7h`8G1iFwq1ZJR-|Md4)wP z3M)f*@S!JKl)WX=GlAM!6xaBIAu(C+C{)yi2%2O3aaeC$l$p$G6fmT6Fo=0m^PR~M z5nq^^4{7-xciDi=EW|$hVM+ff3oamhae0F2gfFu_vK25Q4ytmf5%)q@0`SM<8F4H4 z<1Gy?bkoXRSQ_UV(axmH#KZ)t>N@TvIW6)6piWuoh)+QsIL3Sn=xLu9@LS-c;sQ6| zw;1rtCK52mFE4OH7MxvNvXJyDCNFfOdaj>yLAY|AT;2Ll@Il0~UypCMW{$#A1sR`= zK}PZLtr+Im&g+qs)3L5lANP0DUW3@@AeUfXpzL#iZK-%FmJ%S51eZ7RkSNW^Xu*~T z26f8=j&fO)6Ncy+o{t0*=!o8iJp@UF&ZnqACKu4Y63mdA#&HfRsJ76PX2vFpQSEa= zL{nEuZDaLx*UG>{?)$5cu0Qm{-$%BWs0*yWzq5ODZQ}s;IOy~?Z7N^T zTHAF(+o>9Zu?E2mw9We6h=Wf+%%i%ORWw81#Ivnm!Hr_>r{P=$>P6d zALDFr@=_mfoX4LGPC9iizjNfwd)MXjizk2es|kHff_{D-9F?p!4o4-YbP^oN{~v*4 zf`#mt=%WR2OpS#j_RoX%WSA0`o#zgp+cjhys<+zvtrQ!NzEgPLOUX!`O%UuDECtWQC?pGcxKa>1fD}JiiDy5~f!I2nVp_eX4TcSHw&Y1}HO?nt4BpA7dcTzIWS9loYmt%1D4>4JfFou}P$ zim-?HBsh$(I@&(Rq3B43*hoi}S0|tc1niL<1wG&-f!#@U5@Cm(@x3+(C^iyE^w?rc zo(}u|h+t04a z)7#v%c5PF0Z@a$;!O4Y1{_7UHroE}FtEsuOv)Qv?fya%GkPK--9-W)#MWy2Zm*?@b z67#?>ry3Zr$pU=NNLw}0ejaAzo9K+Z$hUa_y)s>-y?G-$J(!5gM)!C?8M{947;+jT zDcIwG8Uf}fy}=@$)zppsNVps-5F5typ|+n07NOI)ZvFTU5J>yl3Q7Fv<;&=AZ>WBJ zj}AnJLXUBc6e+TQWTOtmjgt$Vs~zwIgXpV!G*BGe90=)@zFL4Ng72GjY1#QRi#)V~ z=UC-gF!RE02-A6RrfUxHST+@bHDHU6wAoLCR)PDC zPZD?@$EQ=U-9-9x%Uwclvp9FE8S-6nn z0ltI>ftR$OS0OSKftTEn!98oRNK>9W*;x#*Hz5U%_X@$aP7O>Kz$^DLAk#?=TQ#>oJ;vNQ+|l|3iCDRc@Sx_Imc(}Dt5rQdUpILt(O4gXbF74j2^H8N_$6$=0q*DvuU0YQPIxQ zl>sF36e8Nh0GsS5adkh56xKu@Xpu;!R;Uz^r%3ds!64fg22k06ND9)-eO8CMACbeF z9NCeGs7~P9(QS!BM~^8R6A7iEzY+Jf#))@EHYgJJja`lGQ533JPGiCP(r(u8fdu4Y zPpN!Ay2A*9!MPBaTRNO&p9!ctArYovFbJjquTVE0LBjZmOJVsg?t~i2Q{}_xrXs0l2LV zPc%KC_5YLO!8XT+TaQO500FwK0f{?^drcmb`v!9~mm}nBiA1_M=FWC^|&5V z3E*uHZ8zm#M&^swXCW*@Uz!5{q(yGog=Og}9iHo2gf;5JkC|AbF#KSRszZhseWX%Z zm6aBXeH@vm{F)ctn1_r#o=Qk@@CLeczCQ{eQ|fTH2G5EcEY;fs*Pe+w9@$`(bvwY} z<^kkDr>|SNh#8T}@xMrb!}z9vmplh72LCU@Jb+~(iVG(~YTg`L+!;>J?LPJ5j$uc! zg2b&MwKc-5qT4%DLSlf*5w6AG_dPM4L)=9i}`azMl<5tnM$9h=-*Ew1o>uNBqFR)=XC({T7di# z?EOTse6f2KQ525bBWHt<6oHT+2t7K@^+0+QrNPfeadvb&qyN9VZvl_$xYDe9 z`=KX7Zy|)BK`(?rEp;P=009z079a~*$TG+>1{uV|AYlm^V})Si*p6d6iJy5mVI78e z7$(DXOA7Jl&3H-1vdgw)UtiS6<4s1!|Nv6}=EM4W7N&v_^J7agU)^A#o+;Qv4vWTQp!r6hzzkUyXxk zd!uyc<9!ED@2ed9uJPQ*AAY!x^W(tS*yZ^)d=-H(g4VfAZm|_!2zNy+zsGWBBeYVo zz8V%j+|}gRA6yeLGDv*+1Wc2ot4UX^EO;uyDJNV84>m5vNl6=Z&tF{5!If}A1$063 zT*6?6vkLJZPQhW~e=}e(Z8#xoK3;LSfl{IT+Tc%*xdZA6y`BRY9KcJamm4&oH!JPj ztCw0&bd=RJ)vWG3(7Ae9$MG#453bu#bzpVJ4Qo30-?+NEp{Bi~?+*8ZZ~oNZcDT0X zaA$i}Wqq(}bKiCKeb-g2@E=>Y^p?8STWf;#UDq^r-?+1`WzUzM`hfLAVz0pOfO95g z+;31;jh3)wqPb0EF}P%nHGn|eO&ob#M0xz|1f%d zjr1s`;i?D{%1DFTD>@p#CoD~#5u6b;%{}lRnU*ZB9hQShG+q2+W^_?@`_ytmGu|mD zk}gXKrf1Wo><>ei49>~SL}biwJ@~C5Owr7cKF>ZIsYgSoN0smh{FYO0sxZ7@qZrj_ z6r+&eAI27Eq2jiVKM2wn8S*T(9B!g$~j=ALaT_vvK;T=_RKDY zO?LLZB4iIQT8yR@Gsd!RFHZ}{!(S>?o*u5Q#TkPqK&A`(F?wJqxatHswXVcvEkJ?9E)Be>6?f=e6`$@37(3u#mR zhb^8;xHuyOJCU zmvhAcc`&J_4cDS&$L4`@+-@dNV9wb+=M>iD+^`|Snh&Vv zW8c1xj{W;Ps%mSif@^E7cHHwjxz>L@Mp2-Duz;m3R2mFJ`*7btDj+T&;PSAIfzi5r zKf%=Mu-l?*jl7h8oodAfR8I)nW;N!9fuENTE2u6I%1;f~L(b;{5{m0I0vxH8$ZD(8 zM&f7trEbYjA5LGo5*ALVK5aN_5y}G)lJwzuMciFbgsbRvg0)ZiX``ZOX<#MUQMU%q z<|+s_wdd7jAZ!w&geDVErCbp|f5HgCab@J2n^$xGCjSL`Gnkkil|ckFURr)6s~ z**N61akF^SL8&c_n#G#}S?zM!_&Ge6!XuNmW+6N=4^4+iRx0iB@rY#DCRHMmjRKLZ zh>l;OKL92f&B+i zt2WWa9?w^-#*}>hn&fM#V(bXO<`(1f>e1#xPla3 zpIA+qn&aaLW+*>BynGe9oQY_-es6R>XKUsh-kbsb3~glCBdDobs~Q zF+#6jc?Umk#7SSg$egOoBV5U2=TuE2GN;OXq6@;|u4GHnYOLs=eKwMS>&lymKUJ># zi<(zu3;v%ruR2Gw^3u6@Z6-$62`YTJ$SvEt!@#V0!}B`k`B!qh9tgB$;Zpe2z*xox zX_(wMLN!w03t>*c$BLDGF5og&;atc85&%J$r+wX%9G{w*vmk|1Jr^Tu*|Zy$!#ibI z%*7}hMpZm;I7&)JAOyjfo3#KC4Zo+PNDiRn6y_cUC{%e&30)bMFkj^hj&{iQzu%x>LFO*2g>ZYw|j4 zD?5*qE($|%2v|N##HSr|v|y$UC5V|?I8ui&Q=30W&eY;23n~JOS2zpA!LgK|jbvrA zH@HNZyzs@xf=_(H8GLKbmMdH2dSa}B1L5G-vjkm{xH+i>Bq!!+7gCMVaGL^1&D!KK z7;tp%V!+jMChW(JgIsf;adzBLqlpLm9_mfZd~6f~n>8OBjled|d~EE(ENdv)+H^v- z?9&0Ut&t5op<<^1V{;ur>;A_ohibK_E5{dPqdiSI%+|=bj*};j`PV3(So5#ZJeg+x zb!wj2ZSr*G$%!<{Gb)oDi&147l5$onKA~q19c2ow#=I*kS%CAdtI@`;zzyeJ)!0j> zD(d2-ve8;q-D*=g;WZjmcR|c1CSf5A96dPXQcEY$(wa~pP0hZpV08u1!wtCMmQ&bwOl0xc$)cLk-`HJ@VM zbsE|@hCZT=lIa8}NjGFpn`Am!`ukYJM9$|oiod~n%~Q|-Y?sRgfl%XM&8aQGx%IWi zLj@*JDmDPcCW_hUKw~0<9za-r=_rMBV@xc?1VWSFzka>nU$4(ttbB6p`#@v$SJu|L z(*0{|D{#TS{?03}y54iA%Q@R}_AI5nU4p?bi7M#hWCk|NMJ7ZboQKXlrfu0Yq~j7$ zL7dO9XKt6KeJwdh&)lYCFBS|}i7;03MP_a<8ne^g`9d+In+0_#4w`6Bua18-4TOM80v)Q^nGqk7EZ(MBdUw61=*@4#Hb%$HaDwFT2ztBh4 z1cM)0eNX*xFEKIq4w)tQ-)v^It=#j_SU)y!+wUsF^gs|E2VF} zi$)xlR(!w~!a9lx zOTumImjuIeP^k;i8mOKsL}w(+#gXAW^u(~Aja9&rlH>$DT!@DY{ird{64SiRlTtN@ zGk&3kXC{6 zhmqL-d}KQpgm!9lyIM%_ha&y6{#_V;?1Iiv{7AsL^VX7*KS> zW)9}0V=#0V(5c1RUBQCri`rd*-Mg6G6~<%do-^y8Z0mlaM?&W|-M7Jvh0cxZ7#lcW z)vUpDS5=)K2c9e2B*niI;NN2GJ_aNT%yq*u!QMv}HX*lf3TqcRXyt}$%!h+BbUOGt1%GNmczJv^ zm|QMAd9l?2Yc8-xgnKupqjkqBdhVV>oG~Jcx#$d)E1}g!;}TO`7(2%vH`22iiZ26O zyJ0$ri|n|8q&F~}l*0ugN!Zj7zX|d;84EGep zps~ZKakXK^$Ulx1nk^qZ@`Ydi>5lOG?{s&+^S=4CQE%K}tpw=0@WE4KV`D#cmyZ1k zBBA;x-u;*hSd1~L4dRPp80Z@4Dh9*lD9ju{;Bq7Z2ooz&p1QzjZAKw?fvDwz(*~)? zOHqhY>1haHnV*Gf11ufKo>DRTzY*skdgS3kYylX`zyue7%|dI_hm+AwdwIYNw%@j{=~(sJ`losZFRdDUzw^vc(^pG3_pGhmx2d#r)4p23E|r?!F~!M& z{hw1N^2wpWdtwIk?;d<_0wC^L9MpGNJ~pSM1y~}EM0?qAr@Nvctw40lB^tMKxf#ix zm4XVEiVBwM55w7gWS)G0nMS$fGzBXNrL%i@9+eBxz`~2T=SKX3B9L?{2c1cph#04x z=f+o!@tZlhvtnI}fJ;#TT#6vthd6kL5aP@gj6hSwQ&vV@b3{BaozNzt31=Hm05fi- z$)PTzz-pY;AR3!u#F^qg<7xs%OBQx_4`oP55rCGA#Je&;B*Kgc5Kk@<hjg^@C0A1P*h!ifolz1$aOwHvP+ z-#m4Sb?Wzd_oC}=vlg`y`zf)b01QJMj{THmQZB#o;?birofp07(}(vwXN4bh9X3vS z9>(fF;35GtGl4rY0EB`78j@PWaa{i-;;4SGIzfaJxW1YDFkzsVIF4EHL?Yr3X3++F zZ>?-;@y;sUQ0uR4a2E}Y)@}_BY&TZ~t5)w+bU5W2Fdp!HPNnC;no}!1*K0(-1h0l& z5{bqYuhUDyh@HY>0|16MZV|9ntrX)iTA||3=eTQCYQicjwSB zdOD>0sj(j#4`BCVS-uaK9&y~JNbiR+$(aJQG=C~-SwP7Io+cI~fuO)?uofhZrz!DC zxNI_46+Sne#Lk_M!ecyN5j!m8(#P@SM$LG_EL+;Wnel%y*?@Tm@qWK&0{{i3E3< zb+R@B9!&(&JRYi(+{;{#Zrkv8ZAI&_zg7GCqN4Tf%lvKYip;#~uJwh5>$|F}Z>%dQ zsOvHm-<RK@ktffS0E|6=g^W{!X*w7^j zcv`R(7{P*uGaE)g0ume5+>%&d8af{u;&N&wT+f7~>W-<^KLsq=ff zo~qhYwy*BKr*qBt%fB>DO{aiI1z+t{NtYt= zQOl>SfK_iE#`QF9Vzzl3$w>y_v&6cd86~(w2KG*hF2B74T+UQGz;SXI{&$fro?^;d zD* zhwtJ}b2s<6$FE$t@**_8lu+v}Pj8vO;Cfe7O}HcR&-ju$%&p>rt4Ex5y$>9{_r*c) zXGN3y$D-c2EY6yT#5o)-uI=I&-ZkRcF7qRz9p`(@mqjL?b>cXKV>6B_90fRnIJ$B0 zT|168hUd4qZj2l~qQ(1y*zfKXUiYYY#{IB3=>9p*)#4e~uW|l&anOTnb1v>*7wg<# z#`%AVXFRXqScYRO!ar%9zk=`^MTdKnsPQbu%Io>)y>AkJ_gt~S^}Q=&?yaH=<@hG@ zoR9N5+8Nq%o?nYSK$dHE^@=V$Yb5Pm-6&Uw;Wxh`Y1?T2 zyJ*AFglA79FX!BMD*oX9emoyGe=chAeV=!oxZ8U>j-B9hkLZ?kC+%Yn@1r7B@&dFc zFZ2OE2pnh4xmSLJ<2}z@(V+Q2UN{frwFmb-t%&zS7RQ~5`4ZxO|B%?2v?S?h(my3vBp*rs zYVtqN>X`MZSzn#?qm;~)RVk09{PXPk*`G)?Q#(`tF3n79OuLx=$@K4K#AmF?=*c*p z@q>)FGrgIMGtXsZX7yzKAiF;M5$s9uY|guL8t2?O=jq(^+^*bb<`&O=WbP00n)5<= zWAm2gugt$Q|IPW?^BzBT;taI6L z`Kt1dmA~N6^xx~h;D4>cU9q#``O5anZv=`1j|D4&&j)|9{NVD7Rqm>es+U)!tms_v zlj`2;->v*)P2(!}s+U$jwr1X%M{471KfbnnZO7WX)`r$4u6ttry!Cgj|4ChI-7o4t zQh#Yf!-g+zc(ll zUfvwo{JG7qY*~u`)-AVhxxG2L`6JC|udTQio*z;pzlFIe53V5wxHmESj1zV^Qh({< zN3aA8H;i%2P0%XfwSm1JGOcS9SOo#=+66S$_13i;y@>~{YmfMt;3@=u>lI6kHP&^U zlt1-6UMx3@t>+11j@fBlCy9XBZ(YwSxM-fWu2V!)u@3XMBD?sE_54LqQT&W`{gOy2 ze$%@Cjz}(kS6%Ibsz0kIErM@NMZKA+oQoe$#aEf`_-;chql1qX1| zif4U5cG-)tg9x`9_mwDN1#GPftS+oWOI9y7;>sC={$72(EarRl)mHhP^Q-ac`eMH9 zKtB4BuLH zD&634IZDa{wk!Uf`JK9y&X7LDMQZNGp=iGcDKW)AXId~sBU z;+LC{XXd;gW1IZlE#K|In*)*(N5M&ccRlV7MBb6pDx^xi7b*DV_e@iL(~qwh^B}%t z-VPw10hIV^a?^^`C?!5fMLB+(tXL^!N0IYYNZk+LKmL~^tS)c4%)w#2JBo52K@K(` zM@MkY{Aei+fc}*@mLg_#9l+JqS_?hRaRHk@L(se&lWNjG0(GMxwi!%6t7)_aC zO=>}@4&$j0b6HLaD3SE218qp1wn(YSm)Q77Ip??kFG|lWkqG4ZM9~g9E7`Yc#Ip5( zTE{@Een~48f;Eo%hnh#xl`_eiLK)Q(z72Ju8TVVIoKOqcF*irWKslL6A5d$I&tG)v zn&zw-M-Z#Z59{V3JUJ#Mf;v*wKvlaAA>VySb2sFI+LhX}8^4qWTY9Ji8X>jU$&g1; zCwwuoH~=b8?vJ3%tclcjhj3=S--kM~72h{UO0Z1)G5$$En-i^Q0a|3aw&L85t7dR% zgM7LTzmxI7hrF{cuaMvF1-1I2F(?(s<#TG>YFW}hMj4kyXxxfAx)D6P7Hw8DuC!#6 zv-`l8-QW*pK<>gQJz>ceDari zh;n&E>K>(uR9+~*)H#$y-EJI^ddr78sI}G+#M*~>*1eEC${cHs&cz^NRc#LSn=YIE zEoF5t;%9DEtGpL6^L@XJOH*Yx;-%9ICwEj;|CwSigE?o=6=Xmb9T}QSX$FR@7wstJ=_GQdixAUz;}MD%)wwqiUH4 z@Lq9>bzjF%PU)N|U7_MUBEud<%E~UGHavpwY<*z2!>sF0y~O-0>0mnKA~g?lq`45S zvzQ;v7Zr-yNacDE-!WgLA89;@Yo*orYuhqp;>vdCc{Z9~qSvbV^hMi0uy zew8kX&J(#sDO7C64yP3Ei_(Y&_XL7O*=###7I zn^pH1CgX*cDrbEkfaI~Qj@H=Zi!MELNKTX1T9Q2-mUk^j+6qzjg|-uvR5gMFs>a4jr?vrTeX#vxD3($2d9qgNYUI#a)@zYk zbX(XVsi;RCCM&Dv(?q&bNh;rHjZ903sc0S0Cv_)7P{L^g@eQR==>qb#8>QzPw%$4f zEoj=%4DW+fuq9A6mV8%FY1gpkF?AJM=T&jQ$t7Ab(bSF(Go2);dc(TOI>>aC)X*YO z8ksdw@llmb@k?8nHbvBU##GXwwJvq?UbI3!{BmT6Edk4*<=M&II!QayqaVA#DLode z{WNvR4RU>-j9;~GOhJ{1mXCU-+V6e1V~fmG)pOP?G4tOV|b^=XUHE#0cX3% zdQDBt{4+Fjs_FryKh^?M6wv&eoK`jk*i&mmKV}nJ2ln6CPWZ5YH#Ih|8^*OCtujZn z*#{&wRL+!EC6Cw=C>|;bFrV6Dvh8psMM@^vi`^?_TXRrZM$u*GNEkH}B|=LDZF|)t zr-VgI=H#WPOsbTWUBr8B10T0+7WNO+wc?-7p=}59E89+1VOjQx^bo^Rid4(NI(;bW zYdxNz!>|>!F7nzBrb`K4?s{XBq?3Edm1lx=kj zO3U|(>vpR$IpvkLL`m0mO7V=bP?Ixtrm1>w?D>;U%uB2uB1IXRJ)+pQNY$e0^tQ5B zwH=`BQr0tPA4KJg{Nf0(E`30j{_4}6Iqy_ELD`Z^)SapqLd%_X z*IE0N#ONAIZff~bV-2d;rDQmI99LP;YOGJ^T75l{?Ol$T+ojCW8f6YxPE8@ogzg6_ zD==C{wN1!;Qr4E?too>oi<;^V{IYG*wk>I~AFTu>r5E8zakkd>SRUnHjbtdBS;?Q0 zPMZeo9k3nZ?*W8oT8h7nmGaMe%5hOX=8tNBs#}+Ji0GbhY$q>WWIy zmcKUSizS+BeBRmrP<}h%OAA9P zTIyDvR3;yFsWi8pV^_;1zg3;J=2wo)A^dJO;RIxs!i6t zd8KbvX{bw8DOC+-%S`@LGf)cHzh}*7ol&)pwN;IPkQ?d^wFm3UbP}&(y#o@r7ji)^ zsI;kZZ$%s&U!jEVkyevkLdsLLO{nBxVtUM!&Pq$#sZ301@HfJ!>q*vZ9y+M zqjZGk)$Mr8F86Qub3{wvp-6nF{kZ#Zl@ZZE5@wSW6l(1HlPeD&vl5KbHj{lhfJR% zqU}gm=aZCUPU~>A;`wXw;Wq-0ZrQE`5MH^9H{wD zMJx89Tah+HT!VbI%Um!emX0Awm3F+R-weNJs$1k!l{3L`xe63ARPAQk49<+ge5%r7ZF zo(B`jfR-u7MedLu&5{b+K);RP2xC%D_&ueE^1Z>Lpo&vTH>s+_DjKSh4D#2rhKfayl?=AQVnug188y**wVHxP)m!3`mpuH?dnO49DVkP{~R>4bc4ZKp<0#A3nl!OhkJ~v8T zvk7$B0^VHaM!{6WlEL`%tS3Cud+y?uXb~Q)0Z-;&P7J6fM!iF9aC$LM)GvdqGP4c7SE^$iy zLc9&X$`BUd{#Wr2;)h0(fjt3@6k|5_?n^V$jSM3b+e~L0ImR5U2pJI<#n;92VpM!h z{EfIz{JD`Uo)iDgm}}&T2aS0~zA@js@6hoh`zzHcpiaTK!QKNE6%`HYTvuV8t^4|b zy05pw)z|PofDf#5rFE9!@LYz&dAaqx$~v#G&ehg=rFGWv)v5bRE8fb=#GV8D2K)E! z**&mdy$Up{b5)~z<6wWEys2v3z;k1Lh0F)e0r8A+<(`o7kk}H6Z`~d?j8E+dH+svq zFASyb*b>TY!^K@Y@0)=d&_5;N2d_C2!_%}9ZHU~?#%*eKAH9;dej$ zp{c!@%@0@mqpABD%S5S>uEfm1>Bkqz_(ucz|A1&_?P2=ES^uQK2>@isJ98zjo0}N? zXbek#Gz>prg+i%VHvb`ixQ!nh?+3*2`d|^}HqJluHT?LA|Kkf%>VBm$X>DipqcdUu z8F%CdOQT_hx;6&xKjWJHxB>A0126Z2B6wHkKs+}%3OFk`;*Scr{J=o}qa_%q5T=40P}>d-{S)OU z_xG26i)BrXAFH(HdV0{2a63|b!?rW1; ze&&nID13BZ=F;2wE_UHhi+C1X4JEFNrp->XmX_%@7RTDAo5f|Vc~k#1%Qg|oh^VrV zDHPiRBk_e8DHsRB&jUd*5_duikt8V)QcP&Q#)aMga0^0x#WhUxJo|QEzw37KU9pv= zd%bpD`#c~2JI?g--Fzk&5LQ_b^eWB&cQ`ny{5gi;_e9AfIIkc#p&XzVXlbrFzjTbI zG#T1F`LeO`1kB5GiNd$(vZGS&`$^WlB0^C{kY+%}K@o6WZU?49vPKc-F1c1=0Yy_n z^8o#sJf+CyBmP~$?USCGT`EgGl*(5ihEnw6Vp%AuIsVxg)$qXhJPR~7V=y%f_D1O0 zWVj)k9+2H$v^;Lk!EZIY&pIi1NfFr|vPQ6QC3GpniNH=Lk2Q4FyYk7LVCsb1SzVxp z+j*3`%+W{MPEddn}Mqu`!f#`!6N^6jW7iK2f4)>{I)bGiRtGtympc zTPO1vcg7OBm)QjWUy1sz-q>-S|B~+k%rj(slsEi8ltCfhDm#8w#O`4+hb$`WV2W_{VmQFV2rDMWN+yjBuI(wQ2u?9&R6)qE;D6QV+FbrWb%)D zqi1CFBtQZ<#EN4~ykZh&V#)4fP|gK|9KcG2SE^3x69t~|r8-UmPpNn@eDVa-DLT4} z?D0AQj4=LwThs^>WMrmdbONX?M14t-=CZs+)(bbrxLUE?34O!7D&-uwx~IiptH{Rcf^sY4 zxx^&P&k5OG2dE10&*RbKLXGE+S-E7N&Ur{!s)1jPX^FLPwYg5jB|?no!dlI&>M6>+ zMYL8CCrvzuqdVrXBUt8oBqeij#LjcMcjD|{Ymq7JDJu`jqq0RZ`H^qIL|7UuY5SV- zl3i?w*TpBA$Lx72iqP$oL2HU1Wjo}#OhT|;{w3w{SF>X0(8gIm)|HyQERWU8g=%sm z?`C0h8}A2m~!`tC8_y))O$}Z z=CKJ zq#>lht`d7Ya)`(5NnpI{9v|B849*U|+D~W1aMs>pz{KFcfJP4_@00%fL7Bc#FRxuqT}j( zPy;dWn#v=xiyDxS`$PM*%PlqqVSsKQ4&L)sld{Ha9uNYSn#l4TE}ntyXKbGe)lfQV zG0$A?+L>q>r^TqB!Y&1RiN7BNJZmoG)H!4mv zz^stj7ueF&x^aY^hJ|dS231;B^P0JsxgoO&_xGg%3XskqUO|VngbObRuPZQD&kLtf zze#)npzCT7>QZzH?K$+#XTze4?amY>=kHDew{d8C-rw>ulO&`0f2?-0t$b8nJ-IA7 z<`{Y;MaKS{jXK$v)$rE06Z0B|Zd~m+ePR%noTr5~U3Z#E?=k}(C`|@O&J47>G^g)K zPD{$05xuiOx4)cpJ-{_jQT&VtK{kEYma`O08MBXM%F^0gbO3(^hVQaC|z&X zt@}RZ+iTh98TIcqOfratek7;^6%nU%R| zM?X%bLvu-pP%Z0e{{2pSzQfSB3;&y$WU-P@#=O(=w^!MYTJlLw+EBe@cwk=>!~f6( z{A*ZS#({8X!)X`G1}-1w^66$&hA#$a>A6&FEvC%@!H1ERvg7V@WW=6!B2>1y{x_&Wc$lsCEqMNxVuvv9^M*+ z!|H*EEb3e$UZX@USWzT-e@N8rR*!0GbOA;GL&a>mvVT%`?Z;eJ{2Pue_7^MSfOASi zlu-Wd$);HRP2k@tU1_^Z$K_Rhd{LaVQ;s5;!$W4fZm-}ODcNWOnBE^H)gU>s@s4)S z>}u#E_|2?GlBqef~?y-Rmh}Y*?OhjytH(CSlTJ2>}BX#BgHb%v* zEv<)({ny*-#4(wf_U@SO2G{^fl*o!!-qKHrn~3}5-YlukK z1rIpax}gKo1S?|%SyFBo8Q;V%iEW^Z!H(bU>XwcSr#vUp^;lVI1s#}FG=tI#7RYx> zjbJ37W&67}D`ELNegQ2dn_cA#u+|mZSgd=uIX)ouvXtdtv0@14&mBmgnenCiU@`?s z%s30F0y!zIG56hn=yuAIw29cW5hltXs2!_3mI5H=a`i|`*>B9xs?sQ^SqS+G zY*8jl^5LO!${N+#E5OmK!M(VwS`BJwQg+;$ z-1NcQuhAZuS@7L)dct||;E3Ah!yg=<$ee*L%FX4NV||8r&YUBCM)}HWyg_{a{gMcJ zuzF*=vpeBSEGYPf@OsJ5`QPtr!y7|#1ARw_dnkjQaS7^CqgW$-pubEX-_t#?h3FWm z=mY4fI0f1$=mVKSg8s(hv23xz3cZ<`nOZ&g@v4cb6ZmS{n(`C5z=;8)f#Q%EApuDN z5T$AX0ztjO0dV9)1NuEX5GEoF%KjRWur&JVX$Bzu6rm8Xk@|HA@=P!O>d=;&c>S=U zF0foZl!*0$`lukB+4B!snPyk0n zqM85d1|xmbt||6_pSgxCrXEs*fb>&CPC6$OM2#YdJ2fyeHPACL)REKE*Mb~kVC449 z4vwOT-+q}I8ofXm*G?O)mGtzmV-OF&_*+27D(dTl11vUR2mdFozP_oyJ_fibIASZv z0Yl&`H7taGBYY5W>>!BoE#m^ihZ(GCr9S77vsi$XfUtn1fUJOofQ*1%uOxx4K~#SH zS$M^ep$#={L-P`qZ%Ix`GXo#rD=m;rEX^E-HZTHNKtPCtnZ1l~ItqmEf=Q)NYt?xlo;6u5 zRc*H#%pX;)w_L6?Ic;x!LZC~|%{5%B(a?_hsK)iru!Q9$1`sqLU9@#@hN^g_E5Yzz ze7r$W&|EUbV0ZfNPiFQ0jvR)04G2QAQzwxFa!huJODC)41hhL5D*3M`6;D^!G8U>c61clWwitl zH{lRK0tQy_Io5tRq{R+-<$*yBEJhg`bA=p2TCh3BMK}QA@dpUQt<_-U<`s%(fD@pJ zdkgXsATe3}$!F%`YH*rLW0D$+D|b7!npQd{j_un7pJe@%6>n+)JQtpyM@2RUL1(!}mIMENHoD6_i=~wlD#f3*8{XN%0JVp)o>&dWU z0PQ@B_v7Y5qk2!`m3YEXc;I6=Cj_CKq@HRY*_Y^^Lpn zzwN63rXmF0?8+@m=O`xMb*H+9L9%gS`UQ-7Gp66aBm>^KsjHdI;X#Ew**w4Vo6i1J zbfEFplg)S6Y5unqfB-<>-vET4{en&yedOaba!!O!8Y#@t`j2K44M>6u0nA{;K7>g6 zzvrCMG3Ra3=jdVRM!4_<(dTWt%V921B*6fQ^qEA(zp`hEUXiw>3>l1q6pI;+s}U2%kr=&MdYRxZ%)6Z zEXpXui(eics>a?U{0kMnKi}BLdk9GCUg|LUqODk{OqYf&KD2i-GuKBs3Zyma1AR$X zYrZzf{C=r)iuO{J!LV(W$d$F1QvWUB6&rl~eJE9{ktlLlAVNi_xJ)9m#mrkQjVXDY zz<%C1avML&$z#Imz^T_4FOyX6ogX5XIvQtO;1j>vr$NGl1+P@`k+*0S%wNqTwoj>N ziriQk(TRxsQJ1Rs=ihW0HUifhVcq1pP+e-<-Q5IPWhmh}=j4?MZ3P(xOJ(I>B$bX* zG$i}VAS(;tM)L!aN0)F0*@#Gyu|TP_$azK9%OIFBG>3%ad8nv{X^H1}w8w*pQ77m3 zhy#r3Uj^h(B2D}#ibW5d${hgxn02RnFabmgU&`S)HT7LYVoe^NL3@!wlxP68(ch-T z#Jyq57xe-*BOD~!$uyUVxS8QgZoVJpEnd`Tm6?yx#ec4mo_bWHV6qJ-iRhk#MN~#_>C}>tRZqOwtgx)Jcgp^6B znArKF$iYoC-2=DV#=-9K@8cKIl|#8NNp$T`l(h@luXtV-;rp1G{!x$@cjDKCJYzN= zXuKN^5hV~d$yJ9(SQQDJ!o6_=G?5(qGUxf*Lu0+<8bC1W9h4QSiEw@Op2)mQ*#Ij| zvU(wkza}ldM?;B-7XL$hRQyJ>MR?dU60#Y}X>DsDazkJKGG!Tbzk$Md_@Tk9O%k>T z|Bhnb-v(Z}SyHj=_P$@JRha{a!Tl8pi7`?YGr{H%fW$xox!f=73OOHhO(9N zfkS83bb2X0bN_t_KW{;-59MImy-yVsbV3Mb?dsTUwvR#_M@KzSyCv^0<5Ml4^QU0vzqSaUMJS$THS{l@A?Bi~70iF#(KI)mT&4KdRchE!To z){5m2PbSX0$ovPIeF}}Z-I6npt$o!xr!cHvk&lB~MP`1_vhP`xKYW%@0|)_=eL=mD zkX;)-Y@9|B1v}YHPD;SN36tYIFbQT*K*kCuF?*zmdZkfDdMG0bK~|PCN(LWMDWJnpdm;x3QThyivC;T|cP|2c zmi8MaN*Q~F)Z(1>8=@=+4+_#|tF=+XE+`wL==hgS0xQN%)l+L%HVh{eCkubMtiDFP+LdtETs?C$r>pBT#<@ z@wI${7k|$}mXlXwnjOE+6q!;ZQOloNnL1Vk)tb|a z7{b0YmWY^L5WY(2eAZL2zvz3idGSef6amkY!?EzuVDX>rZdS|ZW>v~^#NONZfhXKT zhP7Z5n*6F{Kr>QPZfWCuJ||*{`ESoepu}G?y9ASGKEei=)e+MCb0Asezz&^2-SY)g zj3>7<{HHz?aG1`H`YbD1nGsgGc-e(v@epKbRr?RfqY%RhUMKy(HH75um_M0W;F3Y) z1{Zsn6jhg)8L$9p#n!Pbr0fiXXAc9ExrNj$JyyW(0kJ_N8@;~^ti0B)ms3CMMDw~% z!|UX@_idz&bxK`xt?n#iwJV~!`d(5!ca|K3(E8oc~ z-QwxYzdnsX)`YXzw#?)LJL5%AlpYz0<1vJldFkpr`9;F1TdIU@k8?MgWu=O4(v76k zj1;>t;8}iClIvp<_=8?invFQXfs8?KrKL`}`8q#lxmACiWX*>?g|_G0x;0W^ci9zW za0s%^;fywnM%;W$c*6Lo-5rmvu8l4u;C-!!pPhv-L(0kq$ZwX=7DoCfu!mF>8YF=; z=MXnpYA)P1rcOq{+Gjd&+Ui3-Vgw*!2qwTH5^@Lm5U1(if`k7N4NEQr7@tDt!-B4H zi@ft*WNI@BWWd+gTr^fX`o8T5w7L>$zk}|Srml$((zx3obJ3tq7>qdJ%p$p&-zdVv z(<=Tgv=EG78HWb`=bw?!9Brrpy*xv43M1Bo*qdm-$lBaR?{G^-6Q=9I-l~=>W`;EU zb(&FCpl9JjUAeaS?>-7fC&l++>5rtF@p&DB9?x?9vAr5kuMFbT4P>QU?prB)%(+tU zTVzKf+=kV05n*Sgf75aiivC7*?VqXobL^89iyf*_sF!+9heOpKG)p~6#=uV?>2Ba8 z305+d*4;(hsN+Mf{`*P9ArxXQ>EvsH5lM8zki=R@g0pyiuY-Y^8#eDwGQ2Ckg z7+;}kW}55GTx-6yW-ev&bnExDD=Ad`$l#P=XYm$jgUO;a&j|VpP1o%MZ!(`&pcWCF* z>%e~REiAMo_&hj7Nv7aY7r#TSo=jxK1frh!C4M>Hn8#ipIQW6Cm6)+dh72@<7w-i)g(L@pzUzOIIDwi zEwWPT2FI$N6b~kKBo>eWDt;+u|M?By+>e3C5pe*v!PV2I0al|6k7}oU{5wx$e}}je zUO-bL;NZl+3QB4Sdj-=+UD>Ip$9!HLif>f4c0!5K?H3m*^2^pzkWweeu+ZifgjK#? z7u|$%jxEQt+Y#cUM&H**_XgIik ztZ1N7e|`EaI(n6r3=r~}F;-Y~qnEQ^8H_Xbi2@FL+b8-8#CPV$t`yx&#VL0Bs$5>x zD!4lzi2JiVfw~9+iW|WZ70djd=uP?TkTpKJo^2ZHpgB!9JC zD8kv2oWv_T8V;S$N*tgt8Q#Uz*pZ%uCr{ z*!LqE7a_3YeCoDF!~9vbfW+jcXNxfy5@EE}`=n6#GpPJoT`bm|32l%Xv>rf-g9I~V zC3hKW8%rksogoEgY0LZOvrs47=hIME@k_GEhX_lr%|RN@RO%inj4%jqjmnCPlddnF zU52H5O+^Z8;YB<@<)}O1&6xM6v@ZiiHD|ZG?#0cn6+9-B4Zk#}J3iSz-}asz+46S~&`h2~yx6UuYt$*+AsYXV z)zANg~PBjutJ*6vt;{)RrGpWAMy)KuedE!kZ|9D(>7jB{VDA+gWbN=h@gbPzU*`qFfJ($aGJCW|YR?en{46_&YD z5#bkJhu%{Ua?zddQ2;}rpczNFka0#y3ABs|=}iex)hg;}HM%XirHqI_KhxPGsMQ1& z2~en`V4rLBNIv&N8HJ4NuD6rOp*>B70);2>{Jd@T_tnm5%12#tldUqvlG71|p_%4eOIOJMQLYdy$>F)3}Kow4I_d2B+Q-KUcxHS z_f32Vgmn`i&HJ~S?y$E7Vpa{|Ws}jWH}%R^Si550^@IA?y&LVP$NHLfX3{ClVx($l zw|O&(p71Gl(lJ*PkU!!LoGk-*Wt;qe80&%THL*}9Gu4rkHLvz6(cltk{0=dx4AF#x zlvGu$D35+$GsZ-W01e`}K@tQkPGC$>I`f#&Z2+lPs}HrB1Q;Zch<-1BC&6d?UA3UK zW#DqOmD!BsB7Hd?)^XkNY>(kp6TR$Ya+og~!Ey7{rsVtpT^eb(Tbo7+Y=VtaKwe!qBfb+@tn zyDYe=M%EJBei3~Ssv+5y;O6nEm$|XMyzUZS-|NiLQOsMN z#8_2C(;$##;;+EO5itWHI4eidLm ze*#lmtgD_7MVY@r{u%xqTaZIK9>4K+D(vt}Lz98=nz6nSyEZxk#4uW9OsF`(ZNPkK zt(XLXG6=iz63#s9_aS09F{)EM0ac^|w?YKfQKp4aDyi00a*!-9XDX+AfN%cw#>h}u zXiQeu`7GKqal*Ib?p?&K-|K8hiCm_;j*iwW_fp-Xa@mx_o&I$~=hr7P#8Bu^7#{XT z+~IJ~PeDX;20s=)*O_Y0-0!R%MUMdo5dUXwekdzNYq)UixD*^ccZ^1E>D_ghSj>t9 zoSzop)&2S8hwDQyhn#7C?O|KXS--!Uy;bnYL$gdewEeb(mYPsvPCDDr?%e%h}Yb*#Q~JW>l>B?R_%$ zf**$%xrRur-y`Ho4-nX`?a&4y+M`Vmaj{BONlDOo;QMrOyvYGVcxKp^P-Id|l^TtS zzqHVCKjjhzG zsnmiwr^PoLhq&k3CdBZ^ebjH%f8fGP$uWZ1W3pF*ErndD@T@Rq3`4n=P)z&1W1gX3 z;NUl_VD?KA4O0Iq#Hz7eZCi$ESvJkNEo<~3`C!6dw;yPtBECD3bj(P z^CYl0Loi;NMy2rk^pC6&CrKdx?5XtGTH0~KypJDWkM_p_^t$x%LuZ{)bM}b(!S{2( zw~aosD2nB{oCF|6fE7w13urxvma2D0t=bwgITxBK;j#5@cZT2O#u>^+Wdn7`V6`9v z<`Axt=YHtr1#7ywi6^3Fx=~{Hs~{^&tU0WIJ;vD;_Y#50Om7!j|{$>}91XNJa*trpQ_Nx4jiGUJvk-vF8 zq83mZrVh$zfUJ7)97{m@NXhZSg;Nln^nl?zE&SIR&Mn>4_aWEW+M(XZ&`yt&PlXwu zzN{=RZ#{%V+V4fm9uthF^s`*!-_?w<>ZK?th>2PK+I!e;OC%xL4Mo<-v$Sm@SlgbeV zM@EKg5q^)0+4HK*%9&o0!MwN~mTu4aeoR2F!LzdEeR>-J{%`~SnpDPhx%NIwzSD=1 zMy&@U4d%<~u?wZC1r=_iWI4bW(x!brmht-A2E$z%oZQ09sQ(L$=XYQfHwjS6kuWM%>mpj)%gXy!5fw z%o9Ze@xFYGe-mopM?H|OD>letah0nMYnop`VOcF@iP&w4zNUqY!@Z;Sh`0FN_;j+KO{q#xzo|!(0T$_{U9}*Vsr@@z=;|s$IPi z=w>LiRNh+RRk`8b@rRAqIm579n}guRc}BXqvA6Zq1Yb<%)#IzDTuiM86JDn0anIW$ z#8$6Mk6*c>x)`fV=wCA;6wDCzkX+1qP=cha0-Sk58mmxhE{V!GYVjAeta>xE+CS;z zmh$iUw|7NnU3wo_-kBtp)JmFBy+;{yab`Tc!@CI_ZgL9&tWZiyn4PL*?cSK33cQIr zydW|kh?4pBDD8>J)+CKWrFlZEH`26ShwvT`SGDI=Fr!a+0g|^-AMWliO>_))NCp1I z?tkZUx18pHR%MZK7`+`hsl@mpO{}t-ks;%nE>sO$iGtNA{XUJ5V2Y{p@9Uv_3Cij8 z+U`chh~EoPaa2g_Zt9Iq`M`H}BLcUTkwz?FS`{rT5Amldy}O(buX*3PM_V+KDU6aa zc&ACHNxe~FXp}rc;N3IivSDsG;K@e$QL#`}cG*8HhRjwC>;v|0*j|bOE4T|R6~w56}@Hb>T5)gNuW~L(okW%@ZaEkZyRKZUY;NwXfHdJ$bV?Ja9XT zYFKb>Ejc&D{XCA=F6=O}sI}U6(sK~IizMsr8}*ON&{uba!b03h;#H@jM>x#j_VC5I z7-kL|-56>nqU&%PejP3Hs_gGp<>)G3bWx33!Pf9XI98?;mPq^SY!~<0dkD%z|Di9a zCTsLiR}2cR;Pm2rjD{-|#z;-1m95pwbMt#pxJy4e=E3`}gqYIH^bYU{IFSBUqBKj- zJ!E9mCrf@JLFd3MG?vx#{X}ztVJvYb7|pB?i7=K8SotfKdiU!QOgbY!DyKs6cOlhm@%uN zRAe(xxZbF495==QZHxd)oDsJ&$3Ys8Bc#*>`AP#LbK*!_%~aq)a)h{x|4)*EEzxRt zBUY9+u!lY~E>#o7)=-S1Y*d@;STWF6@+OO^uD>y(y6J9`1xK!Gd*wz?j5eFsTy^lP z!|G!qttzUnW@u>YS#56O6o$5|p;zr1r`-B|Rc|sIJ=*(f)acU9rn1C|I*EnGv16b5 zL@@yq6{LROcQE-7@e5d=h^9}kckd~|7_a9t_On4d7b+|yxGl;x{wo3d#Njx2P*HX+ zKG+zD#7{mTHe8bmQh&8~&h+Rm*+YBHc?MC;h zmCYfQ){|`{oC>SRwDVIoUc@Y3RA)yw!O-=?%kmSBrJbL(75N@cBr~&gm!KnD?U0rK zJNB5!Wh~8~R+N( zqQz9_B0fwH4?iQB8*H90iF^4Mr$!G_2OA?05Hh{?NBlghfp3mJHIxMaiW|NMXL-Ub7hUBe9 z8o7L!J|2FRz7HS#BR*#$dat&2GS+uv2r)T#O|ZY#%E18$WqL&y$|Zyhk)?Lb=%j_= zPe~VV7I7e2Y$<3?;!VHCAe8WJ#|4;{hXMvOjQabr8{Kh;7e+^>!a_kB;4imhGf5oH z4R!|`GskLtA7+%iGsbbC@jlwSxyD_iv1GB)Y>LA_sL0?aG7Y#SY@4VsjQM~!!b;mq z>E!wH_7n!bpw+YC>b^ZDow?Bv?waQFd!&9nI`?_FEr7T04s4zfx3$f3(s%+Bia6FB zS=7x2x|rg>-n$K zcJ30I#S=3SGpHoXCAdre(plB566a$Ma_O~KRF|aQU_LWU>Yz>}ZurT0B!_^&kBAdp zU*A9jl7fAPFe4W>o6Joi&)!imlFLgK`Tgh#S!~L6Yx_eS`S0R#K93sRG_JlUe3VbK zMRJPE01*CL=NZWFQZ-8+6gI*^Gork6kMKcBw($;N4zKWQhsw4UyzrOT^mMtZi*}|#_f0Iccm+otCh<6Ql;vF#frYXIMLI0qf8;3XnUo8f z()b4kaKfH|Pl;yl#NXxC`R^h)5Knk3X?g!*Ni@WU(lvEd{R|4eFHV%a_s13Z@S^U% z%_R`Ko_S0(N%$OfmrAFC# z-URQmq|RO`)sAkg| z%p$5d8z`0)PtPnmi+?V^?9eDhoj-UTYAW7bGYgAdDOj7)>LKni^Ye?#OrRDbwJO@; z7tv*@7g!QHK*Q!9U`fm%OBHYs%@)Qd5P*`Q>O+FHqLQR15!sqU`6IyELQ+BU8{uKN z;i_29*O(!svpi0_US*M6@Xoq?lr>~+dckL2UaUI#`#jrMnw@uEt}7+JT~OC_$Wyeg zQ~=cA7vq27ND73&Cie1xW`kWM6aWkk=A=QJw?9nG>VH)-KUFc~#XU|Q6Kddnp)N7g z9i=JW(|&4C%=4n%H*Rwtaj1)~Sgm>}82D=^CoYT;Sg@h!BV!lD}Dk+v(7s=Luvz5`iKF=q;qt%d#0(e$H@4JP7` zP3JPhBSilPt$2JPL_5!0s6^geB+qR|za80l> z%Us&E?3wSkEf}(c`G**U5lb(OL|j^G0}|GmM3oqtSJX^+#G346nl5-Sjg6lPU1N8B z?-!-?x?ZR?^X#9`q^#y{SoIUIrxX=ht=KBMe(!TBNP`Z(#=^%Yy7IlJoOzMx7CK8G zktH`jM!{#vJTFvrz2L93WdLpTTIOHb5;v-KdYt9`fZo_Cb0H`=n=`l z9V0p^V?86}2}apeJ&uSwO_3Q99;Sz=YB%Q8m9>(uVB$=JlvC-s=GFROzL^oM106jj zW_7J4L^w~dEsJ<37oFQ$%V%)#ja3?p3|n)bRW3ArJE2!}tyWi0U~Ww{I*Ts3MmuBK z5J8X6wl~2H(o~T|e0FYPpI=%-9yC~h?&Y)F$cHO1_2j#cM3WjKQV zCSfHF)_sPtjk2W#X|^-f(1S9Yz`1*5NbEzOejztt}u}Mn1sS#e7$9vh@oL8^)Z$$fntz=HG zij$q)-jdN?mgUlhaUxN1NsN!f=)&}+d|eNBZ+=8PHu(jT6Xk;2YuXQWPzk*uB6xb` zUyt2Bt;XsvbK@xAPf^=6?4D(!_p_LHWG2aFd-a4D7DUrUFCFTc9Prya8GE(rj${tX zOa*L5D5y!6F6fjrv>J%iMdywfQCs-KN)eLdnNlBD&rwxx(|i^btf$nn>kTD24Q=dM zMJtXE>!W^tm$gj`;CRQ}Payb#R17nI5SXc&ujYP{Wmy!a*mX5*EI5BBef^z*7JA`< zbS7e|=31hwI1gABkwE0iJ{jf*ABqpKwlkb7DM1A%aDFA&&CFioP{5*|!l)fyn>jzM z+j*^b++1H4(pb26-|6Pm7%jdN(=Lggbp3fl5`m06K&$;^Mt}=5yf7!g*g#4HUhe)D z&=R#3kfp&t9nk>wIl5AGh}!ytmERG+lb2XhYWg=};ps-um9g%$Z-%AG`75N`0TMpT z=pT1{m&IqC%&ZX>`gBPRYqT2}Txd;My=WU|QX5E5 z4W9n^pY6a6WrEm3rCs>FmL_S}`gjhmcdO#ogd%w7sr-IHOO&LSsvJz<2$=mWQI4(C zBjm#}@oM1;W0%H{wo(M~B2sE{6e$`-67na>%88g1)qG~!Du6{M4!&f0BhnLBOe>l) z2R14VM3J1+0nse%S9BzE(KG;i5z>ZijGRqJtB3%;T!*4qXmb$&Pi}^VdkWnY5_!(L z5Ef-W!@^iOYso%MD%w;q(o$Xe{mMgzduHL6`|ka{bjh0KB4+pf`rh39%QK_F@T|=o8c`YrOkB#?0>4@_dPNaxr4tL^V&agp$Z$hsr7%8N_^^$Mt2Bgg zAW2BJy4dHIuYCr?bGk+`KtD;avkr4Z@csHo+o5+{dy~51^3jz^|xB6WXRPOgdoi)3mBvuk&&SY zt29R`AcQ27^WS8^VmxzKX5DfcgSFM5eBwZ$0`9WDJDZ9fCmp>wr;*_Md4<-{kDXHgr5Y|%Vc5T_SlNBo{(PQB^_rDxou&4MrzbRl4;)jT*9H)U!Qpyyu zd7H5b441B830dsaqvxEAWcw}!d(NY`>AFwt!RQdfI(cH$cjtK&Ri+>b{!q;JTS6~M zoMK7xpZ~N3g9Zu252=gi_rVTZ=fPfU=S^|ng=Z%eAoe(&%5_LodQ_k*Xp&%B7Y+mX z3FT~o7XnQOk!U3;>0-g6(?HzTLV@tI=6;DoKP&O{<5q{ZHWwPDf>o^crk&KS^_s0g za6aEwZB^@k`nNFL8ISWXGqn?%4Tn8l7>PnmMYCe~y@gsT9m2a6SvJd}I9@~A&UM`u z=UF^_RD(>T(W5~li-WZL>(tg*lRU<0mq`P3MkBxi2TDtNU!Xy2-^pQc#DBvr18I)h z3L$V**OefxDCxF~lBty^x!?B314qUr<$jRYiBnA$wiim)p%(kz(&Rs{QYMvlRVLq2 z<5di7jHl@OzM)Cv=Pf2vB&EwVl^zEG!j3tBaKq>nqWfbUafF*>>pUpMG_BUufT7sO z6_k_Ofkwys_9SH=LP@BCen*O?S@S_VSf;N7()nBoeH!)*!oh03VM*XR>sJ1jcPMpU z-qrk!>$;&58q6~+tigW-_fla2Mb(M*{L{At9uul8iB$(-4EGnkY=e;|IpNNH)HW?y zaK$MKPN-4!ni%ERMq;nFdT5yIdE#I?8JmumWGz@meD{shjT04X*dNjJ#3}uwgR$gT zY_W%6Y~m$MM{^T*9L+h^i`g~Qm&IP6YLo0cpRAZiS>e#vpl9F7!^gHR*J}Y^_WGLR zIt4kOlI?AU>)Qplm(Q$6-1MA-FrzxU+Zb1#*#if@*++7|dJfhJIai!J_jH8W8a2L| zlUrunsxsdl+L+^Nh{O)A_wqJG;%6a8pP-W+k&4(6C53hS0bzdX1CfMS^7z9#27er5 zbFh?~{Li*jJbVQKC;V(7bH-uG-)bAOhSRQL*{wB&cyqidVKkZ(_O5HA-|BAY#NYRO zqjP_hj{hcRbLafFP-B#3BilDifNF!nm31+=uCB&1x`29wL;=Qo zwxHAv**KexJL;i+pfeJ_s$`LqvslXS0PXv=Ja#E0a%143fa85~3|W>GbnA=eij^Bx zM@U_LD2%5dV@)5Kc+X>%Tw>|t;*1=wrM3Thq^Jxea#$Fe9$%@F`ItYX-PNZi;~PI1 zXNrK>nwQ}rne9%GRt8^GLKLj1BUT7eR?+gWabx~Y-sw7ggy*Z*+)HWwW%jn2CWnUh z-Ys$fyyWaHcz0>3y19m%W9iBGTrK*iQq~lUSr9!-OX2)pIPNf$sV$jZ?zw&d&j`#L zN!4h{aDQpg#Bcc06J z*LP2=WvZkD*yaBOv@=W0Y`rn9J`6esV`ZDDVL4is$X8LhKqMP@X`N~sj1Q?2UR)#- zpNm?O^ou2Od zD#~A7lWrNiRe;U0P*`TD|E)U`znYx<=;FY@#g8T@f0fv=rSsX>hK7E8ytDK8kB5d{ zd$#i@wf$eet9``3+1nP}b6|3JeQ#w=_^Vyb`}@Mo)6ab@c6W=PeDlOie9M;j%!&Pf z|EOSOBe09>zBX0t5SZUua48z)yd1r z@2{h4(ue681I)XWnp&0Wno3OW>`%>w8Vw@o0;u_f9`Fnmxce^5D(i)sP0;UH&CnDbqN8f9K3C#?!#0lroA17WOL4POF3 zQLFIFYEG=Y1C9b;L~e?60GZgq_9s37U(gw%>T3QbF*R=92yX$NBwDNrG zgLU=0TlnX`S>Nq-b$4%W3wF5qu*%taY)2%r<5*|slLPD54?Nk~IMUAZ?IVq`FM(3- z7)gFepIm>JyUuv-@Zp~{lvco=(7ft()}xNXo_HyxTv0upK3IHfut|>Q>EEhe9~wg5 zYhwqQHuWg%O)1rcUPQtnYv|&+dZDrfI4_t(suOu_F4j+YYpGcO+$@+jZ>&oYx?XGZ>rfhs?!q}xMpyZ_lxutM! zm31Nm(X2^6tPP1~~-_hZxM8V9?4RXdO9H*|M{aZk>I*>xpiE z>t>6^VV`+QmM#X zusmF;k0d8ufF#l)TAV$NHY_C>u~qg)_3n4>VK%EzOvQT68w`kp{v2$TodiDZ0-;DKBLk_Lv@Xt8e(nt)Px&C-c6BM z(b^Uv)>CA8pe58%=jLnuGOVDIXVUbs zFR|}}rSQPKn6W-1W}KH%J%rIs5;nGFhKsnFNy^@UY#l)=2G>1 zs6(_${jk#cA~Rxy=8y+zxOQ9PWMW5WbnvAE&CJE~?7@*U{gpFM2)oYCY*N4b3F*U_ zWUTZEn*(;#a#o)Qn7?)By5va%@AdjDCwE%v>nb-?7HUYHH9JYa zt2s^SsEAA#vW`)v=@8YYCnS!{s3jbzMb(F;&Kj+pM?wWgCY89jc(l_yuunbHZuFGS z{G1sk_DVI_E9b#pL77=cA}c;r<|+~v_Du^yQkgnZ{Jf3 zuYrjz?d{#&?d@CGf!2vH@cb7hT3f$LUhmoUr;|Ge_wB>=4T6nfTWDhd4J&5GoQ*-N zw6cv6vauoBPAe~izx?H9IzRdTtnzw=-bmL~Ctn#)zPxVkkd%^B`)<6iPO3Wq-$vl2 zV(L5<&sDmO3`n0UA(5S@H)Q)n4vu8iVhhCch``OW0Oo0frR?$?L6j`AJ7})w(mwDK zaoipmSs2KexEIeQK4WgSJP?NLqoe{V5_7rdFjas)xyW-Rw~ zE5k?ZuAoNfh06=$= ziYzDg6?*4^?j)dRTjbdid4&La5$3I**Jj%^PLHfzGT_(KOQ;$d4R(SCrfhM&vk)W9 z^NZ^t6`65Y47gcARZ(H8mijRjUn@`ji{~L;RtgdaI6@7oXs86g&X)!BBM1pzrk`LS zzK%z**9G*xglai{jY^Cb>u%CsqRS$!1=5KG5+Rv%0)cpFE&g>V2>4qY!o#&Z;BNy~ z?x6^%3QKEUU<u9!C`FvRnvEdq>Ma}Ylh8ojRfgO z5_Q&u#+t88JHV))glOJj8R{XKm{v6hKl-CaGw*1gqhJ7T>TR6R;C7E!TF_$l z)GVvH4te8ThkWj09r6yPL;iV&))|c1a*sSteB&~EzietzcSd+*I#qZ$13o7QNVTVugu zYb?xIW0r-J*JvTOQN|iW0(wu{MRD14_4L)RAJ84Bxf!48Gw;23@L&c?)9XD1>n#Nv z>4}BwElaIeEaIgsH4YA)vSbzMz>HI)UqFj1GQrS+r&B8XJ!K@p21bhL3}pC1DL}DJ zu~bL$e4>q;VzOulvOo?7C;{X$_{Nsn9II!iy@%d(Ans%4+V>hD|Ou&$BW=Y z>=`TbIzvMDxYO%g6P($b!i-A6%LL%X23p!njW5JZP8u^{PI?3~K*qTUJ|vT48v8=A z+N%N%E9T?Cq(i$xlcO}YV+D*@VU07Gl0`&dk!OA;A~G{PbF`ZAX$C6pPsQTyngjl= zbW|?IWyR$Ex^**t1$?$r`6Pu0Ek$?^@o-{EuEX&;PT5ED@Cm!3n&+Vd4D3XSObR6y zj5{ewA~A11Qmm+zHM($Eswe>kLYuM+$vn|KPGrKO$||Y#;W_4&JWkBiTEmO7F}uWk z)x}v`7-(zVxwExxpeyY4gu@1 zepaM}d4nB9Yan5=7)jfz9!D-1(<2u^UKy|BY~ECvka7Oh1$pWNsUb%g=O#VCgQdaS z)@Uuv6}EBqhxFS?H`6?;Np}7XZJ~Q~yLP81K+yY>BVdW2efw?FQ{0rE(XmJFo=A9Q z+dnm=0n@?d$~LafoY8=Fa8=4cTsOyAzh_kE7MCyg3b;pAoV3D6Z1!@X?B%{HG91kd zB27o>bMJ7*$4$P>ZH9i(XXd#&x10eobNXgKh>>S5M_SLqbpU!Z){)F2YE4-O>Wca6u-zUnId`xQ z`_gz()LJHPx)g0TZ(Rqm{Y0Lh18bWg`3o1d_tvY!iZ-VpnQ_inAXV+T)KZOl%qLkv zTJ|08DAjM%XyYf#u4WxSFz% z4{{xH%Yczod4uvGbO}2F zhLRYp{LtEz+q*AH)pE9eLuOK%5?>OiLJ*nFdJDC6ha7P1A*#7ta_rim!==Si? zZV*6s1Q4?iwbw$J_$6RkwHQqP448bs*B&V%?)Gp!q6ok{28}4d6l{kGY_-%^7J*G% z&r3dFOCd0|%C{bPGz4#!2T0&&4rI&;*2-L2Yr)#b#A^B7>N#{^ut%r`E6$wn@~K0= z7eW?g%-f@9>k_PbXXrY#kaJZE-X;j%1i;+DVwm&uVznUoK*NL~e^7n7IhhA$?+pS;vX$C7BCQGGXFQT;|eMio#M z)GBJX9PO5tV}}Z2x(xZxPb`l@@DU3wM3&%azyLNo6C(s&;!5uMtlWHk35k=zhei`> zg5|k5r7WK)iRWn(rz&2h%-_n&mPeOiZ4SYBs_ILbc&oMLIB3!=Y)g+~_VhhbzX@^s z=8^y0{dn!>w&QP%4Sa9&MCT@^x&OJ&W81?Ag?-J4=W?g*9~>jN zjneE= zoGJ2ok@fveN+92Ai713wWsMWCMmOch*-r~siB&kfAQgik@dii{`rMK++g0Wu?NQhU zzY-2t&H;i&=*!B0!tJ_Sfw6?NSOl!s77-6iLiprDk-*5aUtmAXAUT5zr@!Q+r@z>h z=`T|%4QC?)aLEBM;09K{=doG?OGg}2y}oI28eufnCQB})kzNx2${{c$8Rh%>zPHsWelC~Zbz z9Nrd!r4@%o6PHkxEU&`gilkqKV=xPF45nnSAnjKang5MH6u7wFUZV8!AtlJr$mNI& z3lZgp1Pp}PxwAjH+%neg3&+Bd&h4F%%Jz}Zv_Dq6F}OX_{`vKt{r5&f(QsS);FnaT zSKjAacU3p<>TC=8>xE$B;63$&_pDmO4_B_de?#Q%uu$K%xuN&o?hVabzjbm3`Gd?} zQC}oE6Cd7h=nkf@th|HiE0qhTuOxKld}4e4vF5MX1e@zlX0Uj~7bb2yheb6a*HJ9Q z$7BCt_;?=i;q|2vBHB1{#UBAJg|u)-qiH5z5Z}KoTG%fr1t#>90h0KQv-V{H8H)*z{KXtHYv&;|z=70} z*7`Z?ML=q4#fl)oDu-D0SjFXlRjeQU-LaZ>6z2Q{Eqe!pk&fwFn>_U)e}33{A0F0) z{Dom_({08jsXSd{PLFdtJTG(h&+N>co}^OnKBnAXu9mu-iFw~V%=2l?yNT~mI?s1l zj`&`VVa0Ms`nGZz1S_t?4E$;bDSJkjVh-&aGSM!TD|ekEyuF%}bQm(rR8u1kFf0D-i-`#;! z0B0f*O4q~sl=MYirleRt&%w=<8&g%ba$tyc@|WxQMAbqLn7a#Vi#{m657#uuY68Ual*P$(b&Fm;6wC=8pJ+!vE{+rvz&W8J5?;*R=@Mlqr zof$sU&oiy`|NPS4{-@_}$DZEaJ@um(2Ey*<@lHSQ8uHfcY3#ZWx99Fg&#th58#HtL z^f@wbB-y0QKNGO~bzTln8G^P*4$r=54lj|Bz)PrejVt7s*TN)ToYEJN$!XjjOisHg zi+8(GYR-9Cyjx6GoAuqC!(-)nWWVE*V#PYiXh_h3M-)*4=n@^>{Jx1VOMXje!-x_kf+3z|*S`KE%YA`#h z7oY(--UoJtFO89@kf9hm^8v+VVQKj=FhI*$xyn9uM$D=H2A))K&UraidWoDWL#K19HklAHPGnRlEv%gW z(xvnT*c<;0=eLl)zj=98`hR6!H7}i4J*&H^Pf(r@mwt$J2E%dRL=Cj^@9dupT3lH({fAypSxC3`MX@@k^k z3Unl@q6T4@WrQDPGzjK8K=G?RZW2IoxE<&^X5%RI6Y+_hoPvz^UP1dAVRPX5eK zs|H3t+jzL1cKMUP9sOqQ(5bEMCr9u4c4f=9NJC#kIm1N1)({rL&Ny@ zo1bo*Y^@*gc7~p6+dq~(`+nZ*Jk?noF6^xKcaD(RU1WyfD3t@0 za(IbJXqc(ZQ;|$Zs8)TZQYNq08(6US9- zF0lw!00CeMVz|@!_^#%`f)dnTvMIOblem@@mt`V1^LT)6WCTR zHi7Ln`ODjMv=PmIyFqO0iDJLaFgEtVw7&aa4~ML^ZwJS5qO@-V2h);{>tw>hF9FM{ z#bCKj{`I!7C_4Fe@Ho;c`Ho;Bu^1VqQ36vn_2?oni4h)eKraR`?-~J)TceWmt~HsU zF3<8Ms%3VowqU|5A;|9pKtq;{1`N4WV-ctq11@s*bq%tcU=>&Yj)b}h=iMGb%%aSA zdvtZpV@}fhBI9lB=Dcer<`&Jn+HaS4wcj@HdK=spZuYv=aXMe)}R%InQ+bZ-diPh=RD^mr_^zsc!VG&e7 zDzuTbEvg2q4d)+|e8ybnb|v?`(UHpBTCGwxxigZ-F_*c$NSm#yVt192x;2{SQn#}5 zRZ_RObD4?T+z{D7Pxqj(9=E6Y*!sRk<@nU-)=r+j*z{2CuI9?^ExT)XwfOwT$@&X} zxHW?CmB?g$d^c{-1EY-Rz&`=&D%mkRe2?3m^O!v6u>BJL(+sCF$P(-?WK z9qexr;APRtcFy!xm&s`Thchm4(qLbwP8-x8@POZjC6bp9{r+)O}(}lY5=GemkMQQNf zjaJa^56JZsIA;tei{YJGqsfm8Y}EVYvS6(Nb*o$!Y@xjLT(bxoJ{Bu!Zf6GQFVJDOb@qq!-zO`W=9#0%Kl<($ zq>q2o+xwf3nKN`f{duJ>z3alv$z(G5zRH{Y6_v8-HT~=iP{ev_BlR2=uRyP}RFJ9w zn7n{I5jRVa*6J~`pQyG~V1%j~V{=ggj~9UOl1f9#2JU)w0HMR!lf^tOZNf@J@W`bK zbr{pLlCB(HYpn6CJRHd+TDZ8ikW?K>RTXlVF8rtve^hOq0)R;rd+q!H^%Ajt&0@Bo zS`P1bH;$g}+|RD_+fy4G4%O73>>IkgZs_CAQ=_r--p0O~>TP#< zy?1S^uHM$@WnN{d@xA@e$X4L^=+NYXfd0Or-!5VkW@AvFRcdS|hz01O9-LoggSy)- zpcM#?dHSZ>peb^*O~7ClU@!}hL7Ax{@m5N9mMlVWje#ER$A3 zU9?M4&Z#IDnRQ`Oonm9)pV_HuKxq-h_IPx->_k>2vg9xa z@|j(pMJgINi8Ya1WXcUI^de3aZCcbvS`eSeoC!qDoA^WhLEl|t_c7J-{glvAP*39{ z<94M8Kh)d>uf=d@trkOM(0Yb(6R729u zI8C)p=F4B?Tk(th5?G8og=~qkh8IP*`!*Xu_upziIv4B?gL}jLn(U1W{0qPqCVa8) zwOC&NVmQMBH+#D4BW^DvLnsR5^-FdWny|$#B7_jJlBq1b=vKOKjchu6tF3NcxbqC| zJM(L@@Bh)TIQ}qLetH{FaF!D7W{O!WWy6?(nNO&*aDbKaqIBgY3}mX6@Pnn<- zTJWmeM{qy=BAN5zq-ea$5uin#zKu#SD72F!LlNp=S2uqP3|xdAR|@F)T9oDl9r)?wGyW^yGT!UF}agd z0f$8@4zLw+l%}#}tAp$5kSiFfVdv%`;=B`0j=hMMN5^L5_&odw!;I{+etzQI)~=Jm zt-fs=_MgdTbh95Z4E<8Fsqn;j(}TeWqUZN?o%nK_jsAIu-s-kmoLr}2WcOR zqu1hFO~qV}$9n&TAKVq|?0g`)@6rhIuQbeus&~}=U@H_-6N=oXFHhYPC(28n#UO}Ne=A=-Tvz$Df6q8zoI%NR3XvN{PxelfGAjhuE8FZ4{ zb6!Wp{tTbzy1o_C&9Wwe}GP{JiTbwPslUtmj z5s%eWZoE@dd9KMP6#-*=@U}2E?B~bypc^Ntmy)6CMy!uAHkR8P3U|<4K0BN)gS^xk5c-muNWME*Q_tyzX zPW`{`|4DX12A~KEcm)8NX9oHJc-n1~O=uHA9L3+x{C6rI#DicHs2+L|i-aOldI$lj zF)h+Sh>L^}LX<^#X}{L)k6=ZNDf7+hn_^dc~tQtROz9Xn1jYQ zn-4`TbprvJ)>lyB zwK?MxN9qHPu`fqnkSTJG43o2@)cKYr89Uz2wQKb7F>SVRU2-t8j=C(NEZfvE z)b%cP2W6LS73KS9BxHs99d-8;IYZJMzueIp$6tUalNfhTVNFf|W)d-p;-voA`X_1R zd5(9id5D_e{&xk4i07na5EZ$JK-W;97Igy!(@nd$f!vty`v z)^^<8#WnRFm-H<1)-&t9F7aHR6H~R&+oak-hNRf{lC=bKr=1Vq7umn2wh`z2Ig`Lc zbDI>HvyW2f`vI88I2vX#*3O~}a{#HTXlsxBaif^%=CB{u$JWf zfVs%K#w=_O{V<0JsW^?+Z}LO(nCa!P59>?VW)}U}EW)g*ynAKyhGT6_o-<62=m)?6 z|BbGy%lt<+$>}!uO7*E%`Hfk0;r+}SKlFW`ELiCAJh8fFUy zGLPj9D-&xN>lD@}Yzb^z*frP}u|MG`*#?-9Qfe}@2*K$O5GK_)>r!EHiaLg$1zgw2H0gy#ug5q>Aa zCZZv-LR3UFP4tSGlh`Ej67gN)-z3~5sw9?43Q5LD?vPTGnk02a+DCeW^gkILSqs@J z*%xwRa#Q3s$m_`G0O1Y=J%tyFMT+;7Sd_LXeNvvF!lq)SlBCk0s-xcp zwNvUU>JjQq>f6*`Xjo|EY5dS^)AG^UrOl%qr@c+bN#~euloK<+ zw+rrd?%zBVJaTZrDvu72X&&o5j(Pm^H1o{yJmY2LHN)$Iw+;{nc$ayX`SAGE_$=`? z@Ld4_ri7Ey009610O|l*00jU5000020096302TlM0RRD_00000c-q}sOK&4t6~3Jw zUHBGItYzGk)2!jbRaj`>b|=7);mh6Z_|g{_tf|4 ziQ7Bsf!de$ebrJwllEJxu6`r!uc%+EKT7+n>WBA!D($bSKiwNk`)&39&aSlIQQzJ9 zrL@1H_IDQ2e)k8z-}#-iAE@J9Gw09hJG;M?_FvS~-9JeCuj;|>Kc)RQ_08RXar^K0 z)%PClsIJP@LiJUwuGECukxHmQH^8n$+@lFzb2sUSU12>R{b^x=~@}r}VU-@tDR^8ud9jqt(%h8BZ}|LVY<= z(j(V)tgb%9=#(hDrqz1Rn0gc14H0KL7$9ErNL+qSl*9q2L&{J&3w5E9`Q%|Y)3D!rDa{{y? z?7XB=&0CDw^*eEMMlCIWMEb4M_p$7%tt_XxFNpdRb%Vb;P+$sm>^7yboG8ty=YXE) z)LwX2x=;8&qfgDY_wS6_dvCzgV^F*St`o7z49H!}Zbz^w&10K(n1Yu_fdxDQ9u2}K z!45{MtYW`9B>CUAo_E#P@#)bl=H6k{1;;rdswHspX^+$Lra(h;umnw}VtDM5G%fny zft{qm&aGgF+KPDatdhTwy5VU6JB)=Njc!6cH`tpIaoS1f{|v2bL{}XLF6ny|SWh1u zH`FI+=VX6T!4@;3s*#+-KI-5;q5m5BD;n?6`j(Yo?@Rg9l$|EdD3Z4!SBL)hpotUm zVcpxM?^Sw;XeaH&{~krwN>1Tk3Xg8{N+liKWF5lP14yZ<*XL9jOA#}UXets zd*g}5Qa4+ujzg@?$FK(Wh8$L;{Wm86<$RKh6=@qt$m+B`4bi8+uf;Csh&@b+dC~Tp zh$R}&k1;=@al_Rd8SHcF9l+*l9v4d*<*1Q+vbnVbaLxO&ZbvjamK6?_KFu_dOJm^t zCqWd_nKKrt2g3Oj@^eh$$m07QbI813TRi&UPV+SK=FY4x#y~;i#Av>@QSb(_U;r;Q z@@f8cq`ejI*N99+Q^OzzOf4sVWSwJfPHndT3H;iR2QHEp<0jkC5qzu1lG?HDnbJ)CJhqX}$i;H9UKXf`mNq{aloO!Smnb@eF3mG@dh1zt z3W+1c%0!~-t(`dqp9xURkO_MQi(?yzkI^r9tswHpk!7pN;O9+R-9E}yR`OjxfLSSI6Et- zTF*F6V3Qny79O&O6UKJ|osM7=<;-qm^lJ)J2b$;+TDs4#-uqXG9Sdm9#J{oVqU;e1 zsh-$Fi#0}dHK1!N#@a~MM_f$Ac4vY8&1mh?-lKJo zw?^OC;Xr6E1b0&d8z0OhW9b-fY$Oh1xF0u0;*GA(b`sVkD#YT)VNK?HMyP9-2c2#n zZgpZk6G8IZ4z#CNenU)Ix7F5FJm_1R}CwU6PikIdp?zzh~Q6tBu< zq-JA**6XEIM>(W5Yxht?ht-Bz-Cd=dkw)>eqpwbp!m2UzX3(bKzy?X)cmGtFo#u3>;12ZSMTWQI)wKi4GZoRJf}Jb+|c=aP530r}Gf+P zMax_*y`!|W_X4H{zLlDV_vBi54SZH$XyA&U( zibFLU&B_soDQt?{nP@OP%Y|nU=1c2q?D@<-q`y1e?R8h=uY4}~@0>wu8%`l3oZcsa z_nDZ)9_pV;`L5x5WyQf2bSyQOJIz;Df3DWgWJmwx7S?k^@ao&Bsfu!;vj^R=WpRZ5 zLQV^Qh+&gM zP&3*z^o#^1o3efK4VquEuRfp81lf>Ih%;CEvDDqyn0ZrKaV@3Nb^h}si5{~pyyY&w zvK0Q!nr*6W|NOxSWsvd~qUJE#_Rpg416gxEPYZ~L`rY=N zI14h4U&U4LyUuR-29I-ccu(Q(P5isXDdvo9{Wk>NOzN72RP%fTl0!!$FV=3d_?pk!mDKCKaIeIVX z{~68W_m&OF%-psi!x4?MEKP90nqZC&@GkT-%y>zB^}xj$Y>;QxAmKTF0i)Mzsvo15 zIqQg)&r!#iUFw1*^P%zo)Kcz3{ubU684D@%5kb=}P(6nhnu2{p-f8;iF!s&@YxqS+ z1N^?UIwkozD~G+$+>$b*!IK z>lN;lTwTh))H;<5Ww;Oz_JTV?;U$@@4aOy_FdrFd+(ya?kWNCWl<|$ftldw51*G=|D$1(U~rEr5oMpK~H+o zn?7jh7`Q0JO%cVEP)c9=(VqbfWDtY#;H8XmDli$sP=+y_5sYLMqZz|k#xb4=Ok@(1 znZi`2F`XIAWEQiT!(1xG&QUh7ksIveBb(U8b`EfyW8z>N?^(}YiIy04vY##7=7Tsn z$O(S&liwWS6c4z^Y34DX-Bj_2YVPxpXFTQ!Px%!7;7gwKf;0H|!5-f5ir4u0!Z)_E zfB*|wL@jk3qMpSx5Tub1OIXUX@TZrvf+kk7npIroFwLxCE$jHocdl`kb6n>w?<7{@ zBwi9EQIaHCQY2Nt`vBu8@DA$gK71$JL;Q-fc( zFf3e-P+dUNv~o+2rc}AtP^vy}X^Td9K&dMYrAt|;bSsOL#mW-pzkMD{UG3F%XHCEt z3RYKDHu|lSVOqLPTSX{X-n%tE_#Af{HB{R1nDg|OTGfKwLn=jxQ}c013TllOg*;t1p-C7>0n9wB-llER)RfbH~2FHpM3y@a%7|c00C8N`Ue02kJ_TG literal 0 HcmV?d00001 diff --git a/media-processor/test/fixtures/font-1.woff2 b/media-processor/test/fixtures/font-1.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..492d463d90e29712d342073f84832c91b834f976 GIT binary patch literal 75876 zcmafb1yq&Y*7c!N8U*R??vn0Kk#6Y*0qO1z>Fx&U2I-XUl%&a?MkYp%KGT=-E=lnDR=002NJ3<1c%zrnOI0U+eh08r3K;7|WpZ@k5^{U(%g zCY&I30nu*I-r_K+@W{}x0cAM;b7&xJpd?_$0JwNi05}0e&>N@&Xn1dHja~8@G{LX6 zntk-sccGk_%mG`qd1$A~J8v~;AS8R|c+Yzo!L97y=v@glmJp0Q z)4XPnetiC8t*R>`e;AM?C%5dg;lK2PMvi18f{{ROl#jC-tS%%$mOX`vcr z2yY4wa^Ln>;st65{mEu#hfm>GFud0|jfV@APDoo|j!FtbE}EJAid<4dD$4QXsrbrp zTiFmZ&GP;_pI=o+RQ038kGFkkX6&hZzXSK(qFd8(q?VK8B=YDLxOMU#t@Sy`qIV6t zf><-|R99P3cX!Aep2LU}zxX=6#hc!3fc{3Uj4%o@>WQDq$zl-HXC9WwI^4biG~ZLo zgQCIY{v@Q(K3X0|RvZF#_B{rr0;y~{bFn&j&+DbInibJ_m_Z3#`hX+u#g*hX&6s(O ztoseiZTk<)yEJ06hu60yDl>{|SnB?tok!X{?85yVTU_pEN|U8#GP$V_^Tbs7rI98F z`w{$ojrQWpVwq9Rdo6|2C^k543s6*8clE8i!xl~$+^rSU*j;=@f@q^YOEbr2TPe;Y z5|%WpqPM=gXb8CU-*Puwr~8*e3{xFg8a+a*KS>zQ=xidNK!&M>ou<4tH>nZLhxj?8Gl5R)j?jv zbdePF}#eieoVrzc`L*CvJ@7p4tN2W=Ggns57bD;rhnzKt~sJIYK$M}D#17li70 z<+^FB221bE>-#44`@4+yqbZt6^iNMc_$8jtgd3u&s!TGuksF&9Gqzl}^@7{x;>fQ} zs?pg$sJ3;dehSb~GwOn?(&0db)(M%~#A*Mrz9T@Tp=BpbdiecxC3{cz<8CIbIDCprS| z=TBTLo2R@A5ER`)9)fbYur58iVOeI5?F7|v7`-(5_;QMg?mD~~hdNZw&$m16t}}0M zydMmOiJ6Fug^m5?e<P=>JWpIrfm!!ZzwoJzgN+%*BprRGk z!*TUeaPFm?5Y^Ys>OvZM&m22ImN$AO2t7|qNEO6BHR)=`&v{p%T*M!~V!u&l&{>-O z5DdKqPsQ`$QtF`M3-Y2&@AI?vhsTxsRQnTm0bLL>k^&L|6~~AU4NLG6+LBnB;$^|K ziAoH<4vXO^5H%~2<*H&0twXDKttHbUSI18E8V-!TK8*UxVT1%hesP%u*`QPlx*!{T z`@Y^A++&S+a_y%x{5gwdN-RWUXMFVK>FKN68hQq747Utld1ywvDQJefC}=Eb5a61> zS>p6%t5xaugrHL=^n`4x*+T*zJon!=7NR(NQXP~SYQBrtP?bDiloQ`IP zj$c@cZp*9501J~r_x};On9`p3iK_9&py{wa{rcInq~PMY;1ge6UEWk&UHsIjVKt$2 zNq!__z8_;RG_fIJI_Y~9NQv)Mvtf5+5BEBNSD88)W;3sl5FP>Fu6u1{39p`CfmUZS z;6;RT4>^(!%s?y=+o*^S5E@IOK2yPeSO7o840oc4bKl|a995~YhbTc9HWDZm6rqlC zcxy7?r04E^&0sj&?mOL_GRZS?23P2T)iFz&CSO1%S%I23UTTb30#M7=Eq%2_L{^1D z<)?Ux6io7jdq%bb4giA)+`f`Gz0;U?`~JPV);psHd7Y~aY;V^TpSDEaXG)8j~@19g0h8_th?=eYxO(F9taH5Rcc@opg)B5Hf=BJ zW)z!8N#Ov9$|_FTXtDRT6wpy8Q>BlnEqf^6^950P@S2B%LSV1xW2JWUB+^)F(3Q-v9A|naG#At7GGwhRiZGoAfiFc!AhmBO-_jh@j_WGaN&5KQ>T<3 z>S9%?phCljEBt;|;=}M2ilZCOPefig(_pjz$;5QZqy(eG6Fw7QKL3q(QXGUbYx4SZ z_ui$LFPiX>P<^uiM6QTg44-Nld%PyV(M23tUVG4Mi^(0b<(b_nEyUebTP}|g<13tI z22Em1Ef|zubFv@ij$dz) z7`Bs`6hc|QYwp^c9pQM!3BScUy1Y0jcM!A)La1fPeN2~Ej(Y}`69HA({QKp^Ix}Th z6h9j)x*qBb@gnS1_N zR~?rPZlwnohO1ZTE#B1Z)Ubw=&X?gNIEne9%>>e0vEcYG*~2bEr*lfGi z$hF@7{7lE@#y`goOThms7R4k*!CRmnoZl5hkqttO>w7=R9$K3Hq^8lGoy*Q~)4pVL z^Lf+t{_(?eg8SsOqwF#a-*j)G>qnTq+jKILz%E*p(1oJo6_$16f?zrl5vNmQyOD$N zi+Dm|5i9*D=)Fn9$> zGbsiqy&+H%x__=np5GXK?bIb|mWtwJza!5wYBcifwSKz3j+pqBWH&UKe?W{Np1`8Z zZWG*JlPi}h7hs24E9(%ZlCx4osmV$=E3%XAL=J!k_4a!*U$~#M$Eodyv+Phc8A=Uv zxa$9s-mNV*Fly1Ku!)uh!j_3F&o`gsasB*pfri#x0=sy0!;mczC(((yj*&-Gea1R6 z+Wftg{-KPH(tSB?kP$i9fWEOYNa1lc7;_CgxhUVhQw|Yc$rcAF`D*lLFbOx-qW5ft zhGYrt{ZQ2V$ju~~w?FK6!NgR_o`T7-d>)M6Za{rsJYH`g_hFt`uTH!-2K6$uV|lm# z!F*_foSXSRZjtHJj1UM_jd^%sMJ3sd;MHnF)A@K!!ouOB-*kg8g)X5&A%_S9;qEo8 zcvn?vR9U6?xwf)D?N}`PT5B{ZngcR9(-=?Hc-JybD(M@5d#IJ|dk|x=Y_X3<8cZU# z7yt}Qg}n;}walUHb8lqOy;hr2+nKnE0cJI6c*kDk^VLZJZ* zPa+AoE~y0{7rx+I-bKPaK9a=mL=y`|Eyj4EUm_m)R2=hcKawfA;@3RU%Z=t5D&~hj zKj9+5-TD#3Dq8dkO452A;MRJj?oQOTNE;1S#&eylubVGCjI`!7D{<6zt}CWyl@AtF zWIF^OiV~ut3Fa_>LbD2N?FEW_DWNg-K0k%SejaVSsCP;<{ahdf-PVF6>{Cm`c4fU+ zmq*QGsa9bPd08)$DKN1|R2{EN+OQ)Rc`z4@g-(uiGU*ejD2XM>296rWC<<-hhb-tP zFMX320~4& z5HyNU5I77WPKKft#bdPPBX0)>xsSIJ2r_1vfK;5q4fXRwSt=AYxn|F21J)>j_h+KV z1M4h91pDA+oHhJ$Rh%E>Def6;drl&a=w)mE z3|*K8(n@GD)(=flkdyDrD!swGq&@VwdeE0bETpQNNc%POD8_{C-cX~P$!Dg?q4{m^ zT#kf)fmDvE847`5Fs9~un5Mc(&cjS-qB6lU7w^}op_((_ncBND*(-yeuCE`te=@8b zonU5jBh}i%J+DYe=#<>p3FPqN-R1pU-%tc-W{{Gj9Zj6jxMmJQ4Dv#@>Dna$#Uhvs z!{9$p%HsPxM(qCf5&)B$G54vbPfGq(>tr)UxWJdOAGZlj1Q_q;&ny*Io`{9zLq0xu zSJ|hn^Lpy`eQHy1ec)~aaR~OMC zq;sUY;7;EYd>XN$`*qalAPk{?`SBObHn9OkD~gCq&Xcs!y>4D`AVOD^65Hp z5&E-ed0t>};O^_Kg$`8yzCH@sW2yI~k7GQig72plTr}rnh0|NqaKlXvK4Yf8Q!y8$J8Tdk`SQQNo>aTib>w%KsOQ^IxW zoY$w~u)_Zk_0GtlVG+I+)#FkDQwXJkty6oG;56@#&Q4RT4r)l_1Bw8kyD)eUt_RL^;ZQ8P(Ns6 zKfk_qF2RD`;<0Y^TK7}rqsqO}z2m)B;1Z!jDOUvdC}%mRRR*l1+rEXJQRCsU$;aZw zy)0KeXHUQVXX`tKWYr&{^JXJAj$AT9}GleZj`FrT`RY>MLrFB6z@z$CnkH0J zQnD>;RClUyXi6QG(VXvBy7ZuHRN-T@XKbD8b>}^LwKE@TFC}5rQ%VpFiC#<6J);%5 z2<*ePjZ`ESCff{`2<@uj-M81x+v6rL&WceGb-N;wlz6m_D*LYo-lej+TC5&60Ak`s!mu0n+DpNhY9 zbEPbpzO$Ogt4IH+-oD>i)WL>vhwd*HeIuxM5>4Mm7(Uam1uojXkqeCVQ9qFmk$Tb~l@bb|h&iGM(@7^+?-ToEDZJo?JY{SS{kUu!O3z47>D!p%k7A zYXN)#1CvNQO-K|}H$V}-|4C!!ta>Zf&Qt}eP?gL@^vfP2Z{VdZeqg|74Wuw5`3{my z;6Dc-lf~I)?FE?5N2KfwE!Dz^`*t8vz(??Bk7Lr%J@90GRYhU)m&{>)dCQ?qI6tB) zVX9c_II3FFl1c0^vKqvCTb*n9F|Jl8LV_;8>UH|pVahO&{NOz{KDj0VTh0P;z*h(E z_b?OGAIS$9tvir`E?$tCrVlQggt|EY+7eP)X3kU%r9?K{R%LqP!+$gjO|WdEH?P0RY_z9kLEC@eO|H%!eX+@VljgE~{+R z)Of-0-5}+!ZGFf@sIuzLFH?iHKuGL+9hMFN2r~F!_@a`CFbhVj5ycOnOUA3mx=%@b z>TDofYAR86ocI6_A0D;{V=?%-)_%Cls81fim`XsItjDR1MEYKVb(w*7(ez-@@>K%? zP`>Q)VFwKsG^pg@`R7(dU76!&ll>e0^Wq)x)b$L^Kb));Di^V;r>*F+aB?;`G;L|S zD?^YP+WA*gnF0KC|2}C#WIcxDVT_WIW1FTWyb9|d??8SYI8017R>CHPLJSMsYXuQn z3=RQBmRO!L`QoWJWwO=`$rC0wNl>uy7)ahoVVP>qq9k*%GB>ELmz`_3r?F(auJ4qf zmB^^f-l%iALW+_ZCsdD8r{t=txH3r(#gS^HyD@VbQhY>4pFpQnPQ9bA&FSj{`!UJJ++jn7n}DLg)*x=)s42<0mZn;m}0_^Y|-Dk#srmgp(T| zji>eEaow>k@Y?u4gA8ulxVUxx5a9vHBY_jY1nQq5HMs&tTRhaIC8qSB(NlJKPyQ~^ z^v|b@H*Tn?lqCO^hA%mkZ;uWD_a>Cb#PJr+VpPLP=O>9~HT@YPC^Q54_mQrGqo)dF zhu1z6J8r+6dfhhkS>y=r7XRtG6P&)SJ;g$i$t9I_GhFJ_bFLIrfo zu5_;4s*f{MA7YuUj^Q_%*0Ui%y{ot&+aX0;EQ6sfa6f2JVq<(upWIF;Xt#>Sf7h!K zuTe6o&CB$WdREz5OX51;=F#dTV*0M0Ff@>Snh(nDDlQ<%e)_wst!1UuRHmrV;Iif` z=1w(mbT(Kr5Si+1&>)7aFgPF@4>>LI@OAR0ctO8p`WiF|Yn$MKJuW`a_;YhTMK9ZmGK>oyC9(g2dCeuB<)HN}<013#_ zO@RSIB~h?6)_#2Q1miy|05>!U1~?t)3)E+a{>+q|={?VxP76ycr(?Wy?KBX16%`fq zyxJM{gNkC{G7Dpb1e}8X+*e2ARUs9A$c?+3h%j)00{|DGfR_&eR|voh;C7i!4uAoC zee?q72L%AmxJbSR@`I89BpzgAMl|`MA;tKG1w{q&MAIPOa6yu(b^rjCuK)moPbwe) zPVVJu{$`sSjVtTg^etW7Bkpo1KX*f6HSuHDsb~s2Qvcex!a`G%yahX@5jM`u44@X5>q=cgyx8CVwF0*4*znYS}OCnssBslQ(Y z5U^C50dEpCTKIniDHN5sFOw$tOYsz$#C$0zz{_SH_*HPWyBZrmIaiO94}`!1X#f)) z%QC|vYiD9grMiAFNsM3xC~?wXtcj{7?-vU0Vu8Z?{8he0|5e1R=^ZDuqpZ-DnxsmK zr81ZpSeWoor<6wwM>E%S0Q^Xpp#Bg#;3)pRy0*^$d|S<2!<0|}z`;e$KfElh)i>vN zetrLbxUrwF`|HU6ER6obG>(6sQobiTfDlvQU*-F?De4NQyId>i3s?<_r>jt?n1!nr zuw%-A(&U^2QxL{fA?76knP>Cc&-k+@KxYJ?MdfK)2S`*Be6t_^dw(G53fi~ zW(P|qf7bvn?uSbW%|UUcdCZdEO7R;$Lzs$re=zXsE!FykNAz#h7v05|KDL7;8FVwh%NB2R@caOzV*tKwI{lKrC&Fl%F`4}t4cMg$N zSU^sMo0t$fopdB~DC}%7C?u>}U5do4^?A z`Dm0D?yE%QRe8wBH8&<3wFkV#gQFh{N}BJTBhXak*-|ym&BibR4DBn~V0JI~;(xpzHBjMERO1s8EO4B@e{gJm6rGyN#+qBxRS zKVXY+t57K=6Q+eYFEz3Dgw?(UsFDGE$zmy@tJU6D>wKA|sSVG=n(%DBt4d`w?ikrF z*KbX81j!5n{Q?74`6?mn=Cykyt~Jws`jIHI2rEgU%v=PA7_PmhjM+iCY%8Qy_yJxsYmP_>+Yy(;{zRErMJy-!;$CX_aq0zKL*dwJ zYjoAz!z!%s*zOvWL889}r`?tebA@64hq9IX@>Z9}#zTR{!Q*4*H{Yq=xY_l9T7EKsuzV@UZ<_t{t@aFlXkNW;9cNe+pF^`lmw87DRU;H<|a15gfDPj7IPP>+vKlS#N%&zqqe$&E~XGq^QGSBlr{B;QT@nwWLmd_hW~ zOsa5QKU$oLv@FtVKYw>VI7{XeoqxQdBn0a#T3lW*VpQg#O2zq&a0F%=|3%@#GPb5B zz%Vz22&oae3ajG_{lL{~HMMafQoXF8M^9xW$XbF&#S_^8H>5{qXQU72S*=87wJ`v1 z)TTq7&>{yP^snRy;TrdX%0R?MxFn>U-8iJ{72e5dUEmMchd1givI0SbQ7^|LYkvaw zE`!&9f2d}nWY`lb&m0FZis4ROJS@6;7P=Qy@DN_Y6O2_74qMt^UXa13VH{^PZ0Z~2Lp19IyFnDz{#y^XZIsS#Om%} zwP4V3Wsxl3cpD=@b%qT94N-%N)(s(MSVf;t1GMPlm%b=T&C3=dAtnZ;d*J5fECI0;<&qje%0fl?ktWQ0APv$ zC;6j7J(du+Jy<5uWq$mjT^D=vcxwQ18-SIZZS^jmu~ljN_C1{0R?Y#gW3`w!EIyP_ zCIiR+Y}~H7UqPm4HvDhh8#*N~wKfk!hYQ~U{w3wMW{Di%b2|{BFQ!@^bvF_&h z7Th5rkXh;W!)MKZj6qwo;FUD=)GuF`aqBC1 zu?Oobfl|~S@Gjy5usfGw8IT-voI^>fHiRvB1n57x81V z<$C~!5LB8G4cYMMbkt?l3vbNPWkv9Fdij?7nTBl#1M4oqz^uq4)1T45K0h-rUTxcP zlwx4IGQUcS4o>P>3h303%u#~J2(#5V+t#2Hm&rsDxl`H?odK|{Cmya#TaSS|o1zN|YyWc_Z?f1RB%Sk;@meH^Z z+z+Eo6n8szU~Me@e*M;g1ODKKNt7)XYz2*PjTBn7Uikwz{z z7v+@N5M~VsA$rQ)aa3xO@@nd1;JZ6iX~~2Z(SK3I{$^grq0`1=(H~`3-hBK7`->EI zMk*m^2^~iN1zf6!aX{qK8KthdDz#OY>;Ev#xvv<(>(So6r4#kv4k!cXwwxM(1RlVz zcbXtM4hcK}4R54(f1UFCDo$W<4%Jb66!p?XE0HBiBP794Vu{A*zz4I|+)*~6YkawK zC_58ABT)gX@*#U7SNN1ZcW~nR4x;cZ*qy@T(3Te=18z~|I5RpD5cIbK^{ZeTtvcC7 zkM{lB}f&(1azU%AjJq2On?zUjwJD&g6ky!v^1Alv`$)X2%$Q& zi2Gzyx$KX-jQBxcG>jYi$o&%VTUm(V(6C|#=>5l^x&ENf?r$^3cJO~ZIZIQA{Jf6bu$E#;m7M?I zTax|tmP@=m88C0%2^cdGL4hiqsSo7^#8i3f%7pNO@Q(5vob4gW=X#Y|;%2`jlB4b) z%BgG?NDh&?j-TfI0pXE+<$FT?Vs5;muI}X-<_rr+sk(yEo76r*pPZ;Z$>B<$nC!$z zdtri+2z9QZN?QxKPxv!Qw$R$F3khuBsoJo?wiIjU?J?(0gGQ3z#J(}>L)slSU9CFE z&r@!|R||KZN=^PGv>}X^(T^!0X&2<7LrslTa);h47YOgSHPE6c0OnRcNPQ!C43~n& z6ZhT5L@CM+G6MD0iBG`yf-PNm#uAsAQv#%pR^-WXA#p+&)Kg&G(}_tfz*}$NdEaYY zVD))Q?bTF5g-0H5Vn%S+j>zNmlG~y+#=-5=o=+z3J0TrJ6?Vr&e0GQ{J$r;b9IBfl z>p-uX=F-)6bnwS@_Nfi5)N=|ry2@Mvmh%a5H z&)ROkR!QSt=2RsY7gts78Pc7ZG`j(6r3ygb-nl#;WQ8yNz1%{k1+kJx{~St&`E+gC zFyt?-0RYG<_GqqDPMcU!8v7WvD+=~8#U_PXmk#UgbYFCmYaL_j`2>SuuJ;E8SQ;ae zh>+xfgot$S=wO$+O2NX#=G&dRw?nwQ93i;f*o;UgY`gK0W89y)v80AbomL*ei{P^V zQ}uXZBz+F8;5{%wyD>PwIEQN2hnu`h)jP~oRmL)EJ6H(KZ`G-}2|Dq_qDS!>v|$l+ zq=N)gIB(6cT6?~h!G#epfW!LM#|fCf03(EmMf6`dbmU2Ir1UfXLENyx zzLhSSX2OoDVP0rfTTesPibKQ2#HVJ3cP(%gNrUlCO<+G^mhkouAPV2b7Uid$c}o5*f9XSC2wXj4d!I!{qya(v<&GXoQ2 zOk(&qU>hA1q+4sZu#uK07b@%rlrhCHtGZ2E?cS9oE<9C?; zFKRmTtQ7^NBUzdvdHg^#740c=L1NQgr|zK-lQd|mp$G($o*>riWUJ2!M-A!i{pqif zfHKzQ1qzbqk3FrYnvexg5c&VJ>lng^=`T-j^UhLO2g+10s%f0!w_SL$(4V|8OPmHs zjbQU6%f7iL+{CJM4f=}7rMEg?ZM*;A=<)eUc>w&t*7~Ra@h66xlS>i@Y=uK}ed9PC zC8EC@m-bHTVuCQ@C0BA0=Pp)KG4s8`%X+54=xnMCeo?)l2?&14uSP6MKx-iFFE<7J z2LMT+$_DO5SxbO>k?xBmfwE+E8WI%n^SdfBt-?`gzl1eSQlRyMDf3c@K7O}o1PbnB zG2lVKP5u7Bi}LnzP}+)wa?qr2PzZBg#6M=$q~CirWtMtEc%g6IA+Xp#m7kZL|3v(O z`JU;r)*(YY;AxoM$6sZ?Zrv53cdhb`0V=IN`~iZ*YBY_EmW(1rM&)xy^`Fj9F9%PI zpTU6&QTIeg((0#b-@2z894EZ9-8kC`rS&h&qg*>JaQC55K+4s@S&Iq)1a>z{uWYx@ z5Qe+3FWIkxA@ZI#;HLDZ0^7-dsw%&QO5T+AqBx7i|M)$+yXdO^qSLIi*p%yUON z@2BggakyW%w|{x36Y;Ap3@{)CwVeV*`a@6oX@@NK7SQ5CLuFxj?uhT0sJWzOGONY9 zx94&Wbo6L_Tt1?C|8y==B6ZSK-1uG==8-^q11GKXJO2I6s!mMva2H?3zk6xnt_}!4 zg;foZpu4az8R-{oO+m*{=f~h|+gZKtBj4oE>hvOs_eMBnA7#FD!>K;&cl$4a0UXji z9J6c3N;_F%JfKP@jCfvEpi~TNaxv)W{C+S-nkG$p>UEpjWg6PM+M0}*@3bjxCf>T^ zl}iVD{oy11)pq}GIz~>E30}x8aEZrE)jm@+qsak-7U?&a_-QNsL1|`cT6D_KRq=RS z&kuV_*iR<=C^1G~$N^E)M*3r+^YBOj(`NH9~~!C|&swwiDl8G;hHy-HXTg>iK(Gw5kutKLUZ4 z8@faLCum^DDKkkz{3%=iVP;A76LORPTm_TijE*tpga4ALKm8?BXHEP!C_#`Phw%>O z2kLo6yH`MA6x8jd5jG5bp}gIDgRcPpW|9HDj79yhEugkuR{pu_FM0MY7@o`lo5ezi zQRGYV`g}70KnVQvV&-RUC$s_V`t`>X3zNJMO~-XtV05Bw*?_PA;e}R5rfQZU7`(Wb zy1u^DrRe@YO+`u_QiP_MueKtts&-;evf%|CAdP|DuoZ-VnmcZxTqe3kPq}d4(y^_=jNGS zz^~K;`)`u!%!<|k4KVGgWTbFsrzU2;O;3J2+JzJA&PgR)7)RxTwD;m0w4 zfo^=C|7rg&W@c6S#W4Tk;5NS`-k3pD@iMXAjsF{#l*q<@`JQ&xK-*#E533psSTEFF zbsDpP?(m-6Z+JRlulA*3{3&hZh=DW`{IjO?idac6HJP7i+3PPW^@u^WP3FyU{2|)| zANPMYPMmNNS}Ae1Lr!*3wF`$pk+_9GvYH4S0RVKsgH_wIw_kMQNU@&={xAy*=F0(p zzHPDZml5%gZx9I>5E6v&*Tj@G)%J%?URKSW%%8%W7RfF;&wI9Kf32}oMf>{bQ1K{U zH9CEpB93}D+_t_dJh=#~AY_1LaSJ0kq{w!<(n8pqu1O@U%e^1yIV>emub-Vl2!6xx z7#RAsJ}RAUE$W}`Z)sWX**33eFaW@z%T7Q59iv~D9JOX0wZ#4s4?&#)z|!{HvIm-~ z-jFZ#^QQszvaxKlCZO!`l2N|N_&0t9JV#h#uA>*)r}_?PKW+NOKrsb05e2kq%5=2z z2y2k`q$l-PUPyQrR9mHeds33-q}jOZg$5BNwu7byv7U}e)rLdS)MLYw-JPGkbUXe7 z?MF07ymanosfu5$Ga)a?zvTK~D*o@Y{)z}ZYq$bvp!>}x`(l5itC-&}u>gyCCKxc~ zSuCI;3nl&-qxfCRrM0XgXFo`Q3jd-R{~EIxa?+$9=rk9kR*WJ0Ie&mfZTx!xVIj;_ z{=XG*+Gk59`UISJsL4!&r4j{q08Ceq#wuDm4~J!@PU31&|=9lElYBe)%a{f z@{htY2w6~e@brrR*X#n~{}jRK6phkmNhF+HFn=Qj>%PfPcT<@hSP`rmUz?_pO0fQP zz5d;Tkpy~4za93`J@u>Y<8_$PKgK%%@WtICZ$i+;qR=dnD-`>tflmc{DUQYp|4Ofo zpK}j#%zAt`wJ!q=Q9i(=QgfD}NmH^K7^=~sWf8E` z9v`QoJWW>-m@JL$;GAUlkhf2CRK2J&GQyG9fFi_Yz@)>D)4zcSbqHEz@*l+D?#&Vz z0SM^d+F-*qy#pgb9A<8e!hau8`AZHT>Hz@bFx&t_i5SC-C=%XlKj;J!IDc4?!0ht}xtC-P7>Os5h#>(Qk{g;~ zECMkkE_7Q@z=Y280SB=`Apa#+qQ&@v@%3U7*PPk>d?aA^@FA)O)@l7;cDKkqB3Om8 z;Qv?Hpn8!EXw`n+FylXNIOhlo?FE0FmM9xvwPSnsl-vdp6bk{IP_V(cXa?up(nz4~l6C#(%Xn z8njwHVwtHZNhuR^0y!$2jY!_+&S7N{k^j#&liSYDQM>owMMr?>B2y;$MT$o6jfXG5lH^bo(?F}xS0 zSB~R;j0=x5Y^1w3U@_(u48Q%_6Cv7NOSfnBPU(Xu4TBCtQ{ez+Wbi5G?P#XK zkHypVYikl}PU|+g4Sg0a6sAoAVpx5>xpueYPuRkJ&I^z8G$_YP*31;i6!Xmt?xCGE zOUYO8j18=Qd94dGt*WB=v$!_ngn{%YM_IzB66g`fmvg}|O2!-w!|&H5VraHbSTV9D zlZR;L0S>Wjf^402ui#)SpCCzszJ3OlN}{mobpr#Yn@8B61IjVY>MfVt-`s*yGCM_qOIMLmWS1QgOA;} z*k^sm8@?m6j4h-sZ<0*jue6J=e2EIeX~1eWYp)z=7C4u~rGTTeaR4>_wgin*XS1-3 z00A2Q+IC?1pqyQND(@CT;SOVCSKyT>n1QXqmxf5|wD+Pe zFPvhV545a+-j*8`vU=WvEoH>GX5IqOk>%-NtY$@xV`YKGaoV9P%94%dDWjLAnsH zxPcno`ur~sGs(Q06pun#y#wZ@H_$ruL*l@{SkN*{m)c&{5}7uTmK#wX{nMBFT|Z>* zDf!``UjX@5PAJ-Xh`NTJ!YC0{Go33!&Ir3IAlsi#B?kQrKivRTrDmJoJq_B+PE>OB z>NK5i_QPJNT=^hDGDLX8o{dB!au|~GKc_ZhOFZOuw?k6wj2w`EwVdUFfopJn+3%DOY&$r+jVH61=SbSW}zbwAO`fj~7=Yrgu zPoY7`=ZrpO+c}`+MN9JMR&%1#Zt=VC(jSXVbgBE=8WoCgLWcJe86(x z06il|#I@j5CqW>JBB{0y=@j_BM><0QM+o=~Dga=}!77T)HsT<)TuB72QI*gb zNpGC}scEAZy8Bq>aE*B-f|ZHvzSw6Btk8N|rd|nc_^INF<)Y6fruWByMY!;@&eU zKKqG@y{HU(?T-t}!inSo#DKrXxc*|%K(RsS3v#;$_m8H5!C!0P+sw?&=#Y^%E7q@- z^nEoR|2)7YF$L;}R-z^?Qr-ZWjqulsE~L0SvsL@o&wOHgU@gq`*vf?N(GwZ)aeKW( zZyVo|rDT6A)v-q(Vu+-HXO3T~CJMK-e5ZR`da5HCQa3hi7?oBqF6+gEFX~IH#@#yr zUcYT`8Qw^*MXYpCG54VzoVX>o+|3HqBf|JyQW}VcQ`mbo+=c}F_q=9M$vfn55u811a(u%&MI*+VXoZh`SOFTPMh?ABxlY;30Mkh; z@KMEx=3(Kb(I66Pz&8p!g-*@&We77>8 zb{j(Wgh$sNXIf*yQouN~PtQWO2pjN|-^@03^{bI*jQxDrWhsUUMitChH9tNfX@~q7 zLQxr1haLKp;F6nD7*t@gn{`%=2*Z<74wQn^L+3}U@9WZ@RrSqIeQIHaqWo+52=xr! zHJ#X6@n~@h9k;xuju%xw23kCQm@PkpGL>a5u)vfsgX!qy>66i^B4*w+k8gc{f6<90u|_8d3qgP_M8tJBFWO!`e|_1VpX(|!6jJ%Q9&6mfa|x$P;T zrBW+(bM%EC=bprn_gmF_oNF>|wnlw@%$r~B*SlCcft+PSOAL2SYD=Epp@8bYwhZ5QI# zEU+}GJ&TH(cOT!a&72!ChilO>uz72m;?jB=9GycHV6aY9pGq}%OANNcz8zvmb;D~D zx3!`f0lmQV6D_GmW2<-8z;5Sh<_eF^?nV@=&kSAj2vL_#> zt-7W>o30;*WOqJF$`jpRJP1N_9@4)^Z?bc*Ftu~jjA193KWuvH+tU5Gfbh7`%Arr$ zw$j{blzH=3WwkfMAx#FZpd9Dk5r=AK!|np}T(6PQ?$LL7Ri$6$q|GXz$XG~#*nIeq zWX_bS&TU%DfHAmXWXQN#5Zb%M@~9)B*MEOVmb0u=L)`9g5z*-D#I=@-S+T(yBi}BG zpm@?~!{tLIzV_AbNx4O0NPj2JdnSUnOIXd*C`%TXH);VQzV~qv`}VUf>JV#rnG<NL18v^aGlR2x`n{` zsjp3V^4lX^4mLy7pbTV++tuML-l-~AHLIMG;OsnOY5RCKow`S0-csqy_0ID{xh+(bb+Buog+1pHpr0oI)^UNg zz*~JJ$`%9*RQqcRA--vgVZ%gsdqKYh4ed*^RF01vG%85B0e@XfDHUa0XU}fy06K`)Hjmh^o;++ufdquYCT)ZwB(jFdOhqYcNoJMn1!s{w!J_K~&%Ns`w zf;fYW^hZHrew)ky?=WztlMW0T;}<(Vm9-yn)8a&_w^dx`rM}Xy&S8^-$eNR^3Prq> zN4*6-HYIns>Rc49CF! z_~Eiyo=(Uz2V*Amsy^%%t@4{T^MOq4S9f~R1$ZJ54yFk}_P3}y+B8q3m3erH4?O8r zTf)-k)XERZ(gI9J3VWHv!CVljRNpTw$Z6rf@uN+ek1NDbGTfxQii+)Z^uwPrqMA^^ zH5f0(2n}rAnqLfytqQWE`qwp}6D%BC$sgPD5f=7>w?Of@&=s^qBRTYjlDH0t>F!p^ zRh||3+Tb{qa^rs_>cvk%=u!hiWjOli#gG=3#OMTyLN@$n{`+0_SqwVKP!6M}^aZlD zfNiWQX*`H|l96|-6GUJZbRRmOjXI2T1)4H7SkGXm3;J3!DU@?d=BS$5DgkzhMYcu0 zMV3XLMUDlz&KBN%SE*jm+ABmq3vf_&SOIL`@o?+v#jj-A_wT`nJbJ2ZJZVH$xD!G@ zz5mu`nCKXavqXnYL9fvhp^rF|2xgd=vmtEIvnB1(XoE!B6HjY}hTfq4)~3-7ozKpn zatR8~m)feOTlNd(eF%}|`#AK|Z^`Vr>^7DA}w z!w`a1a~Ey5mB*>T>E+ALG)W4*Xlw!HGXmmTByC;%pY!S`QFK0F5-?|L*|nhd&kC2w zfT{l=JxiV>&`eHn-clgoK>JqmeYh1~vH`q|+3_8tTP?`^LNw?X4}3L!p*6|u%XmVQ zYl3iAVTH`jz)a*g3+!bsu2tDXGbD>bb@#b=lY@Zl(zi43=$Z4ZT_MW|k=|RHQ3w1# z08K!$zcN*H#m;L?GAq1w1CU*UR*5&Ktx~q2x~s>D>8u!_dzB29251}TuMDncyPhJ`fOc;7CNwY0~1rYv*yk-sT$VxiWC$o?dGL zdvZDC7|h5>$w-;wGRI}X9G5vRD-u>Dtl=OW6V8?`BRB}hgfn8#lEpbo6g&65hy&L< zd^z9$xjH_KyPFVvpTaQ$`Lf=F8J*ndCimv#6#cPPv3?P`9KIzVW&7%;^n2J_vb)B;rY zcO2bae)p?@uG7Heuxyf`cO4CIeF~*q%~GIj&P{OE>lH0Pt{LkLHrw0~tWTVdPAOXo z+xR?X-(t2*+jhdtL*1TyGWo>ih$r_>rekUjkO|@NAfB&eEAVBW0ipuW!xyd&GUBi6*zmU^g7eoOcx*Sh}b z*M@NDQ$^tQ0L8Xg|~n82w#XXTt#>B(Gk*K#5%Ce%NQKDPVG6)-zG2D*FkmuA&{CX=mq_(km<2 z%I1Hgwzajs$7MIHl@FV4nA8yz9I-duq z5pu@y7MRYyq2Eu3dkA+C@H@Oce<6Q_g|GZ~|Gfc!drJI=IDbvd6EDjrEs3eqa}ek~ z&(L+&f8gh93?_YpQ_V0oK%k_d@}@bwnUFOxjlH%KQf{H7tpwUFg;J?XR4*{W3ilEw z!W@ReD>%Y=*QwL&)o_ar0)cCAjE*UVF2w6pjES%jKP>g}g=LEHB)keA!hMI=Q>r-q zm4rfn21;WBF((!hb%BL%6xxp21616rNdW?oL>Pcy92sF!G|WNTA1fyblZZ0Ki7tC7 z9551@s+R@gVt5}qwh!vL=!AM)SU}Ia>etC9z3AnINr@4oDo3P)H(> zf!jTgS$VaMrxPF@m~Pk9WZ;5Cs(A`h5Ydw{l~yMV8iU7pV0@4Rm{`UD){Uf-vVu%n z*`BkC%n-@;Mrf3dt172?y}-2m(rL04Btkl|rU%hRPe0a+6Ycv7EIQS9%s`_JYiU4^Wk) z`bJoR$R&vSB3}en7AW1j)-bk|ab44)(UgUOfDSus0=k z*-t#PY#wZ|!S1rJ%N@$I@`iF)IKdj6#U<&wV;){X^br%Z6@8NV z&IL985kP?1RdM)X6dDHz5l!-tNsvT{8&RSbzhA5*NrvP}@$;U@eaCLJQd!5IO>T~$CW z#UHAT>W5OKim}&XS70 zU98ks8T7$1U;l-+wOJ5Gnh|7&`Gd;qMja$mSc7yc!0iCI7b+aBLN(7Jfb$O!JSghT z3w~5c)6KQ8p?J}DhDRnFV2+dk(xjYZLV386*4EhJ5gJcP#DP^zVFBFJho9p`u^n2_ z_NvwyUpUohhT(zvkJWaRyR(p*t+jrN^8 zhVJO9^@ZI8YlVe%6cz{lgOMNs0{sfG8-T#!7h&D7L@Ws#)FXNlJ+|jTkkM1+gU$6F zP#jRwi!bc-=rZ8wW}_+X!Y;S3{Y8J*MW7o4o{*b!!ce630gQPwb~eQ@OY0z0MrsD{ z&JF~=v_FUb2lhxHh6GZQ>Ejf1CD4DROKZkvwD<}`n*kqYU7u5!4;$cg&<7Z3A78s3 zFzU((*E0fez0ZX(ssII4q&Qn=`84U$C=;wyYRFEEIkjSU4Z%P8$^u+GTqxt~I5>`t z)8oRpO4ADNR^27Uzu(Xkf)Aw=vBS?3KFN%Y4Jz|z(KL$Yyo>DE8aJ7`14qZP1;S2j zV0BtZc8vr9uW%SM`UZs)nB}vE(lRawh3-!MVa;q~PBBl+vrIN|9E>sD-W@$!X2x7G1eFr6YY@b z1kDw=j~_=caD_Fqme%^>zg)=Lzr>+M>Ye?yG>|~*8ZGnXq8Dg|QI_tZJ+W!Kbnn_B zfz5G43SJBi!`HS`VCLH}550SwhvY;YzBuJT@R0&?sHBilP?3VHZ>nO~1Dnb26=;yvD7g__Ui z@*dg`{3CCU7p6%6RumLfw1<)YQUZ|Yxg2~w@O0Tn8{Xwx&fJ)_{uR(!3g9Nz2nZe1 zj-Ok52)zYl#?g#pCyBTv#0-(5>lJ_;w~6p9#7wM)-Qi$~Zl8g-D*tS-UO^P9|dSJfO72(`8SWPQGP0LdT#ZA)cb852yP@KPA(y+ zHnMW?oPBl!R};V^5BMNcQev}MOF8SN16ZgMmYNl#_0T&UoO`*l60j>McL3T-aJ5IT z(rcu?X+J7ZScI0dtV(h#i>I>b|H1OEh(uNsc}#9uM5QK|luNEn4#u2|BZBS~3*bBe zovGKt0XvazlzU6TDQ{=cfHB~rB4fdXCpt&*z5EXBS`CwgTuF zd43!2x2lzn|Ip~S^$mW}9)Omd%WPc7*>pu3vvR~m|PMBFjYp#Rg5Qu98n@0qpgwB2p*>RG z)nJzY|8ir&rPKc(7VPgHckSUOpgXs_7ahRv6apTDCf*95%i9aL%WyzDNFmo}-6I#n zD`dBISTcRmg|+ahvxNT8F`hzbKe@jkaT@ud$k-{7ntoGC;!G* zWRwb1U_1|ae()AR3*yNV(K6zRC*Ht2C1QEut?|LVNM9OzIQ95Yac}@5xPp89i+xMM z+-@Fd$1aQRhXy|zR3p;g)`GT&ZK&psE52?2Ul*7|lb{V}E9lZa6+l-2x%#xQVeMol z?n)g46QH6b(0N1Q^+`Z^hA8i`@^}IMemchLy-0og-M1>-%@b65hn&}9j+|=zTlqdV|Z4GOJpZij7ynG3? zxgfp}JHeOHhcyAkIx;_ht9tWh(9aVQ3$^8Y`e-+2MTo)Xc*1He!vOUI+^D#$0%TY? z|F=FLv3~*00H{qv4-J?CegDnbcL!!Nrn93jn7+e7Pb#L;?flJ9`Y<45bG-SCX4 zMwt7TK7uI@5x``-h_zD%&auf-3Mjp0&{K8OT?gH3)4jbPbOYK;z_Al*8JL%ehq*2iEaR~4fDt2(Q!bXyqM5yMwB~2i?}Sj zZNcmhF`jxuXG@;LuCOl-#}k>axpP75&Q>ssEtO9ZL0b-`z5|d-c5D@%&5k`VFhoaB zUaK8|{Pg6-Ay0uk9;*eAmx%l(kf%s;x|PeJ3a@*&y1{nx!t4R%ZT=rk#McT-l1fxoMd{yC-1b`xnrZ6B|!3)Os)gNWgM+KK&!diXXWKs1HlU zhW$XzAHZBz9TkU6Va{R$t1>Xv?)W*>oS6n=WpUT$?%eL(KbIpire{Nsv<5cUNhpf2 z{SJ0#o>*|};ig0YNg~95h*(V{c0!yTHb(J*P5uBT_3P~>c1AUa0p^4v!~8UuEudKd zS<(aP4`(u1M8!n-5jI^$1FBF+w@D|JGUqd+pQA=ZlH#W|hYX;-0k`ZuP_{jwYVXKS zwy(0Q+a1G0Z@oQ>{d0}pTZGpU06V^C@X($%nz zb{D)y+y?3ptViS1B8&yz@ui?h3t(YEX)zWpVByA?U$GV{Q-K6RfdoDz1iv*8fhSJQ zBO+UZ$*L!CdvOB5Fb1A`&>Kfq{RvQA$lw^0c})OitMB|kz_<@*SlucN`jPmwvb3;2>_=KTS65gE+2 zk*j??dk?|Gjqxxk4>A7`&;fuF+7AfR$>> zxo&y@C{AzMGmpUW@tU*pv$s1KfO+pQ8LwoJA9CQgNre(|phQd%fq0n=mNm_sFd}l1 zN8Lw4xnY%{5#S({HM%EDtd7pMjJBvu-E9c&WEAGp9B?lKaWP!e{7w@E3mpxa1_mZB&f}JVF<|tl z+OZ#5r1QuGn;Ul?JbCeEdJr?fV@O$%u_kB3_Da#RnP*G_Dw`g8Kl#(Agir3pr`$9? z6~=h;nnz5VLF~4 zMoF=JUna9gAz6G92l#HZcHrZyEeU3S$$T^pkx#pI89(m@K zcdC5y&98uF6R9<~Fa`=JSUb1fUx+s{_`KR2OD8*!X5! zY_$zWK4$4eh@A4YmjJEJzwP~&rA9q*T*o~xMa1lGkYPp{XOd}Vu(ny)?gY$cWftmS zr~BJ^giXuZYoSFJm+j0YmR1E`V|&-Tp@2WjX7kK1spL{hEv@u2$}Fn|Eo@PXiz~i_ z66;;xuI>76?B;Im_U`QN)hNMHPu6m>eiwD~iTJcwYLkNwIqXOeidPCNDJ?6nSh954 z^06p-?!5U6;#1uFWsG>}7Tg`|zdK|n5Q@YgK|(pRDkK_CY5L>Q5iqB^R>oQ%`?fi0 zx;X|O5hR&lMg|VK$YslkITxRhE4K=G@FZ_`w-PLz%xsD5`ebGMN|(h?E(nzG3sS7S z#=O)T5!R^_s}&S$M~P!%spC#7bN0T=TyVv5|LU<;pNH0a?5#bfaQb>(?cQEY6*@|5 z*yT<#HJ`M-T0=b1i>eQP*2muJm@j?MIbZv{5mVjkbtb*-L)<>*Pty-=nL4SzQBaDE zjLwNknimX4d?C23=TH}}MMLFUo!Q0OTo3iq$^FfH{?U2pd*QkF{BZ{!E?nIclY=|dZ{*+_u|2rMcK?os^Z0At;)|4P z={ItDt=m?vZ95LGy~jxNI<|ky@9hol@_n*vxX<_7E$@r{xzYP_zJ>4WMHjnEZ!o|g z-X>hZAH&zbL25=R;4a_7agTR;Co&uibt3!SUHAC7TcT-{s_;o-noLz?mD*^fTDm&UKMVW>{c_tK8tW=O-r}HrV0` zyS(bp4m;{CANYTL9^2P;x4oP`u;AI43ag)3iY86Pla?hb?NeGsI@XY`^`vJ5>D#Q~ z3HrF z`swVA=OMCm_a+duPH%6Lr)B=CaLiyLV1^SBGnz=4@njQwvdv_&%WUl~Bktso#pGC= zpK?mPmg6LuoRdy&IZQ73PYk`{j?A*)f#+{{=@{NRhf3G*(LH?i3_rcYU*8DOKV}&~ zpg{x~La-lz7Q4d!o%ZY+Bp$-b zBRJrNBTgu$IU9nDVJMBj)hOJIAqGF@GLKjV1?Kz8v0&_DVQFJgps^UG8!JGJNLg-1dz!X@6{{*O>NH~!Cs9}16Q@}SUaF~e zm}_@Xf=qkx$AhcIQ5)R$hgN) zD7e=Mq2g+|I_^z2Y?o*}pea)}v}6Ip?8Gy`Iq6J@k2|epc1e%un5<5CUMMOzXZUy* zzHaR8=bd<&bI9k122biWV2!oOtE?V!`0L@(I{|9~N0}fgl^IT?50fRDlVT&a(MIa( zbEZKF=-k2JB{Hc5VToYFj-qVTOElY9wj8GNMCT|_ggf|_;8_dt)z?Cx4cO>_txniE zBicKsi-W6Bc*M><{W!1JCo4Pbc_Z8ShRts`yr8$;EbZmQTV!B;5lPr`#90M2yTO*2S~sGc-TZLz#y+Ec@(uO6=qe^GbQcNP#KLJsx$;3;8FBWmj0jvkAc9|GZ(7~eAKxZ zphg^>8coG_=@5HbNDGAf35k1ttcjrx=8)OVr&fHZ<8e=g|1N*6h{1V4}h^|IO0yD^SEUt6YM^&T{{cXu&>!Q|gZ@U}cc4hM8AC;TRtTgI)bJR{*;n(ufFwOf6Y&DWhWW zqMr7QXFcb6yS(5<_2BwnwO^GHpN6XkeCD7!BabNx;)`MaOJDg~Tll9k;YWR8XPtD@ zOJ8RQu&s@2k&+_{Ll{4s;v`8mhn=fBzoEUG*vB5_$(t{Kfr5oj!SOhr$EyMM9o6wU zzQ<1yurEN>Xq5n=4rPBoLI!xtRqB@q1by=BrVj#M!5M=P)k?S@=4$i|^-5;S>3_p< z`on3|pmu0>rAgJU{3Pta(EpP@*Cv?%8e5q%qgmbpJl{zo=UxTSXSAE3`o8*We~bbr zMn+;+`{%X;PlwA3>4fU|6oO2%Jl67bu8`harBD{PcDZK0op91Ar=4-uIp=jPZ{Z^| zH0p9!^z^lLbY)Zcw*G8QtEyz_C&1*MuqG1ml$^5?`V1K-n~#u5aa>TDSi}mgzCT;} zO%yk3+@xtqY1#5|-d;7 zhQbwtwb0CR3#@cjOFiwN&n^t@b+VtgG&N>aF?PcUIZ1s@mP2{%&vk)XZ_h zRLZs?GG`0{gQ|qtY3W|}^cAc!GbjU6x9ge?RNKMos;{9#H8$<~XiYWOQfqCs*U_;x zy3=un1c~e9S471qb6fu#dv2hIq(RGNZRp5`G%(fTXkd^^pvefWP@=8=8d$N%}6is66?yq zj+6Imr&bf~G&KoaNf8J6vbd;VO`sfg=pc&BAOp37y<7^}JHEn_GUp zAiwhNjAO=1Gp)4KNjJUpGsrNbjALh#X=ZT~WR)!I)ugjxERcUJKo@2qn!9YL%$iqI*&$D|{tG3mG^!?x9 zCpwnMJMVok;iE@9yeYGYmu38fi4rGC>Z^dDkbh5Zn_c!fEN1pO=A3KpmCQ5mO6Qw@ zWeco)6(CV2OC?E>DowUL1q%DtQkh+&MVk(t^|l`GL?=7nXk(4{yzhmJ#EQJynI?V! zJrwZ6Afso3s$!QyCFC>Tt-}ZhzjJcCJJixU!qEFL@*#{(1lz|j@hMDwc5suKFF|r< z=1b|ew2a&MHCuh7|~ zeP4Di#y}u9k;Xc~O z`(&T)vwglB8w&!82f{4+aX)QfKkVmWC$cYfX-nT_(YR_e7l|kQqqLWVF6Ic}T#2ch z_ycKQo#cC-tl9paQ2(r8Ql%*TJ03R1^bFdDG6|nJFoAl}8+NdIL9G3 z*M*US{Z33W>8va@)HlzVqtNs3n>c01a28_9Tyr~|HQBxSzSp{H3XAjlLun8a08$_o zszG(A0cns90SqFDK^zhw5t1N7)8#;)!G`ohd9TE-5W5uc)l5uF1+ec{bT* zm;L8Yi2qBV$T^pkx#o6E*+u4FF#w@!69{Nj(*)@g6OiQu(+=BUs!C!Z(vXmUBn9Ws zqu}xp1C(FvGkG70RaDNWsk|S|J-((6g1wQw|GD4z7z~KnR|~?I3bnDv^=4_m#nPd^ zuao=yMg3I@5N@tGY4Vg=X@ed1s?(&?Io+=7_r$0-KI=PzRLII8PQv6V@=TsSd!>)0 zu3uSyASwB2p2LVash&Xz*;!trs0B$LgRmJHULweO>0ZD|xhbAP@L9>8fN{sXnsRDd zjF=O%<-%K_*>DXE%fR^$oaey#44n4B=?$F5!12MGv#`Fe;Z>O)U_t9~snuoxF1v4jK(GY7^~3Rfy`zfyAxS(IKOHp?M-A z{M@DW=er^R>r?_YG<D)0F?d>=!enA_t=fIHOiSC0CJzO(Efo|*p{ukM}< z!1exFUJ8B>K>s>7pxLNnaQZW0^DoLX;g5X<_-`*FDg!);6(>uR*57P9I#@?Hd)eD2 zckV^eWenH(e?=FUxXJb6o_g(F!=`Pt*I}n!^s8OH{6)bP&xj!~w9&^LYuv&B2P#pI zZj53pc49Yb@Uv9oBLjlUj029j;sy0=U?=-H$t^s{1|H^7wz88ad5#xEQ);4;Nl%XA zmMzjG=M|}X)jO?fpwPD7s7K3ew>8^wJ8c*2AM3RncE^7A&G~}dn@{Ag+r0n(LTIU+ z2j`Odar5H_PQA z1|{rpqXr!qz!vNn=Bg!$H}n{h!=WQCmu_Jf2e@HzJsTG{^Vs*EW!D!zibFQaQ8_1V zq}v7luKl*lnyk}KS(jb59=q=9e)UcHyt;w>RoenJSedf#51e;0o%GG$O}}`j-va+6 z3CU-#J<%#;Vxe}}ehdE@RRx;Mr!^73PXEU9vQsJP{miVlY00SPD6Mrn{RRLv=Y8fg z);HFf?n&QF+(|8)$6NnKI{`?BN-f$4LN0HV(N;>4PIGy3%dVY{i zMGJ?g2yrTI3JH&a71-=K^cWQpI|6POZvj7n)zpzQ&lUpFL-~5ZHU35xcuV|iQU>kl zzJL`L8;@k3P(z37sI#ty8fmmQeUBiW z>CR}zqYov`r+#Hoc>n6XDt0wKQ~nEh|Nf3)laHg+{&OEYqT~$t&+uAzJwwphm-`3i ze!TvDp{D_b%xGiK6$FKnl}NdHtl1((vs7WDrIy=gZ8q$0@=$wR*6pf62m6{UUec^- zGH0u#u4-ziy{+x2vZ@j~-}NqZsVDVZ8+$w=0%-p-9I+doDeY#%@}$!o?B?UN7`Hek zEx<34pd{|~l9kE15?&3kE{lKTd>b{ZNdZj=ZG+&}Tih-S+hI-%7PZsd=0vn2zP%FL zU!#-`No!SVhoyH!cE=QSQcsTMX)r7X* zJKvN~`sw3-7@n59WB6wf+@Ge~qk;ah{6Bp|GcoMd_})zD^#px$teg5q20W6|B+Klm zd}W>G*O)y$VnJLgy`mU4*WUg|FPK&loGRj0g{U@J&~|J3S7rAd?uDbha%C8}Gdk$( zlh69)V7+R3W_^90&*GZoTR(d2k*EIq__zGz-+uO!UmkywS6}?x8)5-LEbaq%c|=77 z$lZj!}KlNtO zC=jh6Y6ZaaLQI9!d-GT4i;y>$O1W0fyFs?)vM--Q1sp46SDyK8Gp{W+bj_x2SkYyF z)y?i#tnR9n{bNhFZ0nBg-L<28wszaUBY@tKz_n4pjj4f~%UQRk0j`e<#>O=|j`5j$ zF&<6Sv5DH>?zS%r1$(8{Yt38od|Z<{Dl%E`s6oJS=ezY0ej za@bL=+O%kNM3X)C*>AlSYHYX7MctNMv25Ln4G)}{vrF1@f4J5BZ+%n?>4ViIV5Q7t zz~1`)foB~NJ*~U9mj}0Ui?&p-zOn7V%Ix!kc0XUu6DVc3sJ=gF5(qE21Ae9Hs~-Yo z+M%zG&$l%oj{*B}l%)6nklB4tJba0KbC1DWUaH%FMXddwLKiu+Y2_T?7vkX6h(rE7M}A8xKt%?9+)uvY1uJl{MBpz{Mo4~NACtI^*U*OM6im9oIbUB&qL{tU9|renRTwYTInR2jr=@O5a)Ie0wzW=Z9E!G0Q|f~CQCSlK^c4E5T_^; zCty|$PUWYO+Ff~A7L`CBUz6P^Y&oatK@v`6Pf>^k-2fhDS)=YvHKp5 zm5~ z-qgmYA+g*LJGPvyK;A`8Azu9pi462|V`Wx%bxdA^c?x)c5R1)*=_cv9op_3PzZ>80 zKVd8PUbeW~5(+5rkl8Latbx@N@DK~-@QYZyaMP5Cnme(m&Wl;-a<>q?vW%Sp>&0Gm z(fBU03Z+D#Nz~EA#6vj@Rfze1PXTzMLr7pQ;8IJyPGzi=3XBdSn+A=TH83gB2+Du2 zwpZb;oRI*eN2M17YdPy}`7_?Tkvs?rD7yzmfiR0F>v<@_G9Wx<8=Tc29+UirJrT3BJZjP0=gevLqKmnYpC+GwFe zm2rd#gu4j$5bh&9KzNAo2(eznz4t2G?67Y zt$l1sednEFCHca{yqjh26mJ^_H!k6{F^ad7glA4wv!m_~4?WG$H*;GgV9`a_MLNJk_I-K|p+Bg#o)y^!SuShe5nCC{A?cA!x*jB2Mvs_N{ z&0CWQWgF+OZp>T8h^^@iXPI>{40JQqGj1Vl?J_3@VFnDyrUw@l7@B)XzwFdb4!t^ zp5J3}5u>SAWIiRA;;QCdZ^Kk;u=|)x#+|A^JzzDdE^JcU9;f5`_o|)o-C+e2-((M{ zK3TUJ(7cNl_!o&$VZ|IoSi`z!UR>}^D{H;8WoQr^oU^~FQEiWDZ@7SxXj#A?OLzW2 zOgtFmfgF}-teNurisurKXt5cnt`U7n*P*$y4=#x&KI&MG$|2wC5#8UCGd1Gavw}Lj ziUQzYO(bDs4JhkyU4z+Tsx|JSFn4Ngz?qx8&N7?m0cb>jz}5tGwgnD&htJuq0Pvdk zOu%q|oP-BR_7JB4+~m)3q@Z&gVUUM>E#Tbjl>qpbLgy}$@x4I!a6Rw|g+7ET5^9L^ zu_D14pC-`xEb@Q>pTH&IT_HLOMuZ?j5n+gMLZeRC&kvWmbB5-pb zjeL+OAWmGlAuc7U=Zo@*Le`)JgHL>=jjCqpih->5ATdwrimlC#T-y)2Cklg;US}&w=0n8vsE!DZJ3( zeXi03m<08^7%0Pa`Q)1?F#l}oyWMek(}E3R2oVu>v73lZ;T$1jCS@{Jors=!{4sJd zGlW!meA~z61UM>CZn!9}w#f4xfhGM+1VNaLumEvGzZ$$UCAi&&Lzn6bZ)TWl81!Q0 z=L{uxEod`7|2t7IJjyo_BcAZ1Ih@GD0Q0aBo;82dh^ z6yY~=qH3EA#TjXoC$}D1Gl*Se$~f&yO;;n4%1m{0nN$KD6xel!3LkGe={X?B5Hpw$ z?F+ZqY8)#OslUl?XJ%r`s-xHoD z%L7uyDWo(216d|&J|uFk0#Nc{6&?d*_YG3=>?nZF{piBq&s%5YM`CF|mKtI#yBo4V zfq68hC_uaj$r7vGL5Q@O+6yRBUcwNp7?0)gwCJ=RtmA?wczt)B)V{0I1UTCsVw}^N z6*fnSWVVuyPyBxw!*d@grl;}7Jk{3=ptEp&c{uVsmKtI#n|!zzN6N%R^Ww%l%S6ra;L?uxr7tkWo zIuSvh$5KO#<%^n+N$`yh zDcA^y#50A%QtH*D%lz)Kt!$cr8PFOnc|LDm#rVMLL%k+}AxH>bcIt#+E;sq{&0SAnMV&FjFr9wN0aVhcC4! zjJwKov0A7JBCM)yNOwNm+`PYwgvbP-GAE4g7i<2T{EGhJ1HY9a?Rvo*-IVwH(qbX4 z*%tqK^*7E(;>a+F*#j*JkuO_TBFRl=PFn^JQeuck6+*%A`(4!K1@Zs1QH0VIFDeC6 zWRnJUB@hu~ev`G0AB!)hczleXU0O$sJufEt)gX(3jn9N)|KJAwDyy@E=%chNtlYNC zX9FruyozB4#%3V>DgvH04;{eqSvWBB+qngUuU3dKa(lhE_AMN#{&I`kI^Bb;n^4T7 zGj*C26G%qEf++DX4T8?spOH4yJOmG?9W*saV(Rv??MzsvjV@_TFs)12TPq%js z+B4SemVXVBgz}4`dq{cygxsr0AD2+XR6G>}kt%ynBp&wfE^-1P=Ooc>QDdivJdhK@ zArzVco(77!XLFgcyeYQ_i>yPmI1o!rO$#0uoz!QLXaSn;Bu`J-(`@YQn&DqL71|YM zZC^8YJ_6)y0*R4u&aOl{ui`rRBI7LyA`!x#qorI`tMNHIL(-5)xE3EbTY#u1ZF-Tl zJ`?(Sg5du#L@W@LPRJMIgEEAcvm8I6TZ;lex6Pbz3dE}vo;bI zFuD>Tr;w*@$+L@gaDw-MTc($Zz9@rw0`@4|brlBf*v*QXx#Tfb4wdI2?}MZ8Ua&?i z2?1Iy)M2W+eQ7c_pyoW!^JdTq#khWR&4UJN9yY_yjeNsz6}y56M3O5($ZlhW8|*x3 z-w=zOUeZn_a2ABN`NRskVk3rNGUT0hm14_&D*gy;EWw>fo4kGcfJtTYJY4Cg`ljXKt#7 zGrToQUT?aMk7+y*_gc0>2MN#H!PzK3G2E2*nSNtB=IYTa94>3mctVR!jAiz@GaxEQ zFozWv@!de;hi%YAvaAzwCo4c(U zB=UV74HW87qoQA+2aRSBTSe{EB<*^ZR?msptE%lGnY8XD2_zIo|GC^vIl>Ymmp8I9BU+u7-y=;$B&RRikRP&DT`N+xKb(FvkX*$XR61 zJDbGes_mEicFHo)`vcG_D9aUs2dM%&AX9yfh}fni9S#<%MBlPc&%@zG#X}S)=G~Nc zeM!u-md$mITqp);WFba_Nb|1#cm;N>@}KLAz@Y7Y8cCGXLpm%P#aIe1j0z@{lHJR< zNom7CLpDGNcU~pepPlxh9WP*)DNd2BpnCMf(KebsJkCEKAQiH##gY)jzCWe^wDN_4 z@A{!w)J%rWlw$DoNbvejG|zp59d_W*K9@B#E+0`_z*-bn1cb1*n(Z@)v}!HstLf;0 z1twMvqK3V7NoLaMH4SZB*oV&y;siDIAo`Uk))IDSfo+pesj-e*++Gf*>R!tl9SR{k zECG0OBCTw~840FU%d`=6(>Ztj0%Hkcq?Z#6*o#y$wC;SPpL+h%?hMK|9sbU=Ur&4c z@Dimz<1_2)39$WJg%i?$7^r~CSQvBS7CI~?;tRq_FltDPS`Yj`fiY6b&YLi1myBZ& z5q-7}4qUqLJt%xZ#U|o{^Mp;uBCShgLVH*v4TM!3NO4{9N2<-)a{E<(Bz1N?fu#hc zIR{I~TB5(Ol)!XwHjia`V;YoxQ@kZ-wam>Vlxn36}3Kc5qMzkI~gAi^I zFMQ(G^%2k46wl2%gqm{hDIn2fp+S!K$hwQ#3&iPLh?%^ zhFv*U{mj_A&i+FOVeCARBH3mEtYG~UFp2s&?{Q4;L*c_jG1f7+!R?ACSUibPdOJ6b zojzzIkz**bt>ui}1mAYy0-Fa1F!;@M`{N^Q2Bk54VrdL0_K7nTnpFU1@G(rB+PRfL z#FvSCL(3fz99GDyZqJ?RZTDbW<9osm_Q*|EMTAv!LVyPr<^i|@B?zS&o$2&GLqG*p zcLF-2<$#J~Ai>NFWOObb;`93l@qlPdX2T~kCM8OeVF!w`(jZ$z1w+1e(b+2ON*#%| zW02F}PiN?Dn_4^-(e3jS{kTu zxkJ);cazCFa-PJ#5a&XkG2cxv?My?fS~^a*u$91aYg;PNYRg-Rujrn1fR_CnqEud{ zTOJ{hiDqQz{^gyyo@2!(a8`_|8(l(s^9PiCg$u#A_;8n)ci&aa!bc*CnE@x>@e0@A zE-_~3->R-bu4b{(NZ@0aN$#WW8MAR)n}kHulbY3qd%(w}!P9;_GKs_Vh0DObEtjM9 z**PfwKB{nq!$wssc#)8jbSdWCVooU4jwJW6vu%CWg?vbGMW?f1%S+6A%Y7_bgewU# zx``!|u&~Ih2qNekk=E?Spx?Y9ZkBpe3q-wr&Zo-7$0v^Vor6c5P=2wwZFx(<1UJQD z1x@fgMWwZ0`&dVlfRS$j7rlA>L&-l%o)6c+*B0r12Z8X{#zOLFmpCYgkTR14+ORti z^LTLZ8}uNa&7V53^ZcE=GpCBjsPTd8Qh!p$;d{l&gF!?yBrp2^LawC zew5d{A)7cpy&J7XDXjkMpFq_HL1UY;Clbfc!DNQuUqG78s;~)c4>mk(^(|S z9``#&2_ObU%_lTDvcX_9?O-M}OwX3-x$Ps*NYFIXY5FRATP{bOz6<|$JEs;jTE5_* znBLg>NrobRf(Ys6n6%u2#)JLU6$SH7@Sx;?#D}DSF#Xp@}=%S!%1yLaZD9 z4ptfj%6ph2b);^#CrVX1Ypu>QHauJV%z0K}GYQTHG8hvXd1akv4E5x$tZAg8^)gvw z$et9Pe(DgqCrZ7K*dy+MT?>J5FLUp*#LM1iPyb}hsduBDj>7R)#~TIG)=8X(c~dG@ zSxboyA$Jy|wvLj-KE8xV%o2URFV5y55psQCLrW_;Qe}!ZzltG4KWp9+lFyn3Gz=fl z*&#t)$g|%8;n^m^r9kiRB6B_3E}T#w$PwB$qw*s*#td;tk)<_Ap}pitIeOwgBPt`8 zydcZs$XRs1afNr$Van^0$3-vqJ*g#ZzeJaF=`CW90gi~@ATO%V@tLw+!4zbHv&*_5 ztBYVWM9Ke6vv6V*)>J!=RJhgVwLUd+qRDXflRWOF2jw|FSn7f<(ZsPTaBIX$*rVba ze>b^&4qO#DxY}h#$)3cM=82AbK@leWMaOAu)_&k41`t6rV&w0Vf0BG3pBmWn1fv$I zcy6tD3ikAc5s(Z$CEtog^w2u3)Ty!b8t* zFzHOlWPy7nN!azx;gDWnNGB+}X{&>HUni@4P#G#c9DZBa&u?jf;_H*ie?FCBMfD$% z3ogM0Juf>9Ii%(F31z9*rkL-f0iL9r-aBKy`u#lARwv$yR6;tf47nxFtJipj3Xl#h zZ8K7o5xA9aGU%MG?*L!CWI(FxgP^QrgW+o+j{@UnKuh4wb!#^(6X60`a*=NubUWX! zGwGVXnZAyS?G&i(=jU&4wlX>If32I%=`p9wseephi+VfZQR&S+sTyE~ps_rom%n%7 z!Lpv9LG_SA9P$kA%?Fm5-kL#l!Rd(;;8s!TN#R3@DUi6ltRQA(7XvDX%Q zG@1gY%0rMtbu-90$pT0Gs{DnNXJKZnc_u-4EKE&7%PyHIm}~$ zftQ@YNzEika$lGvG{T9H)s?~>mL*D&t0u<1DQ5`=j`b3R!|YL;EGfft$%N$-jgnDD zt;RmN5VX+V$J4x9v?vJ;zQNJ_sY{uok-u*%8ecW+H_!hNVLd*DJ)3)uB!cW zsUW}w*qxQ#Z61btFu=i{OXf+!olw(SfvFSSlQiD|8e^KoF#B&FDCSo+@>DN@nfkWS zXp)UzCy_Md`f-XKT}p=U@MUKFgIfhAmX2i#7g)E#v}XZ6G9g2;h0*ZIWHjb9pO`=| zHBi|4@{l<%vuR&xYp2v9%gO!E*&Dv?cC@`Au+6F5LNb4iO!Ps+-oe^3gT(IG^(ipD zJxk?>q-xm9j79w)yGWmKL{;3$LPt=sw^?-sF}CAKE(Im{pQZ6$vR5upjf5gc?&h9@ z&PR!pD>cBZ(4eo-c${sy*S~m5t*34@sD~PoYNkJdTP=T$7EW#@uk0R+z&iXeWquNv zTICDuq*dDLBfaAF*_H|NhDS6KeMyci>~myU=rv(T{|iZ)(QS{%7q|}pHOGp|lBpAc zkhFcdeOowW*u37EO2+>~yt<;<7{LD+SO33B80Se4BWwnv-+AcVUfl7&*Y^LWG8f&E zf@Uq^tCfO9`+l;bf@j>`nfZ?$8p3FWbleb97s|=uNkD$D&Z$8%K-Nwa^3@T~5V{^$ z{_zh#?8=^>itpbt6-Oq)D;{F-)It2m__F!D3#`qoA=bO>Mm@YXKZzIS{?MyVn`jw)~X?=wE_8=D*y5^E5`D$<(fOUt9tZr_?b@NiA}FI?OU4`=Yy_<{Qg-= ztMjkj>9XZ!;mK{o)ZUnu>75>@4mu8O*}V-ryr3iW5>Ccit2T1v0|IHId;oO=)o~%y z;Y8>Bf3wU!od0BMPw_hi2glLdJqKH9z}Z}H6EYo~E~Xk)CQA)*j-%v>6>JcD-tngHP_4^eyQIS0%K-k!)E+9 zeroo<+K&1fHmnRo0)D4o!M`{`noT!%C?UUw@eGlzvX3tZtpxkRkA zj1zP@bnWB1hCqg(K(O&qEQc5au8-n}pfY-|$Dvh>EZk3_;^C~AEZo+2-%#Z9qgd7m z1;O4*D+YX}1;sqxfff@O2~&kEIIo>nVJDcQkZ5yTt&S34P7~Sn{Es^@-%@wiCTJww zq)|(ud+cdB`VW;Lsd^YghEBv7_tlBjMVZV?h<-kio`ia*czrrG6hi3klQh5ufib34 zS=1W)ntH#Ia3`5-JY*3hp9#vT8=C9=TH3THC@T$q{R5{6*d2>c9$kFyj_gE=Wn#r% zEgg=&p?VBZ$(_$1R@B9fi^ZB>p8tZ~eS)y~EphSaqlMdN*YDA;%Z=aLLEkW=+R&6N zG3jJ7qfQct+MAViGAp6k=d5`?{jt`p-mqb6ukiM8B865-T(!i-d}M&m%HSvekP-wG zV$MG?!_j`+Qs}<85Pz`#Ig?mEhR~+(HEb33;myg= zg?dPNGgn`%Zwc(#7N68e@Vv@}C*5Q$)N2=9e-GZKs-QF^?|esmOb9dMU8Tio0fs`7 z&?)eQqO=k>b!5yeUOjz;-`y=92#U^6q2ze|GIy;MJoB3$}jTh95efV-Qmhu?@0C zURSfj%;M<;z~EUxxv{|1;cP!V+Ut}`oxLkFF1ge-!=8I1t9+}wvTUPPwYj?5KMotU z{_-srkZY;n%co?iw!ExNS*{}U>8DWu&%(BSM|SLp6Kn%5MkE-OexEPu8XX%6(J>{4 zSkSip7Ows89~-gpHFN!N$ME_B*<#43c`Uv+e%v=of*EpUX7aN}%h{DV3pv6-*V6_$ znHvo9>uZ7{a)N<^<$&1)MtsOO2Mc0lMel-67YwlpU z4QQQEDK^(uX_gmtWsMWsT+5E4H+d3@TwCF<(gpNvMILM2=4On)H9~vdLmUGDCU^2* z64n;i*1e?8T?K=BF>bKwp~C^x4Fuu_3V9~FJ@daQn|jAimJtsCu&(k`Gb_J4D#>D3 zzRl&OHe*rGcf+^amWYv={Cav+b4#jDBn99XmU?HWM!YRTd{?ykV0M%pEoU-t62(aF z^99vUh)egQej2~B*FfVrx3-9UKC*c9kIKIEq=o#awYpDdXm2&KK!%y;X{~Lqc2ltS zD5Y53sb1laMUSmF>VZ~o2J1U=^F{vWZ8Y%)( z3HBqvuil&pDN(y^Db{oK&1HQ=dDmr48RA)KBQzO}n$hRLk-Q@%@mS|oeLl6sEBi<( zJp=gJACvmDNrB!7J%EISh84;d?yHQpZaw*L+Njxut)tYp2K-zR|1N3JV4rVUm7H)x z>tAx7IH2eJD&)AA-%_aL6`D^l1m`WO+dVWc&%K(>fcS)jJtLOUn;QQBkzX>Ul?MW? zW?Jjv1&A#5g;DufXm2QUgHY}l1S_0-@4ZLuJ3GdjDl0=-$MBbHl}U_RhoiRQG_{r3 zKiW`R8+`v@bM08&vf6>V^L5oFPG;Iof2qe68oDHI!?C&WS+e8RG(UT+KemILg~#nC zrME5UlHdR4?fY0TywEmO>yiuIza5!yas4mJpANfo(9|@%!QXW-A0S_Gu4ggyb!(;g zV&=1ql$x`3pwd)_MJASbddWKfer}k)N#4s6R1~KCXRf)HonuX@BMD)A1&`ajF#1W{THL3j zX26}wm^&P{T);!!&dk)FK9`*FPiFFc72i&k)8xW<8ftFS)cWzGQ!`!NGgC02>?Ttb zo=j#gZ$6BD3qLMgf3k40V*`9Jr@w0>?8b$iuh#=w0oywwYa>rwTsW^P&fXQ0wM$uy z0?fYG-QV7S^}eECa--o|_4?Kj9B>9pIShUM&|sopp`A=w+~ha)GEI;(Kkxa6mE8Tg$e3{ca|P$ zKD97g54JMX@VK<1&A{AND6AzHmV<7i)B`AU!E$mCEihYeUQWg2zqxS)2q62%XLk6}py_X(6JerzP>@M5?+_Wnk zHLZ^ujr)H~!2vz*7i)2s1A)1>j$7A?gLU~i7NWnmvW@)B8@5lqT2Is7u!Jc&z`6(2<-{xLJhGa9Q(1cLH@j(u?09ag@T6UQl}dGIJc$(&?NDr4Yv z`7(K6luiHwvlr0IwJZo2|_sSM+6)k=%esGm8c!@fLfS*C3&%hC9qE&EX zKsP`yD|60-=MfUfad2@~W-+)rHVzAU$F0F7=e`WfE`nsoWo3fFS=ko3Li|#03dY{g ztG!9sFnG&Zq-=Ot(ptq%&Hfq(sq43jLPG((0tS09xfK>8X*bo@b$DE_qu_~qfu65$ zBmv?zu^mBE*~J0|S1gKIgHP{B{Vg?zh0oH)fBZJ()+e+L8`M`}S&bJsV`*r$aWfCtv_d{uK;^geB}DEYefbzqBHDJ?73rK zidpb#gc=hX_G~iK(BL`$9X-9wG}fbOHM-h$!kKAe&Z_DI>yH0k2(R0N<>KU|1F2C9 zt??${<1F|Mt_6BY_v#nk{n-$bKL8q>VgXYsd3jhKliL9YU*56}<}48VFx+A%wBf0N zLsga+_@@~-sH+{`xeoXgLf zZj(woC?sLAjL0XG2z=Q{-5=NKPfN9)4&3uC;^t^QUb+Xo1FBV(+N;hW|G4cS1A8}) z_vra8Ed^>#T>-mFl#~-?@;ULy_g2^9KNl+8+4&oFvCh}mIk}LeI%U4V9x_{bpHueh zgbJ!q#v)PKy-|FC`p!hp4(7MLy&3()(rJ-K-M@UpX*sO=_7`D!e- z@cA-8R{tx%&@ajFhHnJbr(d3XX&nVb<3AU^Ef z!#<+A>vDt&=w9rd#SB7&XZ&LHrej7i8cZ;)>xN_oiSAN>EaCtq&4oe-&}y3tC4I%l zLSv7jx`0z9f=f)6dP443tb0^hotF3K@nYv9Tm0cawx5-1eWs`(BH&E^b(+Q{IH9D3%!8YzKVlQC}EL2a2Dx3-ipLM+Z z?1t{7$2az5zWc1R>Df)Y+(LbK^OxhR8oUjMJPoi9o<%p<_gy;r$1(PbyV-N((#e-C z0UqtyYhFV&&uv2&S7yL+e-n13llmn=Boh81CNsd_T|O)-ytTuvC$-n0%r&hrS;_c_?@7wA`Y9 z;QvxfKY3bLF1P_+Qg1xPD_fKvAd|hf{%mSBEvNI-Nj=5S_sYg{R%X0IScJG~Pc+HOX9K9u42}KBraPFl=?g=HRA;+b>=@(TrUgKP;n5JubH}MGKV+DR)N9%wTtZW#kNa(My=@U6s1$l zM?rnyicR7qaLHSfYd{*l%YiN;1Ba(h&!JxxJ(wDWBhgTAve8T7TrD57)VUP7(v?ar zl{8s;dLkK}VMMRcqD{qQ>V@KGs4!N$nGE2zTMk77!+Wr9v)!XXkl`%}2}_mLNiIbp zny#oK>0)8wyhkh~ z(iIi>f3NJSMBK6lrX1crGb{`7@YT9XgDGE~VL~{Gw=;5jGa;K$7T3f;8}1#dTpsxz z>0Ox)Bbx6%9wa|(@c9rA1kkz3ZBY-a{_k+&%VZEdxy=bP9RnLQPlmTG;er?R_~b($g@uyfitvb!n=&5+zX{ zUdHRq7$#4p)%t9f7idO3Rti#)|DI`?A01`CGlo7Rm!ncB9%HT%1++d>`>UT@l%Tay z*2ol*KX11Q{Mj&H2-Bk|D;yh?M~7$zu~@)|<93b0vi1!8L&eMs}0kdbkwm2aFX++gXDu>-u_Zq)qHXbaIgi2yBnyBs+hl*pX z4@%eHE)KmFH3NJ~BCnFufr)f(V)BazRt(RDpR|6;#Cu(P_qVxMxjXG9 zt&;PMzL7*=0slUxsjsH9wWZvU?%a_9PLCpsatLsyA{TgLW=It}8=edDZ0LWdu0Hxq z!h71!8@NqgO$YvM#d%t%`?R}VkHZ3DmgNq7@cKext#%W=dUlhvHjzjX`LT%vtq+CN z8zUc)x84Q59kPD8e`Yxt&~q16Ln2?=my5N8gqMe`?6yTfsnfx8IK=>&FaZdl zm=(;y3i~d_)j(u`oYI=!NpI?37U6Vz(b>>)onA530|YQ09@Dao@__;WZ8Dt@St%ft ztC5h72_+hOZUtW}KUpauT=8-9zI)^U|IplWoln{L(e9jkbW^|}F$vU2eY=o5U>~1d z?%_JId(6x2xq(MbFRMiDJ1nm{ap*yIfsfEwcK41Q2TMs6t$v3sycO-Q9wdJT)|D$) znCzRVBJx3NQ+=DyZfo<`H#zM-$AZHwlhf}VwK2jd5>7~HUi9E%cXD1uJwuDl(e?Q_ zNJ2KEq)7rgkBVJ9ToE~|rqgXLVC#)jA05xXqVL9gOfsp-Lu=r!L2jh24;wA16)C%| z#N@H>T{KNpi9%+WENip2;q>P~$ogl@1KodgJ>?P?Wa;LZl|`0FRu*w?24xF{`Ihv z%Mo0Q;XIQ6Cf;S0NUSaj58};XMvu zVcSSQJCK_(>w6M$mH5bzInmzO-CZ43wx44}A?ujhlJykkNffU7mPa!>Z6YNl*HxC* zWGa*&8gB+?#_9q5{reMh{#*O{gn4D0CO#PnhWvfD0&c6db>F2Jye^~Byunb4AsD?5 zJVT|2`9XWIe$2cz> z8)cH+_4PQIaJ3RH^<+e3$7*p0<7SeyFcJBWNjI#Zu0cH2Qd~wvzKJK%g+aV|e6W=7 zZ>GvEwM8@!-7~j+R2e*|?{dhtVKN?AHLcIotO!igM9g2g$U9ijP(#BnQN$%IVl)>G ztY4xXkSJFLam?25S^PDuYjiZxPv{HDc z6-^f}Lo>~r2VgjYfFnNTD4UlID=Z-~Y(>Jt(z-l_X+SPstZP+m86h!kXg3DsK@noG z*cc&n0n(+@xL1*9%T_m)hcvddLs;7yMm?7XSY=M|NuIL6yrI0W*AD`70Z(}{IESvx zGmd%cdeDy$@W*KMpK!z@^bxWHiK-$It58UX2(dCTy|%wyyt!MfGxrI>@+}2T*U`l+ zk3Bv#dM<`mTtdxr6ibTA8W@UUJOqy}z@kggm=YmS4t?Y#PYod##(1FgAy-}?;f2$K z%pVvuV9)H_#*KFLU}1MVovgLlaL_?D?&8=#!Gh7asXR55M~Ivt!_wL2YAPjW-}kwS z$0||d$IIg(gH35?+J3Hz#B_2MtZWo62~HxqmXUd6R}F^}Tk9KAXB+2=qN==j5*nXG z0BSduH6%ph{>Gd2STKE-oZ=W)JL=lXjC3T|%?}-TSKdgI zc{^lK@vO8fl|rVGNl7GRWN1YOrhZ#LEO8~6A<7hrRt%lOG}9D)^gJeCR8pqG)Q+jx@8qnO*hsfL3 zIOH{#S73wp2z4CWhSHv%DxHlb(4P!WV#sOcA#Xzq=Fi;RKQYRI(C0}`G}=j0B|@=B zW{v+>*?}tVgi8>PsHco6j_oJ zk_Rw(f-xv42KNsZ`;X9KA*t&XZ40F*dDgbJtNvwO zb~A(kY@hJk*<7_R_--b7`bz8S@=`F<^5owJ>v^ht`-H!39(5g_`!)*ob}sxnDwTjm zp*VQuoD2~dXesN4q1OrK!Na1XF!VwNAm%BYRJ- z^kO8wIjrul6WR7IHYSI97k33Pq}VRK#2^Fdcl*-ETyXF ztn!K5Fu4N6P_}`B`EIqQSb@YsWggzTQc1`+JYB1oeC2CtD2IW5%ENZ8!!SyVl~N<~ zZX?H^2UzT5|16D|m%44LGzRp2mh*~+j7P)p zj=@&vYTN|jC+WFUgs5POFH6BLDzxx5M$q-t)HCU6pC_kYmn0~)oNce%DGk$#1(e;E z+z-B1#T3!O1Z=Q2S;TM`7!1fWM4IGapb|p26)DxBMKnk(^#vP;XJ_0?!HM4d+OnuD zP+jgZaZH$Jm8QZynMgoR@H`XB(-g6|E~z3gXIiQ->7S;gekyGJaDWecpO9EQP5rqY*V&2HGGjHd0Q7=w3~k z?YUEtlizR8lOv$zNWv}396FR_uVT?sm=X_$BE}J|vXqd9rFk6l!c!eu;2aWUNEcYX z>sZ8GHduxR>W+Uh>-v0f!P4X4(lEgsuAa~JLlv}UaLs?U*X6Oz{X2;A!&dA;=nm}) zH7U~6PQy)#&iCU{hhaIpky+2a?qeXhBkjPb`I?YluaS%;4yCibN=S(8o}QmA5%;wg zOATf0Y;G@S+e|LGSfh*%WvUTWiXoRGXX_fu8yXNTFjxx$u>^r`l7LAMI{b^2iACD>BlBc=?d2$poLx%Gv;jZuZT1vBO8j&jTBxwj>i9-pox8?v7pf`$UG%S*Iv=oKDxMJW@5H2 zA29}jjv){uu$&R$#v>#R4yz%BURazaW9A5xE~5~T`llLi>HwGZ0YBoqm`n+lbU7CY z{IKvK@zJzyb{|_N3HfMLK7oKkp>Z@IZAI3C=IaWJs46om4`^Bn+Pp4OPDVo)@s?=+ zUe-yTu8|~sNx-AfcmffRM&XNrz=yB<>NO&fUM-O%h1XZZPy`0>V>6)>ogou5V}p1^ z(=hTQh`z=vRyAPQI6dMA3;BMKhp!3?45aU9mT3sIqouHlKxfLNKhToCqh+6L4vaW| zTT%NPlwG@?FdVz1W_s-P_1Qa;004?C6om)sK|6io#?lm=KhT^qSrKRlt@v<`r2)~y z5vWd?d_1N#IVJ-F#rHKqMy29Z|A68(qe7!LQF|rA;@#Nl4JUld<9{3aMV=6_ta?YQK)0u01ktQ~PuT^(!;4-nAGf z9z>)V2kef}6p1fQb#1GWOYn*I;b2wC_gC)as!efrOsN3Gyk?2_dOemJCa1@1zD3s3 zYYf)M#@}Ziueq1a>h+quUaRjKvmJRKg}#r>y^luSSF z5?yOURg}HbJ{fCtsQeGaf`hXW?qr)*V(+N%L^+O8lh@97iQ=ErlYX)m3Zv+cXqK{; z%IZLyg(1b{jqNn;l|ec_%F`$##HW6@|LI(34%^0^Ji`{Hy}T5DvJHb=FTzw|!f)GrBz;o1^)qWR<9 z&DA+u$jq|BwTkXXBeKG7aD}fTX>Y>c@o%0!W3j#TO_`(4zjfz-8nJepfixkBfR~XO z&}-RKTUq_KSF4f|OId==lW9!0rHU$Zx0n+x6ZF^aVo@)UiLc(g$vS$dbt`Lqb_{&b zekofRqOXnuL}7N1T9Bi4#<~;JUo(Atnt>U7Dcv8uVdKvAzY)r_ipsOqis0&M z*oIXKuCR-8ELt2jui7&OOeDt#3( z`XPJOXC)}vaAX2`ebo1~s^(pSB1h=1pjKFom$b9UGwLpjiBqE_x(Dty+3CqXZ&U+K z@dbCNAA-c~-BzQOW3^eSsold>Ht%p*cP-~N<7?fedX|EKSFrSjmfUaM?Y2}tBxnyb zjb}Yp9#OtC@nU0n?Gd+49tq9+L72u1m+{qS)4>ii0GhQ=z|<@oBKe|vWj|!Bu>NDN zVPBh%6TF5FE};jnFoHdp;JtgWudR=pdAcxDBfb20W4Kvwhh(j%ub)ntrWgI)d5Lq0 zf2pAL(qaFlMIJzE`8$1mvNUI6KNc>m2#%6(mPh)u;ql#WJeSTvb_TuCNqq z8SL2IpXucgm$V%yq(Uu z>ntd@uX9#8q<{|gdR^t;tqMNN499(w1)1VZ z4QeQV&93s_cl?QT?{ctqLAY;;M~HY{(s0_dpJ`Y@Vy_^I-i}D*wcXez^Z-lx;c@yt zkE>#+JGnv2mjx$9NBi;AiR}|ZweD(ZlCn2{olQq7=6`TZle;%6S~EeDxvT9UzH#-H z+YG2D&1msC@)!l?@rI(kps`qEz-C1LF=2ac1{aDzoYgGtL89jWbWSUdYoa_VG=g6yC5Yhn zsyrb|XEqXl?(Nfz+?M;D&733rBL&N6OLtjs!vZ0&a_a}X&XsN{Fu`(;bQS@Jce=D% zm&57-J7*VLHZB${y0ki%L#JI2WaQbZnX)tQj^DsT zMh3qhG?`O59NXlON-hzsdh{14)E8*X+qB{T?fTL6`;tMzH~6;oN$dZg@XA8q!OijX z<|vdtPriz%u5(zf9rbtHZ zb~$>fvZ8AEE~#{jSdwudiPr5j$O}~*3yt$lq0`Hx#U2*Z_X)VzyS`L-;uIR)$oKt* z^JK+THKAdb&|$@NwIw$Xuf@ky%_E`jWA+qSb2*HGMKM&|ZM3V#HY&>ehL;k%QQR$t zE)L`jb+GEJUUfhw$LX~`K^;S6bkEbYW4Z_R{5o8;Hb(AUNZvL3+t!|BRA8aM>94;a zK^SzBI-?SzfXi6VdYsC~?$({V>?c6IdJ<+Y#agQdTj%li2wJGFP^R6DwiyUBU6hbR-ae#?K~NU5`X92 z`bR6X%K|1-IgGd|cID>c%4#(lm-P@1f0&i6^yrHV1TI6PnJt^^{JZo6Vm`Pyh@{qM z`_03wm(=xpP8sg3uQk=yJL;=V)%Ez6XO^X{;yu#^hKNGLDz;T@>AUWTjL&o)%sEnPLRuxQ;RR9G9pYq0=qHh{(I0km;ZNvRvji8=wLUG$X0IS6Y0K{0V08d*ODk&{GECbeMskR%~7Cc$cq%qwW{nZMQ z9$&=|I2N|6MFT)t7uD&YcFXD>>2!28l66xZda1+FP0bhu%6n}IT5?4U#jDPS-> z_v>X9s|^UX(PlX%V&gWy2L1q-?6R!w@+uW3>-cHz$ucc0W-&?T#m$Rpt;>g&QNm}> zqnISo^Pso9Jr)#;N*UV0b}6P6j-gfrkfM#kZS1)qMVNqxIEEWDV}K}u7A3Mv~S`k-Do`xxs8X1;2Z`P5_fH9PiC?(c`Iq3nG-XCk@3Iwp2}aS;C^(dp67 z{}sP#S86sfEi7H_4A!G<(Eoo&y0HcJKLN06nWcD zdVFh5YE&&xpn~LzBtoR#z^3p`ej6>@%~Wb|RJ@e9KgBW6&BD`xnVVA3Mb7e4-OQ|_ zqAkozu+Z}{uiB8 zYaF2!T6Kd~WFGN+Mq=`z%@A#zM!_Q=qsJih{r(zV4P}E(b3zUy(tb`$$v;<`;7>3~ z3&Ake?fY+3gE6JB-vWm{E9u-~9+;&Pi6 zih^WS@t=bFVu!EXV)X?aLUVpXDREO`dQNLDqAe#~zr}^`)-eO@PL9d4<>n+-)*NTa zd3o5)3iDVew`T~aYJEcA&H{A-P*i8iNetE#37%2 zmb?@fLbqNL|ABGU_)VtbUCvq_B5>5eSK&D#;pcLSWB;+gFu9$b%y@F6v8pkBdV8vR zHa%b6vM_PLg+`e%=g%Yq{NaBzDu=n?lT-m(qn;L!t>8UyuqZX6vcp;!cLWY|(!nnh zl7Go3zc+OD_>SXehR(dbqX&h3x99As`BP_l&dl?&=jP9y?KwN2H@dR(#8+RxELT{y z7@=*WBG2wZNj3{h8h05?%jM;UZ%6Ohu$`PL21s9SAz%U_CwQqg50YYL#My29*iT$RuT=G&CSLzja+J z6j=Ax$7eVUN+CnhpSnIpt5x&vo^`j1{+8d;<6lDoKNg)w4-`fhu5EO$$$2sLBC$(o z5%z~o*Y=^$e*?2$Eq4p7LM!>ur|1}KV{zAFbwNqXixmPk$A)4q%VA@W6!c|UX##<6 zfzocV-g+-YBT`ed8}4nSCq(6YLN#JF>x%|lsspi*a{6n#D{CrIAc)}INk(j9Li(3V zi-STeg)CuE5D6MwF1^jYwh^?Z6qI`iTO0Eh82naDt^YM;Y0qAu{?zihjf&o4hfwod z%Do=-*N#w~!==-rfTn!$sXE9hloB|lS|=;Y`+5cIm9~Jh-YXX8)AIE^X*56O!<6il zx6`vg@kSagFmJjR-}732dd^&+Ac*qc@{`S*L5p!X;LU=A?mc+-^jP;mfLUI`9?Zhg_I%?r4Xn$@3T$d9D1_E~ zHK&>SJ5g|o>(|g-5O?lFqyb69V0T}UrYyvF$1ilUbKP?&;_dEb3(f^YJgfg5mijhI zH^K0iGRAZ|u)-EeCxf+WL%&=$WHM!#bx(!{{x9wK<;(*MP?A~+`~;J)G|<76q3!89 zrh)v11bfOJJw~Ql(h5Fjk-en@5CU^|L|R3gBz%sa)RVf{up;SA`GqF96*JfI;b!f@ z&47L}!CnSEzODqIb${3NLKS<#HDO|)d##n-ig`JLu0&{9*_!>F=z1gOG~rwzF<f@gT*MfwyV;mJCe%uoPSgiz)2kEOA{YZ{w*9oQ3P-|!gPXe!q# z!mY_NK>GPqy#XTJaH(K*Np`hT&-Jcudjw{##qb55pW5Si73l(~b1Ns=*9#3-cR6}YQZiZMfwu90 z9}mC*Ry&w&x|&69C5nhn(#TgRT?~G#g0h-0A@8yT(RDF^6Qc)j}w6ACPQV9 zZX|NO@7j4wLNvA1PAvdl9#H5EfD$5jy#sTGmHT;BffIn?MtK1_{VD|Q@?TH5Okf7k zoPMa^1H3$fmdS9jf9GQVcE$cPs#v*?`<$fi)U~d!okU;^roE^GKldH8flx0n!^jZJ zb$}YT3lEQ(gclJnFoS6iiv~Oxwzq0id;HKDU<~z@*{1i=-Av$QK-(D*WskmGvK+t> zhAkv~;TCyd2GATna(QeYEv8=eTu?V$q`Xvkz6!%#k^TbUff)~r#z}s0mJ2t_12b1) zv_4K<1e{9ravvW$?yTZJc_|@TCUx22Dhz*43RaH4$6@%#4FnUFq3wh977fKQMjmI* zbm%*0C;Fd1#>5|U!bE;+Es*dNQ<=vJ+e31T`ph8+>Tc9^4!UVZ5@wr!WnP z(6F*K+nwlWl=ARUO*jlgHqUm(It~_S+1iSoMN420@;Yy(LmzES@VFx;e#jLHJ}FMD zHc0^zKE>?cDSjGZSb4Sc5{tzJsL;Fr@89^=y_3*(5}z@G5BdPd!#qHgd+L*OtD z!q}C%hwpekUXKw4Yl-IbDaaH1#PaA}_$*O@Zd32U>5MFj2hJ#LNPHrhoZ4vR%+HGD zi4H#mgKyz`37!NOz&b$Oyp(vZ{E^4+&mnJ(P5q|d>6R@}r~(hz>DZef!%Fq4iG?1+ z%4e-YgTP@JJYb;BZ7FsGS`0l}KE-N-FSzf$#-x8JMiJ0Q&mZMeK3oZ`jDOA@|Ln#; z!=IhH$cCE4ftE25G1m!$w@(=kqCkm7=hX6E7#G{_w^7GzIAo`q6&~dDK^RVMZJqKu zfkG7+=2lC`2ok%j#NO{h56HjsZ^RQ?zPQx!Zq#uIhIU>X!@r26W*Gco!EmaMy$~6_ zLMp-75Q=Lth}j{&?qe-FKqN+(6VvK}B9Q4ss<<>JbmMb9`nVGPy z6%D`FfzKg3Vm3n@5SRdtSD0|AH=V1;5i@3)c~LL7(pO7Zi~1W74q}h-3EipEIp84n z056Xud=LPA#O4!556l@>G#it%5Di)ac`pG)=96MPE0%}Lz#$yOUc*V|2@@U!@2h}w z2SI_3&`R}QE)z+56%FlxVT9#qxNPQ*#u4qXoe9jF?2=VO^)QhZk~L@%5gov94&PMt^iDrd-!lMpgp)bqA|eUx8;dKkJ78P z#3>^Q_c=1^e6b=mJby~IyVBi4S(skN;f(p??l|dAwOwW%(|jky6KN5UGn#q_dqv|; z%6p)MwkLS%5feY;3I%s{Vk4&lBs?$Yl8-oWi2ZB5_W*GsfNMB? zK$))zNFPF4ePZ-om1g4dBwac25!A@pmCr=d#!4l%y^O=!nmE@2txfOn<$~{7inei( zmFv8~+oOk?85Sj)f)-`>-7+BpkIUy_?_H?;`tXlNVH?Ou_v)hjHp09Flo967yt8Wl ze6;{Te+)0&3IrgVVMFf#UUyOwxns&tliXW(^^_BU;a0)ZrUCYHZl@lV>v>6ktp+Egu;X?b-`@A6})`4x*2uMKt@mMQSh(e-VAA*GO*~@ z!$}+h#JoHJOhB{0giz5uQ{&CAi$006rVly;D-W=TV;unEo}Caoww(7d3~=~TY5aMH zOrPb;O~AtxE{wC-I7Un$fI$|~xl9DuaqQQm2CoRXkYZUw! z#7luAnANNu&;?3mVg7ui8z*oOdyG$(k2%*(OO|^Aoldj_@bZ9$O2E)Z5`s7ChZI0S-?$NrvTugP%5j@`Xb zv%dnC{bB@e8<>lp!~5Vk?&FP~$$g2={HV_k%?LXOk8#Vd5AoL2Iee5EO6LgQ?yPh4 z3~sJ-jPG~(wecg*FcGl=fGSj^Scy_)%2k|P0@!l-|58Kz55V$cQd{XWl~3*ZX!jd? z9@_i+ao;Z<3403o?^oyC$^o7kG-TL_QDc+wdnxRArfTO2cG4-QopIJV=Ru{59IO9- z*=5fR8ZvCes4<}NN##>6sR7Rn8ZvCes4<(+_&gxj58%_DKfJ%g*flKx3cil-+GDCbL@0yp(lthXHZXZkh1xTRX0z1_K;xRkx{$bI3MYT_7{&er2XaV^LLGJUQd489(a8=;@N?hNCr~0f814VcBBth zM*3>68CGO)5)XFa=+F13FpUpjCLGu!{1MV6yp6y`uNNoU4aULlKV4`v~k? z@tJ4dPvC$5hkp;P=V?Ji`-^Z?tmTdUhiAX}&3w1a{tz=i8oDO-^HrhfF$daU24g|5N1t(c~HQv&PQZ*TRz}uC>mutox`(igc`pBi&9HcWjvp z;NNzoa6AXy!~($2_MrzDqZ+StIXJOwbh6{E4w06ccfiwK6YkOss9B&_QHb|u!O%o1 z&iKb$MK#kBPMXF>EUF{$6JL#uP`o!f@!lwz45Fz;Nvb`-B3t;VeJD{Aadw=p71Y}D z#N@*%BH5z;9rt?2o3O3P=uI??1UEf*D_w3IsmQu0JG*%~bU%cDRgE2~Z)$B63?9rCDP{sT2f+-4of+N^vAx8WBvlz(={~ zJvs{F0O;w8N<2y4F$w^#Eem)7N(;~Al(VvitAqlJld7OZ3RGid9Tq;>lSOzNv1QFuoYKxPr%L)?T9dYtj zC7sljppK31f8^WDrLyGmlCYK*8b`1C@f8GUrzzaNXkTf0bo2wdtK-3?e5owInZw1 zy(Hb4Zcdeu0Q~fds@QLn1AKE7G5~`Z$xD_N1-%67)ahAwf9e_So$9VGa+fHRO#5)o zH@`iG#HIOv2ryG{@g9oC1-h=vD1XyV2B6&0aMCoZzbD|OiWYk>IIfv@MkUflEYe0R z(q@EheKm$mT8oO6V&Nm#z-N_7H?EkYltU8HsGtm19q}16h-f7}2z96)9^ushTxsUa z6dkEVrnlR0o79m_-+wLT5RzN6w;|5x(Kj()#9O6YZ$mnbLStz(C`eZ^A#_@8ve}&` z$0cq+lNVc{B1c=K??f0*2eX5WuTOo9J)~|#nN3=&!!m8g5ug;3h-R6{jaF6-)s`jL z#I4(@mT&-C>L}p>+>;vZ&#XKLfgj^;>GlmN)g~=?4A=C$0T;0G8IL4-tB4@2;%Aq1 zJ{d=EGagkn+@Gu?Us6bXFp|~yaK&b(X z(o<&B+T=(P09+?HMf^bTJ1EUTM0XjOEOh`(*nKFvEmDFp1wGwK=nTfHGwiAd zeZVNb19?!y_fDO4e9eG?TjHop;IVvcZ(z7V2oH7b{ zMF2bpi+BZb9J1O0<{b-dAC^6#sL?TZZC5l92Y{u@m*AW@S{=XCcuZL0U9|h>p_YTZ#B=T)O-y z&P3YEkG4zPCtc?ib>=(tFr+;_2 zDJn-ODo4tsL@B)*Mxq*4gN2XW2gR$_8q6NSeiW3!a}dFcMWkBQe^tGBSllJErAIny zmBNV-x5*H!W>QB3s7vaDnw3&7q%??PvW{9%kZ2gp;*Ym&Us8Zxv5+QS%AO>o;50`6 z+&-*{T1p|}sB}sqhc*3!HE^a=%OQcV^8~OKwLX*@gUM(S3Dqn}6KqqIjmiqFx$=ogyN?;I$^)9WS3%$}(BY<>DJ8^1J?(W56PxI+b%ZKKUtEBH;D?`y6NOo6p^6Srz=`nF!$SUrQPWPMF{_A+!;-=`N5uA0Rn zbqLT8pDljDYfZ6pD@zyU>tounxpbfA{f!5szJ3RU zVO>#d?vrI0iS!6QnZU#cij7Qzcq9uaJHth)f~;zXOIm;h*sY07e#H%e;D-15I4ii z+hGhHQLf&gWpb}IgM@)3%xSwN=@91#@7+tpLmQlMg#_uCHUv^MEeJ$$VF@Cl-NS^$vbHuqnM@SqY3Ye+Hj ziy91og;+@(Fc@SC7I16TI(Apxb7|iNv-K9T8fZ7?fdi@Vk+Fg3wp9T2OVOTOu>mK+ zuCxH-V?vh|)#PP&@J~L}-In9+X47QBxWqHa0glwrg&5pErMHr19G`8nhW%OXY=uVm z;Hj_8zhS&~2kLJ7dTjjMK#eS`wA6V;PL8@|h&Egax5N_av<~Yyq}|p#su}rn|E@bR zJ^MIMR7*VWBB$-^Wo)tg_VyKZr+2;|3b@Bq^(jonyI>DBS})RmHnb4wsK$t81dq70 zduOe7)&bsb=OlWVqSbqwY%RAY4yjA^E_)}FbRga3W5Gd9Oa`3#yMG8Px{KZ5=&}&h z>kPLstZ`j@jO~^XTpHzSjOOk?<4qZ#DU*qQd3B9_*4J>JrQ4Y$hDBmPl>UUtaK5Uo z`79s=c*!6LQ4|Qlg=d#w7y|hHfsCBOVX(f(#t}OX-PmCuoR_Gb8U>6vZ8=)LT`*(p z9N%`eirqiI$bUz8@LWcB`zL5f1Lqi2x&BB)PIbPdR;Ol3X;g9`gFX5*zd9;$Y;*&5 z>MTv-NJ*iu4LD3qT@uR!n8pS-#=Tn>vxR^W2Rlt_1*G?V0*<-O8O%+ntG|ZWa^!or>;jmJa(num_#mD#~E=PlmhT3Uspw*#;|MecYHzXZJCBi z@jh!&2SE8moJJMboQf1xx+Ke~EkulR9!vg>5idXHj0Z%a zb0*YPufAlor{=1vQA?0f0c}$+tJ2r1N9rpPieH3odw75k4KEVTSHx|DrcJ`FTZhCG z98^T}>VjxwV2)V<{F;-*ZX=R92*frwWvtUKqXmA*F8;|slp_r@1hP_|lMssLO@vP| zpU425M&w9}%>q(TJ)-(2HbOY;54>Y=M3TQ*j9N>y_%FoK1mTJ~YM27h7|hF+m|(Aw zC9x6=4p@3r3>ZKZ;cWJ`6>$dn&d$WF;4!B=y{4Vh>S~%1 zXM(A>G~JVVX|#Ij0?uj_&Iomr%oEf8xOrOTJTmY| zGa~)EB*2628_oRNyV+yU4WF?Egp8U41k{JAjp72FJo#A-e0Fzl6na3Ix^aVuy!9Ka z!QmD@cYj+Zo!{3$ymNn~5l*ZQM;s%96x57WH8?5b5zm<)wo=bB61$9M>4VOx>Ed8Z zcV=X$ZMp!AICKrxR~OotE+lFC$N(3Wi;OTIr94mlL2NeC;D<}@T~3$9(Xgq`dyr%2 z`@(34u&;AfU(RyhbjVeBZk%H7iMUGYycZMi6H@ojf8%PA1Dnk56yYLf%Gm~HLPiLG zKtsZM*FLNyPQ^MGrjw_G9h(%pNIEqjaglgl1i(El<6a+|N5rk-bb?)~I>u-|1C6z= zZoT95ZMPmSp%xFvjfR`yb#_&4NC@|<^k;%q{fk> z;*)s^W|_g*<)%$wOvZ|?Hfc&R?74l!wr3h6m8#AS1V(hZss1@c8RJe)H&D4poCchI5KtECo`7Z7{Nxy3SQU+%Utepc5oqY&d}u? zvnGPx$r6odyvHkr*B?I>Q#mQ&-i({1X}I2MZNl=KIZ^qdeH-BH&zAD|vNj9hZ;wy5 zWpO!eZ+EvtS8U477Yf!ne5uO}J55u~R#nAr(P-D4QJXZ`4UWPIaEB1Q1}6^B(4SFdDd71qt+N^~o1Tc-3LP^US`R)zbO(1> zp=rk^bWUl2lTi1c0HcRx4zJvmE2^7lLE0i!@NGrSI^VnbUV6aM;XnoFsD;#eKvfP`UG9sJl>p7zA3ijV+ot1HD-1I z%7)0+LN&l2?UuDTwCtr;#>ga+7JnC+I!L<>wlA?SacK{yLe<|Oj7V{y+bmddGAQ(@ z1?qD%$F6zN2)kV@98<9C0X%}VsMILrO>h;8rmQ~()GX3lis}|R!azCag&`a^Bwye} z0XB10My1+LwA)3yq_ieT)b1upgqOH%RRCv7*RV;yj!&DL;i}j@ul{jhrfQMksT`dZ zl&y`0ri{I_+j6H@a~e zwKS69RAoHmsn|67lkXc+i!dbqQ$_S1P`>&8CWNiX2Pvp43^bP^;el9QIc!R6c+262 zB`yU&jkq)m8d{|%C&LK$jzBQQ9A<*}h{&cY3SF*@#Ixbmu9@FU!ed@k1j8+KFY)x9 zphc^U>|{E;(-eG8_vr2tT%D{SF{e2!gr9~}w5yB9uX9R#{c<_Cvf7q(@K^X&RY+Uk zwyFG#u2x%0#gqk3jVHfL@I(d(km;WyG}?z`W9o!t&Qop8E>&j)8jZbR2>HxVG#XQ6K^(B-wcSwh%_j3OJ3s?`_bnt`Y z+h*-GWvz2;l=8Bw249Q7UdGVJQCe`Ays-W9f?!~R0`-Ql2R9HXBK3s5czSrG?!x4T3rY!}WKzi(&EFs-5@ zxa(rX^mC*;|eB z)N45~LMKGAJPU{o9O({}x=?gxzla8u#p)_{T^FUCjymv7r9)&#Q_YJ~S;dJfoz{e+y zm?A=Uu@T`>5|j;uFf^J@t~l4IzKRpGnwtS*1BG54Thxz$TMCNQR&uBV$rJnvm^j5d zNWB!^jJOaZgBgdQ(~;3M(@SORR`sA|C#d%ZniU$?iWYCqW2U1JxsoxfxO>R3zN#!* z={&&(+QyJC?@x!#s;zHvyw5Gab=P-Jt%M~GV;5!tHq+sT+>VF~JjOY3X{nf67vf>B zlH)0F>vlN3>BJ&J==qrQlAA$J&XuiqT9)X4N&(9-$OFI5f8 zo&l{OFMsJWJ2MpV**s1d^+gwkRkNGAvg?oGEW9NgJ16*!%lH3IOnp1E zVF?0VvxT&2Con$qu7WX3$<-;w(9l93&+p` zpU~CtF<1zfaGV@wN?8ZDaIY=?l`mC-8zr7FvoZR>P!=sAa5mr)ge2ESO#$So?XTRdz zO?nZ_4}kOta8%8`I^bxGUjl~3sZ7aoCUg;JC^4}B4lOWSwC#9K5bu+9KJGTtP*=>q zEIN1(Ctk#ZC3QWb#$%fKaN#l$vX~nis5RgGU#EPy8FwuQLbfh(csmbM%x+~C6KZ)W zTjn=u`Q-=dKIHqfx^X^K1=Rrt9MMC8M|QO6+?qww#N0&@n3a{gF)HoD1{=g{r9A&E zIM#Q!1YiA1C|sRSyUp0QO`a;@H5~GlG1e(+uK5xpod0PiT~yAv&Sprp6Qg&cBssOO z0nTAWzgWUCk%C~W5S6SvT73q6)~`i0XNEalo2UE4CPoJdjw29a=AdnEJh|L@5ts2# zqZuIeh`|~nxi)7|15l(MF9}Hb#stNNg@vl=99~=(rHqkGa^Z8E&vwnS8v?5@4t0HQ z$Ea%wUXI&sw^h;#;*I3>(;YHn6-oG~xF?_tToc#KokdHoh20z}X_gnp?lf@V1x(bD zzi{IYb6h5|VW7Ds+N2d->@;e>@8>E7LKUp6xUsCjpW>x}lg$P#Q8dkmfM~4>duLZE zm}#@FZtM%;`Pgf$Yt!K($FF9N#mL2l)sxub+R363M+}YK9kS3|6?x@kwoKS+E0@JG z63$@}covUM;-e>;S$*RO4Y|}Aa`t$tYF!LjVm3ie?ssqW`b(YbX?@SHIFsCcJKjQN zG&M;#fW%Aju1)rB`F``Hz?L4I-E-@k`JaB3W>)zqPcBaL?xrm6?`|G$9}c_C`dV4P zF0WxVT1j63us!f-#MeIjjHX>`=0kN4CsS`d07hHQ_LDih#iW2OYsiKpBoq~>lSBrU zlSCZ{HiC(f+Q#IZ=Lx}1om6Qhm|Jt=wk(ArBkp}_sKW!+N8THA^)NbQcE0Y|EE4Tw z_ULUXHAMW`6%7qm>bYeFGzh}k~BKYhHM34ZeRL^6mM<<-<1n7AlJ*S~x!8D^u>yl{-zerLR5G z+H7-jUeL*YgpSIaYO&TKp%2hZdAzYK-+LHob=x>s$h|jZ>Wz$((Na_h=jh3Ogo2WC zSwuNLL<5IX$1ebo9%uAg@6F>gM)G`w76?#;KfYQC4d?82cRw$E{1DE-sr~fbM(@Gn zmk&Q0KZ@6f?Rqu#kqmxt_&z4|9&d#V#=MfjgjZ>NiumM;X1&T`jp0*#fT5ASYJL}ot^UAruUk-wbZKp^!OGEK81jV7=ToC&vs z6BBt!icPE92gSVioa{0OK^${ZDbN%}QlZh*@xh4TPd+!}s1yg5p{lSYAk%E)@gV<|C%6p!klMcbo zs7a!}BG4frawhllo%aG|yJNctUq(C(`6+t~Qi)(Vnw=AM4(-?C9px$T>&pnTIRNJ> z9W2EUD?YN_1z|{YD+ONN7iS%QI|un{IuoGphcLKpvFfJoHu-ZAR3!Vmzd4be?3d4(y@X@pJEna ztOU_~J481#P=p}!9>px+JnYM>9D|OB&5p*YS7f41y}X=|H~hdQjs z$=Ym}m`00-wY#M88|tv0C#p+*aaPffnmxD(2DT zvJ39wc{Gy=|1vu8EMwlqxPE#^B{*O+=cOoN(NbL`3oaB{8iW1&;K{;j1UGdo0o$cq zswHgN#xG<>(tp4p2}7XqN&qlQTpEJxiZSsB78(KLIvaorkH9ICi%Fzz^Yho3p^-q^7};U7sVj(ppPft_1fmyIQlWK$8f!6 z)5ftcq$UbiGOpzBAx!4u5UmNNF$*G865T|rqvs_kln&WAy5F0oAPTq@Pu>;@Hi_EP z5~mTh;|TC+=^CYDq=d`v02{hAIYMHa7v>IKW;BlbA~%ALc@?3+!3^E;pzWYE5nzDm zY2pgq?Erf;Ez!-oia?ScEtb->O^Sdpx47|ksR6!IsO$~rM??(dYP>=MD)Oyw9HbD( z^?iy+4WHUUgp1Tkdlfx{gciI?5hCLgoPcoOvo7ISWe;9S6UREXjoO6kDyu;SkP^B* zCoF}zz6%xFq+tu6Ga+n_&SkI=48mpPJY0mwM;|c5itMiBaM?5770cNP-s**sSTRYk z3$TQtHmhS^Z5b@Q3o6Sa8S6PC?qFuiR!%UAn5|SHf>mIdSYgLT!OKB{;*X`eStA^M z^z_b1S_CPsiB2gl%oW5Uy`mm^Pu4ztx8DmzUZRJ(07fQo-sj3%F%b(#{DNG`KTE9K zxJmli4c0}aw{!O8%+)l`T3Jx%D+%VZ);i;!k9)+@;Wfh=D#ArsH~#QNcsTq=1& za-d#~rOBc*RoBLx=*<&i|b(3h}fCG82 z$&%0g^V|q^6o&9520E6@%IoszP{DI2$=$ngCtjfv+a}t^=zE?xwfFi>`{;sSvpo)^ z&A>fO3T#+20@THKBLv z+hSMlL_%bRmkJln^=sB1Air@L>BS<~13z(=u7UNefigC_$l~o^%@)+H3){n#3 zgT=|s&b*d#a@?BErc0P4`s{UuD(kKqFdQeOMorPVgaMGs&e+O>CD&UWxfN+RL+{gU zo$fJbmZXEpQVL}y@HA>Dou(K z9%7|BV#co%mW;;r$gp&S5?V4Iws~mgbWwyhhSPk(41{wbEIJJB)_U*&wmjw~u)i}4 zi_UL;?|7&T_kAKdCOGh!6cf}6!X* zlESID2vS#hzLUJi=Mntq%fsFMW@)W8o~o-@a>-vvN^IWNg}@$+6{`0s<{b==PMeq( zs>MV-|lMd+*;~)kr>&w+chmJ)J@}=(^4Ofvvoz() zUfniJZM{|xUh}>*&9D#FO|=#f^yM&plLEF(Ny{4Rwh2bjGt|;ncw===RlZ9egV)5Y zl!kGdwzb{e`euou+s*xkf;FpgOrvzG6o$Po83ndXQxq*DTdzH$XRP7oC`=Q%-6)2> z=*k_!U z89F6#tYg-a-lOPULWzSi*O&BAn)i%4a~=hFLL{ih*!pWMSjGm(LrEI zHYbrhR$5b`cMlefX`d7^^b?RFLreSc`%E|YT8I^^Eq(*{@JbI_?)-vTF!>H#ht1P4 zRc757oZ?N@}WyeH$;M#%7hbhJTyg==1*nQ%510z&VcUs z$wiCi(aP<#>*6-kZr{Q`}Mv6_q<&b`Z zbsl;wzCM}|t6r$zEPQYp&AB@^oGIe@zLA(CI#Q&M%>~bJLrMwk#t{qPT>ha_KIMqlsEIkMVI_|;R4r|JKO9xQRx)yJxz@PQLbc%^< zgw$EZ;ResDL_e+t_c^yp?8c7s#W(OAcC58yt~NJI<|MdNL$kO2Cu%=yfD65qzkJ?Pnpw-ZjPLru? zCn55?$sEHH*1;f%KO|;Z0fD6@wn}(>BrCAXT@<%Al1i#Mc$HNG9Bd7Sc7Otyp&q3C zOpWQd5-$a0KadR?c<86|#OqdC1_<3az;lmo=1i-cos2}+_#NlmZ_sAQ(g9tYqi>;{JSU@VF zrz)Jk5}vbcYf0|%bdaUG`DtvD>s=lbalwXQ^&fb=n6Nx(PDCW}(}$SRL5gusqEQZ%eJh(D+M7RlvFP^99Ad z1JAIdPd6#2MiWfh0o5Xw!=a{FG=<_FabQ&*T(b-#u?$`w&_Qu+Y&DO+q>tH=v`xkM z4Fr5-zxw-FuF~D@V*ud@-0f@&gC@}M5eqPe>Pqx$7DcBTI@y^}Pzz++_y!Sc!e-yN z$Nj4G?6a{?tZ*2qfPS_`donhiVA`c;AO_4b3m0cN#?t7~(QIAW2s0no;he+xu&lfO z`6aagaweL%AS&jRf1hQpIL!h+2at;!GC^sRBpICb=bXkw${zyo=}CEZzb6=O_fK$J z^QfXoDlI4BF)wXmqSI|QDjN~Wp5qS&%qWbsfq#kAn(0JpVdTazB^%>wv1CE+u^ zo`;Mi!Wkzy_oMkPTw*aH36{s1%o_290gQSPkyu`Gb81MbWbsBPjJK1wv0r4IhG>E= zfR0`zHuRvYCDY@+2w}8ibe5Y9!HZF|ZMRv%!}%u+A8k;r3Qfi&P+3NfA}PWb$(5#r zu|pAVv1ljiJMIrwjoo~IDspimUWQp53~X^-(>PKPji#0?iwRRSEZPDhA)En=hS!$_ z&j!nNy-uQea@QXB=HWKpk=(wj%{^*ale2JIv;EPO;n8zEEgOU~nMsOy6?AYD%A~`| z4tty^g0)6eDEYaQHs~0ow$b-9j91|FvIfo83FxM#v^5H&B=`}t1?*KuHts-vZ}sqm zHz17*Wzk4mJ;$&xuxPCP1URYxp~OhyVLMC8>BsFkaKM*iFCeEII2BQB7H;!;5p6Ww zV_pEJgorXwCuNfqM=T5?_gwB4NLdYpws!GM4mr!(3sbxSL&6xK47#DW&G2(BjJ$he zo)LUHFt zAl%39Ey4_hgU4Ygz&=1Oh_{8WIoLmj!JLoLHmFg1MfvOM%1LBt!0?YbMHGG9w#asc zrJ7iRvxrOXCQ1e+;|mON8U{wn38!%y3HW2)!Rb2s;)2(H3B0@_uLg?OvqpgbBNL$U zy$U}>5Khqx{_0eIR)&s?9{Rm|U&94rnkn+~$f?(-`m?{Br~HNOEJeZyu5I2a@7< z>^b^W5A^sa)<(}yzrL?DPn5E1U%9-!Mqqg2KSu$?0N4UJUf9Q-bPwza@~wM9lxpy0 zL0al!cra4n6`wK>MGPZm`O7)-kc*(>615ER4G})#Ah_tzZzQp+I!4rVLKGDOKOghC z6ImXDLTKaZ$Gnug-OS7(HZ-l!&4zqFi2axs;61@~C?cy4S1<~_vtZ{WSm}@9cyIu{`aygFUcML+AzS(4H;S@Bhc2yO&jGO(Q;IbZS95%9;;5Ze>nWgMigd;hr z=v!Q;)Cs|81%-2ZA*SzO>O)dF2O(y<*-9S7t*@=b_k6^>K$r(+ zw?dO9c@-Y;=+lL8X}_bM9-S+#3U4B^GbVFzulg1Zzm1o&sTO)w%LNSlh#O z%F2UmnzwlvK88{)mcFs1M9XNgHqmjpc{p_vk34*wdlA++8}x$Q!IkrSbi5i_o$5uA z#CjYhGq;oupx2~+S>cEktNhKt?d#K(bd{s`v1FoMVN*BqJN6&H)C~5lq6e+C&-!N$ zY~0`yS&c&ffZPJ7VL7URm-54&a~hb(d_Cd1j&81%yp6acyi>>yhdaYGF)fmb)wCW8`K>i@`yi(Pe}@`(&j|!T+<@d+ zcOH-ff;acwx-mL7Gl@-g0<3lCRf|7X7<116j9U6*k!+I*KKEJxKYM9G^32-?TGfO# zAy$xKi2adBsBQ2dV=-JArZ)q*hoEB-_~=hg3)Fc_Ck7AdBnC0^a=T}=@7Ji<^e~@< z0ayh>|N9)!KmZG~eUY_g!2^}-2v%n&dYh507If_VP_Ssut_Z*b3k*iL1(aIUK+O

z{jv>yTtdpMoFC11NVE=72r1q}e2B;&@zH@;fiQviUaS$w#wi%55N?X2ldcYMo6||y z$Fj)-sp<{B$f1(e$oj2Bs@}mehqaO`S%d5iw|6^d?5m^lR_qp*Rg&{9Sl2n;Av-`^ zgXDk92?tQN_l4xLN+TtBFx8o@=VoGDa&v%h0k(8qR)0HLm5G49V6TU4>I$87X|tZB zLXpDKL4N15y^2^$?a78mrm2j&v~=2G(P>nsEFhSZ_;AY=PkyFX^E5F`NmzBvYgMAD zyU+y3n=3p@5R^d+l(fmXIm0*-?u5c=BgYKxNHROac`th%k2}M0P3LXH zLfX2OGW-(($V$_l@L7N|p)Ikgo6mKZ5=r{ZOg)-BYlqk*i=8)RVB5)Rnbq)yFcXpU zzNsZ@+F~R4${Lg$Qc~spI`c5(8Nt)Z@R3GvYBjUZdoJCuItMnnI+RbHH5Q8e`8!&Z zcGof`65?E;ZB#0h`QRIPXFIWQ+!J76>vl-ra>e=BDUv^p-3BWo5!Q>aKe3UNE! z2s!2mD1Iao!&H0!9H^dQ&xI9~V&uUPWH9Z{OcY{{Kyr^$vz0Qc!V~fi?fLw)p}G{| z+>}H-X(gKY7R6*8go{20L4$qHG|Pa;y$2U0vdkoR@7QEPYJ~xH34=()rEUJWHJK0! zt}64QLOihQeDPOPQ_tTd^_7%m9?N0-1Bs>Rs;J0;$adCLTO)|&mdguD7QdrM&}1=N zj@7VZ^U{xY^(21r?@vE`{`hcrzC$nWzIz}z#gvMwBh_~4kL1r5l@h@#JeR9_P@y!8 zPzPiJOhRUx`G*@NA7WdZL*-ZcQa;eu^|(e zsZ@}nFE=UxJ9QkDjArks88le(Rq&{MZhH(rC2o_mC41X2)uM$9(nzhLz7T-zDk;)J z#3DwhY706pS{~?F+&)hyQsZS+F396~2xyr7Qy0k!Cyx$~_u5v#f4jvU|xz@^YSiDgjp zF&&<)`7bM0L`}@IvZ9Z#W45(c)R|NpafgEEz=cENgI#EuyHxenOwwPVa_T-6_~Y`NYu4Z zpoDaT5hG1^iD6ZYuW#tmmZN282mG}iaE}`h6tK(#R7sX$^DevR@!FqEt}Xl9BLUdR zL@LuhIqgQg1wG-Zik8;S6NURUt0tFbT%fnoA7RrgoThc95b%;pCc)nWRoZsYl`tC3 zJ7=F@*%*^$nQ5vAO=wjz1L1QDj52OVfFka{SIz;R!jOcjvog$9q}25Up>BV^;TN6C z*OhoplEhm0$Z{w4@utrpx7$u59`Z-vTD_!EN*1B6Nj1Y$%;(Ll3uE(%W|aKyZQmOm zAwoDnKNiY=!nB-;0YlEGPEO&rB#84u4mwnQiy&$R;N9CYwOD7AkS1kN+bbJQq+@~mMUuc z#_WDdtT$Nb#ifhj5X|V~(j|*VV9qJ@z41|)R!K`PdS4yzvl4r|Oae4;p$u}8jn*|b%a^svEqhA9rnOtG<$bZgE$1}l z=^&HaR(s+*0XH&4}vE63@M);&Ct0o;6?@7nmF=#azR_;Tu z00f0AuVmIhy&1TSEbVe%=Yx)a005<9$mb3~+=Pgdb-Os_QS$8w#KXWC)uhqjOg$aa zx;Y_TMqtRFb=kl2o-T^8t;^yQhEHS`kOO&vC&h%2lCnh7EYjwq+a19-ub0!;U&!wJ zeUVYkQ;&Eqgk7T*P7(%-ddX;*cDN^K)~#?1AcGc(kv3VYWb@#A?HZ(q$2)5y$v&m7p{*6+Io)hG&Qr*bJC8bgMP;1WI2M{OVTcmgTP zqF*+3kU{YJ3H=LV4+LVxHYtL#mQSV!t6NGYMh ze5^&=-&-hyHiWar^_Q}C)2;dg6qJ>FeExzYVk@NDf{)pfeG(Rf!x;+0G_KOkTs!>am;tyHPYrl6TTkXlR?7J0bRZh1;Y1opD!F=z zwc+Sm(*rJz#HmIfo`!=ZxLBkmmMWD$&Q?sV^|_=Q)yihNO-ykAtvUI3Tsx;$*oZS$ zV-dN%)5(j4IqS|PT<3PZs>)q zr!PJFap7=dp1Bogkn^G8VwwzT#&T~fVuM3hGanm$_Ah@K~jfa`wZ^~K556`n4LmivKE zyY66Ecc19dG?1GEv#F|)O;Om}pY8D5x#7xbOK$cdRZs7Ag;^(Xw2W(>0yZWLGT+@`#$^&SDt_$t89 zj&BJIO2>_=c4l)WAPb%(X>d-_WX#FkFwv1u7qs3fu^I8VP%xpB-(Fy@P+$6a-vS|y z#s5IkQw1p{H%B9ZoHc+Z#g-LD8xu`u zYF^tPmQuVdfs_8P#5vjG1I->bu$DTVNrCzPz{Hm+ju&L?%rj9h397T3SEi8AD9v}Ys-_JklC(A$K>H5DPdcINKKl{I- z)pOI`&|4qUa*NT{-T&m2T$5kNoogipXOv(BLD)U*HAio?E9T{;YjC}H z_!qb__6@g=f5TlWzO6euSKsjHqi;B`Vh?e(!By}J*sgMX(WKVeWLF6$gGX8&f12hn zB!C`?)Y>Pv*LSoR9v#^*vwmt~d}QP3#{S-}j=I{K>Z(dUkHcm$=oAt$hnkSX$f3*g z=SgZlPIpgD_N4iDnfe}4tUDo8;9d+gPf^3L_k49`@JaMb)2{&lm z7D*T1V*$c%%{dqsP0*c%`B2xC_$#9bR&g1T8p6X>dVZaw9 zqD9=o=7Mg7Fab)C*kTmdO=9jD;9g!=aGj3S#BKV=PKA=FzV*$0rSw>+Qqa@sKE(=r z%GUt9o%^qA67ki~v?O)!HF^<`3ff{XInshs)0KVEdS-Psb?auc0YDwU;@$l6{*%Mr z8k>jqp_gV8`!@LT9h(2K`gUPAPl}`zy85T$T?|$6>WM^(lID|Z(%oQIm#-dAhh|@3 zp>NZ-H^IGGK0oHdQ-WRR8cOgcPM9m7m(g$9p-Y{CvRB}Pcd)QkIH%;&q!v6F<&_PD zTb~Z1%|=8#mpHRLS-MB$63e`%C-*Nmg|bWXEZZfdF5xC_0C#}Ii&2&YW1ymF{#g+w z8CY6A>q2}&=*n}SXG$zGFp|s#2Gx7EQ03IUZNfzLTlS=KjR~BXAyA^Q$8IC3(;B0G zfNoglB4diYxi8?hgrB}6#Uu5UpgJ7kEckG30A>XbJMf_U!Fn`j11MYXw)|J1`*urg^}Bae`#;mEq$Al>y2i*q7Yct);ue`QeuXc`Fpb=f)@-}@ zhWlXu*8{}giYc%W)$j^TSaMsOk`>bQq3;BOm#ZP4nFd1~k$CCaZ#B7T0W>b_V zFkOWs2X4mpg-;LLp>3+vt>(*wxD9r3AW>_HJQ6%;Ejv%!p4s<-PBYGmy$f&1S|sj8 zHE@GKpVi^QddMAqb^?ZQ!HBwH;Bq+`?r9-9x_)Db+p9S_vZr;V&h)@ui2~>q<%U-x z0<(w&Ev>@*lqx4tQbOT1WxkbXjP1uB2z=XhjfumCMr07q4KNiD-flvcS8(E^p{te# zIR2m$X=F>lJ^tJ@N~&H~!@my)=?frAij1C~l-DjctA?L$Q(@e^%Dv#;dg)iYX6NJs ztm|U%)s6%TP1$PVIodb`*)_sZ&z^W^s+35s#a0vHsDk{EvAmR|`W*j}L46<+#PQ~2 z(0@%QVQZ|m=%KL%4DC$dP zEa1R|=F^~!yWth{cIa+Ow&qjvO)RzOvu@5E$10wUXJV2nl^l)X5}G40D5@T2k?CEk ztd*5sUsB&KPoLHB_p_ANo+CIMQgm>96xy;iMz(QcxmZ~m#1@*?`3j`LxU?GOBPv%flAHM?eAVzoXyOAY2)_UFJCM1ww@@a?c?V zmG?L4l~g0WKm)bBC7Pu-9>%Oh&ps}Vj)PC~M|StRmNnLhn$x?)wlh9kKzKj64;s9| zRP6=m|Fi#wMDWtHt(yP#EN8Z3{G-w@ZkSOF&ujm1+f}@6rl0<4_Haqx(q_c-9QFHs zyQytE$`@M3;;Vh!_1@#eJe|&8=~m$dkM4O|^Mkta)_*v>m>L&@X~C04%yInkDd&JI zkT>ud?NS?Yi&U?HW<< zi~-H}WC(dYoX?O`4+6v+w7T*^%c8t2U(V{<>ix80X~PxmrgK^;;pJFRd$#uf*+=0D zpY}(sDt)A7ZImt3!534D* zF6rM=>w9La5Ly=WCAYc%eBl^yWz{pLKJj(BUjhClSy1ZEW!Cq$>DPNX%n!VeH!eVb zz5e*d>>DUc>qQ@KI~>qdV;hrT>Rg{BUUj(VRQgbB^lLU((z0&u#dfEP-W4ghtNyS4 zn`*5{>4KB^eYSk6mEa$FGu3vX0)p3F<0m$LqNyJ`O+4qh4dWA!(m$qENnD}4&}k67 zKXHw@{&qd)9$m=IMUk||@w5oNVb6U(=l@$$ySGu5wuDMcTzK_=hzWlDq0i=Ct-YT5 zXTJqs@HE@Dh-#I~nJtGZDy`!N5i=CLHktcEM!xvlIj>E7Npp_29O1Me`wp?$u!Vo& z0W8#lM^CAA=+CVe-+Rqx{R`G91~p=D27Gmylt-KHR*}_CbF*14jN#T0%@=zqWLv~+ z`6TU85}Yl$FG^r6gab@wp9dkW!Unu`3uHc#xPWZbc4TG7JBYhaG4O0 zG|!amyl-b&?`r2BlVVI{+?4M{{O(||21|y>00jQ z*Bx<=mYiCeml66&N$EM1)06-7x+s@E=DC2naF!jwnuPiIdy;66BjZR|j?H9Bf;kYx zA&H(aDI1op`I9*$VV{uQ7Bby@f>4!g3r@=2c@RH*G!J~h`QzSL)ax#BGqp95UCYbIpdM=W^*LrnFr%1vk=?Fd|6lUs`!9!W72uPAUJmr) zXXAgq$YO6O^>S7+H^Qy8@z*fl3fe0n+C}=qXYRF+@$N9*SC1`|>HfvrV^+t3=JAMw z!f@BYWHDs5N1YdRwwGq45>cFPUpmfqo+XG)W9gi!+TcS-moy&@7%mBHVfE`I)5>+) z#Nm1GVx})&EE~93`(+pQz&L8kt;#56Pq8^f*5^y0f7)0cH6Upa@%A+O8UA|tZ#T#7 zLtmEAEo)E{_Cp|ci|IbUd>k-5IgfVSJCZAn?x`o(8v0Z? z%eFWWqFjd21Zf&T&{H?-Zq7aTF<1o7VE!Qi`L&7vxOAT=hU%?-|1VqL#5CdTaS}}) z#Uvs-Y3|65(Zcgn@4WA3p#p9t4j~T55nR&{g?u=90f6t>L%5$m1$vT?Y&eJ& z#>|6&qBh_So0J*RWe?A&uo^cPF6<0bb87J7(SyLfGz+!H84kkuGtr~JANcrWTZT8;7+%OJyQ&RT1?>IlG|r6ZeEX?E;Nk6TM-qhq zky&Ij@Y$uG4~GGsqz(pN6af4h@F9U%A;297+4VaxQvG+}i~@HM_>^@AkwI~Hkg=)& z#oZ!m|Nq_sK;8ZJ4vkD_{_E4&9&cW<#IQY=yG3+WVnX7am+ zRpUv?`2=Z(dCHa{ft)mZzl^geD*h!vt;Xht#B<1QXaij*|H#cLQpZCY?8$p zb9UfVl>(D3Vol01o@tZhws9%nHB)|en?()2V3uNt$dV;XmMqyMnLG3H;7p69${6lx zcGCh#>QKaI2-M;52-%VpD7IMMdIZ&chE(wqQB0J;RG}=RA&Tkb$(Ba&7pOKw4xSWx zcAy*LEoVT3rROtEQ*F_*mZZH{Q6I@-aWko=cmb(GEv<*J4@m)qY%wUCFMcaHM{GW6 zdm0H%la}%`AHqkVJr{&K#Ler(skTFx{ELkOydv)$M zciKrbza3x{D<^5@!@i=d>Za}bVVq`ji(~7yJ8q-b#k1geI$y3gjO7pC@AY@{{=DC; z<^L$~ckVq$fBE&d-~X`Jf1mjLQ`a5%=l}i}2!_IuXe^#crqcFNT*>D04sh`{!xBs7 zO10+DLkdgKmj1xP^jkBX&Ckv+F0ZaXELK+6);Bh{ws&^-_N_v(RIXHO2X@_QxXo6( z^Q3FE!EC0Vo?w86;RugOL{GBQFwTn-()3%hIh*>=HW&CW0w{zKhH#L+a708TL`D=u z1sRJ1X^Tc0h=%AOYw9(b2Id0_AJnO)UTs)A zGLZ$fd@k0LjfOWK!Itsjue-VEVk3eyfFm5kah!k?agx|;raG>n1=EuxOM{BjfTYXN z;ur{DIvj$LBcj697+5&VS2r2OX|)&w6AK$hzUzxJoAF1M?Z!$fY8u*gnY52B{k30K zF)}f;u(El=WM}CTp8cplzdp8RxTkQ`xzYg|6qH-MO656AmkB6Wp;DDTa~@LfRufz*XX-WH4dnCP@Q@W4r$!#99~t9 zII2mr7TV77Y1iSHPIp%4MVH3hiq)nVzJBUYFtwE#6T4K}>9EBbc-WCEb>ymz+WhU1 zJ?Tm4eOnYQ$k96AeU^fvlCp}bngg~vM}ynPc%H(xjwiZ;u|L3ie0_ldWGFO(jS&$V z62*ho*l2@I{>mqwa=kK*ty_*IN02~!N5zm^shj<_Ra58j zsaf}-wv3)Qb|ZgQHAUGz6_FK4nap_z{f0!Umh9=P)d(>SI@1t04SAJ?l%aUm&q=*qO6xX_%mX-7_6 zOkyWN8J|}~w4P?@Pw?Q}3cR|2xSh#~aU*;0@(a0Gm4t#b6(hOuMy$Frak?;RUCIYa zE>W8f*p(TV9kgXNcOyl9GzAiyWO{H9E2FI8@tKb&c*X;3gNsO>{nKZ$LJ+ooa`(AY z#E~9{-gHh|N>y5E%wi#8pJA2a0h`tC`C-)c9^HU_Ru3vAyEvf!tBzeiI3;kfMhubu zs7{@|i%{JI`@h%7x?n`;J-palMCI)wn^7t07$U(L0))T;QXf_`jaZzmzjE7Tz)M1p z4k*xH+@mB!%Zt7}n2TAL%q%!E0v#)t>&0pocN3g?REveh!$q%BC#hO>o9bD1aFRp5 z==Evnre&{2&Q;e&OY#_##bJHR#G6l5bl9ip|3~`SX+Q`%kWYo;3hX;_Dqq=}%*=PY zzxJB1nBFHV^E39m$xm$lO`w$*5ucyBRflsa`CiAnZLpQ&C@T%_g~Wd@!Os=-hRQ~f9QC3jhEm(<}3S` zXdZXYh*L@GUphm0Ipa!7}g2D5BX$GFP!_P0_M|RdtR6E$q|QqogDwDdzank1$W674gZw$q8m~?{-|e{ zJ^L>Cyx2el4FQblMm(=xRc*=czN*8Aq5pPW`l`_3}Bq zyFN$^%n-3VlZKfx_K0B3~jv?OjPk-Bf3AGlo_6{HI`zF+E# zbsf35*88+OlN-SVwY90ar6NK5`f(I&GbQ^tLRWnZ%{B!L+Y<|lHZLYtB85_>K58Zb zY1b_Y5Pu90op&lEc~4f!c$(INMyVRj6q4)V@?imMwXJ>EF)yf-e{CQN52~PYbubmy ze${p9X4;eHrkSPTtJh{?Bj{uU$VRm`Kw-Gpyr$~}Ca=wgftH@#ON zHG`+X%O`_5zt5gE7|apYjC-qTbGsZ;Gte~V{Jy#bZT8GYZnJwP2c#q_enll%GvZI} zHZcjQnJ9%D(v8Qf-O>`(XsYIOE_d?#VkXWDv`0&{?r(3Z2(WV!o00~W)H3)TNduzk zV1MOTH4o`tg}|+E+yv*P&X4Oi6$I8Mrb>A6CzlWHB-MxEtt z@Alje^XaSb0r3ZQI*-OD{rh9)hjfaQPxg)VN!7Cc75L_>(G#&6QD>p8uQvPSq!faA0rp@C)@JKmqT91m+Md^3dS^%>m=574a|433s`~ge)AJK~G6^B4=lWVozmG;_V;wB37Wh z3j>h-6zNUpm)zx)?3GiZ#NHGR@8V>Q^>lRdvO#KO3g$q-%?QSF5{r+l)^81Do1V0# zvjGAM0C~^|hAhU4^|V()JxNKa;|YMYf1xN?slJ zWJSi7Z)s#0KSh8iiCEwQX~--Ip)lu@&F3W>jF(%5YZFE{!9b&e&7D#=$Qqd0^vq*9B@0S+~8pmsh<_4nD0udZZ2%BdzUz{Wg z!$gZA6DLn9vZZq4IxHphE}nwa!GI00L722o$~B-Bu4_$rkCHq~)0|jZXqHCyV{1My zoS>tD($K;$bETlgUq=>RY07RcNkv!jhsDLl$HlKel@MvLdYe`HwfoJr21>(P zm1j!}EzYAg&-?%VI;_PwuVT7cl`W@LAeErwq&=E8D4;brtvJ5Pt>?ok;t(|VTW8jd zO>ZygZkFM0=8k+{b5!UV(Fo^?F)BX!JHHmgs6qgoOTL9z@t45sK*=tM=UTStZDCKH zC)bvOY_U(eQECL|zLe_gk2_Rp&3jb}qh9ug00$?A9RZyJD2ARG`biXE_si1XVQInlrILz?-*>E7v0}yQXmBVBXYCRk zT+`{Z#j<7xwn)y*s4RROv2tBKI#~nI^8tVe3;+WVI{*R<$c8}#2CrrG*L0Ug*v{Wv z(eDDh{d0v7wReU+z5o3IweKXj2ngM1dEcRhYT9l7Gyf7k6+}+&;xK@E*NNU*u}K;4 z(CP$MYi7y)U|YbT)qs~}QLs{Lwaisdn?P^|&M{QxYpsc~2Xq_!g+hWA<$WN(S@jHY zB;M}%L{T0`7~Od0cwPOp&>vniM04_+){-uXmrW+8D6j_xSsL zpGZkgB50=!<$Pe#V@$6=eMdV&j7hO=TDao-Q z?bmt-QQ#wy-l>ESEi9|mdXqD!Bu=9^z3*2WCL>Vg6hT7O(OJyT5e^ijY@Pih2+oFUnN%HvNQpLQI3R+Pk8zwjHPTc7 z!&1t4)J{xd@7o!L@(WS~G}@_Eq(MdG_v#~fLVC3$;+f`Y29fwV(oFUe9|7VZjpSUJ zfSW5wu5paeS0MV^aBB|xF%pt$zd$9|g`E}cU!2CT%2#G<<$ z$&H{!X+SF+m(oR5&GxSKcLRHrNO&B%>sW*SInAhrN=90e)ALYdB!ih?j8znlvBnO& z1{DrxT$IWlq&$t|sx`7|H$)%_q<38$cZ9r9h}wz>29~%?BEe}li6&*u_$9D815dTW z28<~{1KdX_6qnw7xUd(%AY50q-HK`y!8j>VeeM^_W?WyJno?ugDb69^zyGRG=V++RL2tBfZ9aij> zTFZ!~(H~=RT5&gI9uv?7xH;|Fd`RnWNL)u~mZ4>G$Q8ef?xNV?^YabA zZj$kVG2gYQFz{)Yu_Hh=js%T{feH-#IszKdEK{M^yT2cl4xOjQ=Ch`H7bwfB7+&k*JMa z+UP$^P0b-E{rYE#F7!@6|Fm`QAAS7$>)z|R1ozA2nF0m{;GI4^^XDg{;JNbU4Erzl wZ~T1rK`7Vb#($i&kEAQOY=4p1+iH4|Xu@A(=Cz+|^Uojf{>4am6y*I*0OzSI1ONa4 literal 0 HcmV?d00001 diff --git a/media-processor/test/font.test.ts b/media-processor/test/font.test.ts new file mode 100644 index 0000000000..cb0a462801 --- /dev/null +++ b/media-processor/test/font.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { convertFont, execCommand } from "../src/services/font.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; + +const FIXTURES = join(import.meta.dirname, "fixtures"); + +let ttfData: Buffer; +let otfData: Buffer; +let woffData: Buffer; +let woff2Data: Buffer; + +beforeAll(async () => { + [ttfData, otfData, woffData, woff2Data] = await Promise.all([ + readFile(join(FIXTURES, "font-1.ttf")), + readFile(join(FIXTURES, "font-1.otf")), + readFile(join(FIXTURES, "font-1.woff")), + readFile(join(FIXTURES, "font-1.woff2")), + ]); +}); + +describe("convertFont", () => { + describe("sourceType=ttf", () => { + it("ttf→ttf returns the input buffer unchanged", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/ttf"); + expect(result).toBe(ttfData); + }); + + it("ttf→otf returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("ttf→woff returns non-null Buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=otf", () => { + it("otf→otf returns the input buffer unchanged", async () => { + const result = await convertFont(otfData, "font/otf", "font/otf"); + expect(result).toBe(otfData); + }); + + it("otf→ttf returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→woff returns non-null Buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("sourceType=woff", () => { + it("woff→woff returns the input buffer unchanged", async () => { + const result = await convertFont(woffData, "font/woff", "font/woff"); + expect(result).toBe(woffData); + }); + + it("woff→ttf returns non-null Buffer", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→otf returns Buffer or null (FontForge limitation)", async () => { + const result = await convertFont(woffData, "font/woff", "font/otf"); + // FontForge may fail to convert TTF-based WOFF to OTF for some fonts. + // The backend handles null gracefully (variant is just absent). + if (result !== null) { + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + } + }); + }); + + describe("sourceType=woff2", () => { + it("woff2→ttf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→otf returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff2→woff returns non-null Buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("invalid input", () => { + it("woff with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("woff2 with garbage data throws ProcessingError", async () => { + const garbage = Buffer.from("not a font at all"); + await expect(convertFont(garbage, "font/woff2", "font/ttf")).rejects.toThrow(ProcessingError); + }); + + it("sfnt with garbage data throws validation error", async () => { + const garbage = Buffer.from("not a font at all"); + try { + await convertFont(garbage, "font/woff", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("ttf→ttf with invalid magic bytes throws validation error", async () => { + const invalidTtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidTtf, "font/ttf", "font/ttf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("otf→otf with invalid magic bytes throws validation error", async () => { + const invalidOtf = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidOtf, "font/otf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff→woff with invalid magic bytes throws validation error", async () => { + const invalidWoff = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff, "font/woff", "font/woff"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + + it("woff2→woff2 with invalid magic bytes throws validation error", async () => { + const invalidWoff2 = Buffer.from("00000000", "hex"); + try { + await convertFont(invalidWoff2, "font/woff2", "font/woff2"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-font"); + } + }); + }); + + describe("data integrity", () => { + it("ttf→otf produces valid font buffer", async () => { + const result = await convertFont(ttfData, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("otf→ttf produces valid font buffer", async () => { + const result = await convertFont(otfData, "font/otf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + + it("woff→ttf produces valid SFNT with correct magic bytes", async () => { + const result = await convertFont(woffData, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + // SFNT magic: 00010000 (TTF) or 4f54544f (OTF/CFF) + const magic = result!.subarray(0, 4).toString("hex"); + expect(["00010000", "4f54544f"]).toContain(magic); + }); + + it("woff2→ttf produces valid font buffer", async () => { + const result = await convertFont(woff2Data, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + }); + }); + + describe("file path input", () => { + it("ttf→otf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/otf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→ttf with file path returns file contents as Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBe(ttfData.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff`); + try { + await writeFile(tempPath, woffData); + const result = await convertFont(tempPath, "font/woff", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("ttf→woff with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.ttf`); + try { + await writeFile(tempPath, ttfData); + const result = await convertFont(tempPath, "font/ttf", "font/woff"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("woff2→ttf with file path returns non-null Buffer", async () => { + const tempPath = join(tmpdir(), `test-font-${Date.now()}.woff2`); + try { + await writeFile(tempPath, woff2Data); + const result = await convertFont(tempPath, "font/woff2", "font/ttf"); + expect(result).toBeInstanceOf(Buffer); + expect(result!.length).toBeGreaterThan(0); + } finally { + await rm(tempPath, { force: true }); + } + }); + }); + + describe("path validation", () => { + it("rejects string input path outside tmpdir", async () => { + const outsidePath = "/etc/passwd"; + try { + await convertFont(outsidePath, "font/ttf", "font/otf"); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error; + expect(error.message).toContain("Font processing denied: input path is outside expected directory"); + } + }); + }); +}); + +describe("execCommand", () => { + it("preserves killed and signal properties from child process errors", async () => { + try { + await execCommand("false", []); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string; code?: number }; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Command failed"); + } + }); + + it("preserves error properties when command is killed by signal", async () => { + try { + await execCommand("sh", ["-c", "kill -KILL $$"], 5000); + expect.fail("should have thrown"); + } catch (err) { + const error = err as Error & { killed?: boolean; signal?: string }; + expect(error).toBeInstanceOf(Error); + expect(error.signal).toBe("SIGKILL"); + } + }); +}); diff --git a/media-processor/test/image.test.ts b/media-processor/test/image.test.ts new file mode 100644 index 0000000000..b80d8ec3f5 --- /dev/null +++ b/media-processor/test/image.test.ts @@ -0,0 +1,909 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getImageInfo, generateThumbnail, configureImageLimits } from "../src/services/image.js"; +import { parseQuality } from "../src/routes/image.js"; +import { ProcessingError } from "../src/middleware/error-handler.js"; +import sharp from "sharp"; +import { writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const DEFAULT_LIMITS = { + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, +}; + +afterEach(() => { + configureImageLimits(DEFAULT_LIMITS); +}); + +describe("getImageInfo", () => { + it("returns correct info for a PNG", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/png"); + expect(info.size).toBe(buffer.length); + expect(info.orientation).toBe(1); + }); + + it("returns correct info for a JPEG", async () => { + const buffer = await sharp({ + create: { width: 200, height: 150, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(200); + expect(info.height).toBe(150); + expect(info.mtype).toBe("image/jpeg"); + }); + + it("returns correct info for a WebP", async () => { + const buffer = await sharp({ + create: { width: 300, height: 250, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .webp() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(300); + expect(info.height).toBe(250); + expect(info.mtype).toBe("image/webp"); + }); + + it("throws on invalid image data", async () => { + const buffer = Buffer.from("not an image"); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("returns correct info for a GIF", async () => { + // sharp create doesn't support GIF directly, so create PNG then convert + const pngBuffer = await sharp({ + create: { width: 120, height: 90, channels: 3, background: { r: 200, g: 100, b: 50 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const info = await getImageInfo(gifBuffer, gifBuffer.length); + expect(info.width).toBe(120); + expect(info.height).toBe(90); + expect(info.mtype).toBe("image/gif"); + }); + + it("returns size equal to buffer length", async () => { + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.size).toBe(buffer.length); + }); + + it("defaults orientation to 1 when no EXIF data", async () => { + const buffer = await sharp({ + create: { width: 60, height: 40, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.orientation).toBe(1); + }); + + it("throws on garbage data (sharp unsupported format)", async () => { + const buffer = Buffer.alloc(100, 0xff); + await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow(); + }); + + it("accepts file path input and returns correct info", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 128, b: 255 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const info = await getImageInfo(tempPath, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(80); + expect(info.mtype).toBe("image/jpeg"); + expect(info.size).toBe(buffer.length); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws restriction when width exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 200, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when height exceeds limit", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 50, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-dimensions-exceeded"); + } + }); + + it("throws restriction when pixel count exceeds limit", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("passes when dimensions are exactly at the limit", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const info = await getImageInfo(buffer, buffer.length); + expect(info.width).toBe(100); + expect(info.height).toBe(100); + }); + + it("throws when pixel count is exactly 1 over limit", async () => { + configureImageLimits({ maxPixels: 9999, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + try { + await getImageInfo(buffer, buffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.errorBody.code).toBe("image-pixel-count-exceeded"); + } + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const controller = new AbortController(); + controller.abort(); + + try { + await getImageInfo(buffer, buffer.length, controller.signal); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("generateThumbnail", () => { + const createImage = (w: number, h: number) => + sharp({ + create: { width: w, height: h, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + it("mode=fit produces thumbnail fitting within dimensions (no upscale)", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(200); + expect(meta.height).toBeLessThanOrEqual(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("mode=crop produces center-cropped thumbnail at exact dimensions", async () => { + const buffer = await createImage(1000, 800); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + expect(mtype).toBe("image/jpeg"); + }); + + it("supports webp output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "webp", + mode: "fit", + }); + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + }); + + it("supports png output", async () => { + const buffer = await createImage(500, 400); + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 80, + format: "png", + mode: "fit", + }); + expect(mtype).toBe("image/png"); + }); + + it("fit mode does not upscale small source", async () => { + const buffer = await createImage(50, 40); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(50); + expect(meta.height).toBe(40); + }); + + it("crop mode with non-square target", async () => { + const buffer = await createImage(1000, 500); + const { data, mtype } = await generateThumbnail(buffer, { + width: 200, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("png output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "png", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/png"); + }); + + it("webp output with crop mode", async () => { + const buffer = await createImage(800, 600); + const { data, mtype } = await generateThumbnail(buffer, { + width: 150, + height: 150, + quality: 80, + format: "webp", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(150); + expect(meta.height).toBe(150); + expect(mtype).toBe("image/webp"); + }); + + it("source at exact target dimensions (fit mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("source at exact target dimensions (crop mode) returns same size", async () => { + const buffer = await createImage(200, 200); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(200); + expect(meta.height).toBe(200); + }); + + it("very small source (1x1) with fit mode returns 1x1", async () => { + const buffer = await createImage(1, 1); + const { data } = await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(1); + expect(meta.height).toBe(1); + }); + + it("throws restriction when source exceeds dimension limits", async () => { + configureImageLimits({ maxPixels: 1000, maxWidth: 50, maxHeight: 50 }); + const buffer = await createImage(200, 200); + + try { + await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + } + }); + + it("removes alpha channel from PNG source", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + // JPEG output should not have alpha + expect(meta.channels).toBe(3); + }); + + it("composites transparent PNG onto white background for JPEG", async () => { + // Create a fully transparent PNG — removeAlpha() would produce black, + // but the local ImageMagick path composites onto white. + const pngBuffer = await sharp({ + create: { width: 10, height: 10, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .png() + .toBuffer(); + + const { data } = await generateThumbnail(pngBuffer, { + width: 10, + height: 10, + quality: 85, + format: "jpeg", + mode: "fit", + }); + + // Sample a pixel — should be white (255,255,255), not black (0,0,0) + const pixel = await sharp(data).raw().toBuffer(); + const r = pixel[0]; + const g = pixel[1]; + const b = pixel[2]; + expect(r).toBe(255); + expect(g).toBe(255); + expect(b).toBe(255); + }); + + it("GIF source works with thumbnail generation", async () => { + const pngBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 100, g: 100, b: 100 } }, + }) + .png() + .toBuffer(); + + const gifBuffer = await sharp(pngBuffer).gif().toBuffer(); + const { data, mtype } = await generateThumbnail(gifBuffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(100); + expect(meta.height).toBeLessThanOrEqual(100); + expect(mtype).toBe("image/jpeg"); + }); + + it("JPEG quality affects output file size", async () => { + // Create an image with actual detail (gradient) so quality matters + const width = 200; + const height = 200; + const channels = 3; + const rawBuffer = Buffer.alloc(width * height * channels); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * channels; + rawBuffer[idx] = (x * 255) / width; + rawBuffer[idx + 1] = (y * 255) / height; + rawBuffer[idx + 2] = ((x + y) * 255) / (width + height); + } + } + const buffer = await sharp(rawBuffer, { raw: { width, height, channels } }).jpeg().toBuffer(); + + const low = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 10, + format: "jpeg", + mode: "fit", + }); + const high = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 100, + format: "jpeg", + mode: "fit", + }); + expect(low.data.length).toBeLessThan(high.data.length); + }); + + it("accepts quality=1 (minimum valid quality)", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 50, + height: 40, + quality: 1, + format: "jpeg", + mode: "fit", + }); + expect(data).toBeInstanceOf(Buffer); + expect(data.length).toBeGreaterThan(0); + expect(mtype).toBe("image/jpeg"); + }); + + it("accepts file path input for thumbnail generation", async () => { + const buffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .jpeg() + .toBuffer(); + + const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`); + try { + await writeFile(tempPath, buffer); + const { data, mtype } = await generateThumbnail(tempPath, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + const meta = await sharp(data).metadata(); + expect(meta.width).toBeLessThanOrEqual(50); + expect(meta.height).toBeLessThanOrEqual(40); + expect(mtype).toBe("image/jpeg"); + } finally { + await rm(tempPath, { force: true }); + } + }); + + it("throws validation error for unsupported source format (TIFF)", async () => { + const pngBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .png() + .toBuffer(); + + const tiffBuffer = await sharp(pngBuffer).tiff().toBuffer(); + + try { + await generateThumbnail(tiffBuffer, { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("unsupported-image-format"); + } + }); + + it("throws invalid-image for corrupted image data", async () => { + // Create corrupted image by truncating valid image + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await getImageInfo(corruptedBuffer, corruptedBuffer.length); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("throws invalid-image for truncated image data in generateThumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + // Truncate to create corrupted data + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + try { + await generateThumbnail(corruptedBuffer, { + width: 50, + height: 50, + quality: 85, + format: "jpeg", + mode: "fit", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.code).toBe("invalid-image"); + } + }); + + it("preserves alpha channel in PNG output from transparent PNG", async () => { + // Create a transparent PNG with alpha < 1 + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in WebP output from transparent PNG", async () => { + const transparentPng = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0.5 } }, + }) + .png() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentPng, { + width: 50, + height: 50, + quality: 85, + format: "webp", + mode: "fit", + }); + + expect(mtype).toBe("image/webp"); + const meta = await sharp(data).metadata(); + // WebP should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("preserves alpha channel in PNG output from transparent WebP", async () => { + // Create a transparent WebP + const transparentWebp = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 0.5 } }, + }) + .webp() + .toBuffer(); + + const { data, mtype } = await generateThumbnail(transparentWebp, { + width: 50, + height: 50, + quality: 85, + format: "png", + mode: "fit", + }); + + expect(mtype).toBe("image/png"); + const meta = await sharp(data).metadata(); + // PNG should preserve alpha channel (4 channels) + expect(meta.channels).toBe(4); + }); + + it("throws restriction when requested width exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 50, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested height exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 50, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-dimensions-exceeded"); + } + }); + + it("throws restriction when requested pixel count exceeds limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 16384, maxHeight: 16384 }); + const buffer = await createImage(50, 50); + + try { + await generateThumbnail(buffer, { + width: 200, + height: 200, + quality: 85, + format: "jpeg", + mode: "crop", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as import("../src/middleware/error-handler.js").ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.code).toBe("output-pixel-count-exceeded"); + } + }); + + it("accepts requested dimensions at the limit (crop mode)", async () => { + configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 }); + const buffer = await createImage(50, 50); + + const { data, mtype } = await generateThumbnail(buffer, { + width: 100, + height: 100, + quality: 85, + format: "jpeg", + mode: "crop", + }); + + expect(mtype).toBe("image/jpeg"); + const meta = await sharp(data).metadata(); + expect(meta.width).toBe(100); + expect(meta.height).toBe(100); + }); + + it("throws when signal is aborted before processing", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + controller.abort(); + + try { + await generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + controller.signal + ); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("aborts during toBuffer when signal fires", async () => { + const buffer = await createImage(2000, 2000); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 1000, + height: 1000, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + setTimeout(() => controller.abort(), 10); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); + + it("waits for Sharp to complete before checking signal", async () => { + const buffer = await createImage(100, 80); + + const controller = new AbortController(); + const signal = controller.signal; + + const promise = generateThumbnail( + buffer, + { + width: 50, + height: 40, + quality: 85, + format: "jpeg", + mode: "fit", + }, + signal + ); + + controller.abort(); + + try { + await promise; + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe("Request cancelled"); + } + }); +}); + +describe("parseQuality", () => { + it("returns default when value is undefined", () => { + expect(parseQuality(undefined)).toBe(85); + }); + + it("returns default when value is empty string", () => { + expect(parseQuality("")).toBe(85); + }); + + it("returns default when value is not a number", () => { + expect(parseQuality("abc")).toBe(85); + }); + + it("returns parsed value when valid", () => { + expect(parseQuality("50")).toBe(50); + }); + + it("clamps quality=0 to 1 (minimum valid)", () => { + expect(parseQuality("0")).toBe(1); + }); + + it("preserves quality=1 (minimum valid)", () => { + expect(parseQuality("1")).toBe(1); + }); + + it("clamps quality=101 to 100 (maximum valid)", () => { + expect(parseQuality("101")).toBe(100); + }); + + it("preserves quality=100 (maximum valid)", () => { + expect(parseQuality("100")).toBe(100); + }); + + it("uses custom default value", () => { + expect(parseQuality(undefined, 75)).toBe(75); + expect(parseQuality("abc", 75)).toBe(75); + }); +}); diff --git a/media-processor/test/middleware.test.ts b/media-processor/test/middleware.test.ts new file mode 100644 index 0000000000..f7b33c74f3 --- /dev/null +++ b/media-processor/test/middleware.test.ts @@ -0,0 +1,681 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ProcessingError, errorHandler } from "../src/middleware/error-handler.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { cleanupMiddleware } from "../src/middleware/cleanup.js"; +import { throwValidation, throwRestriction } from "../src/services/errors.js"; +import multer from "multer"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + headersSent: false, + }; + return res as unknown as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("ProcessingError", () => { + it("stores statusCode", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.statusCode).toBe(400); + }); + + it("stores errorBody", () => { + const body = { type: "validation" as const, code: "test-error", hint: "details" }; + const err = new ProcessingError(400, body); + expect(err.errorBody).toEqual(body); + }); + + it("message defaults to code when no hint", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + }); + expect(err.message).toBe("test-error"); + }); + + it("message uses hint when provided", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "test-error", + hint: "something went wrong", + }); + expect(err.message).toBe("something went wrong"); + }); + + it("is an instance of Error", () => { + const err = new ProcessingError(500, { + type: "internal", + code: "internal-error", + }); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("throwValidation", () => { + it("throws ProcessingError with status 400", () => { + try { + throwValidation("bad-input", "invalid value"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(400); + expect(pe.errorBody.type).toBe("validation"); + expect(pe.errorBody.code).toBe("bad-input"); + expect(pe.errorBody.hint).toBe("invalid value"); + } + }); + + it("works without hint", () => { + try { + throwValidation("bad-input"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("throwRestriction", () => { + it("throws ProcessingError with status 413", () => { + try { + throwRestriction("too-large", "file exceeds limit"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.statusCode).toBe(413); + expect(pe.errorBody.type).toBe("restriction"); + expect(pe.errorBody.code).toBe("too-large"); + expect(pe.errorBody.hint).toBe("file exceeds limit"); + } + }); + + it("works without hint", () => { + try { + throwRestriction("too-large"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProcessingError); + const pe = err as ProcessingError; + expect(pe.errorBody.hint).toBeUndefined(); + } + }); +}); + +describe("errorHandler", () => { + let res: ReturnType; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("handles ProcessingError (400 validation)", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + }); + + it("handles ProcessingError (413 restriction)", () => { + const err = new ProcessingError(413, { + type: "restriction", + code: "payload-too-large", + }); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles MulterError LIMIT_FILE_SIZE as 413", () => { + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json).toHaveBeenCalledWith({ + type: "restriction", + code: "payload-too-large", + }); + }); + + it("handles generic Error as 500", () => { + const err = new Error("something broke"); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("handles Error with empty message", () => { + const err = new Error(""); + + errorHandler(err, mockReq(), res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-error", + hint: "Internal server error", + }); + }); + + it("does not write response if headers already sent", () => { + const err = new ProcessingError(400, { + type: "validation", + code: "bad-input", + hint: "invalid value", + }); + + const resWithHeadersSent = { + ...res, + headersSent: true, + }; + + errorHandler(err, mockReq(), resWithHeadersSent, next); + + expect(resWithHeadersSent.status).not.toHaveBeenCalled(); + expect(resWithHeadersSent.json).not.toHaveBeenCalled(); + }); + + it("calls releaseQueue for ProcessingError", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for MulterError LIMIT_FILE_SIZE", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new multer.MulterError("LIMIT_FILE_SIZE"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("calls releaseQueue for generic Error", () => { + const releaseQueue = vi.fn(); + const resWithLocals = { ...res, locals: { releaseQueue } } as any; + const err = new Error("something broke"); + + errorHandler(err, mockReq(), resWithLocals, next); + + expect(releaseQueue).toHaveBeenCalled(); + }); + + it("does not throw when releaseQueue is not set", () => { + const resWithNoLocals = { ...res, locals: {} } as any; + const err = new ProcessingError(400, { type: "validation", code: "test" }); + + expect(() => errorHandler(err, mockReq(), resWithNoLocals, next)).not.toThrow(); + }); +}); + +describe("sharedKeyAuth", () => { + let res: ReturnType; + let next: NextFunction; + + beforeEach(() => { + res = mockRes(); + next = vi.fn(); + }); + + it("returns 403 when expectedKey is null", () => { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when expectedKey is null regardless of NODE_ENV", () => { + const originalEnv = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("passes through with correct key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-key" } } as unknown as Request; + middleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("returns 403 with wrong key", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "wrong-key" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with missing header", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ type: "authorization", code: "forbidden" }); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 with undefined header value", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": undefined } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 when key is null and NODE_ENV is production", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const middleware = sharedKeyAuth(null); + const req = { headers: {} } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + type: "authorization", + code: "forbidden", + hint: "Shared key not configured", + }); + expect(next).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it("returns 403 for multibyte Unicode with same string length but different byte length", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-ké" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for emoji input (multibyte)", () => { + const middleware = sharedKeyAuth("test-key"); + const req = { headers: { "x-shared-key": "test-k🔑" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 403 for accented characters with same string length", () => { + const middleware = sharedKeyAuth("abcdefgh"); + const req = { headers: { "x-shared-key": "ábcdefgh" } } as unknown as Request; + middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe("timeoutMiddleware", () => { + it("calls next() immediately", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("clears timer when response finishes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("finish"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("clears timer when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + res.emit("close"); + + // Advance past timeout - should not throw + vi.advanceTimersByTime(2000); + vi.useRealTimers(); + }); + + it("sends 504 response when timeout expires before response", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith({ + type: "internal", + code: "processing-timeout", + hint: "Request timed out", + }); + vi.useRealTimers(); + }); + + it("does not send response if headers already sent", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = true; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + vi.advanceTimersByTime(150); + + expect(res.status).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("destroys request AFTER response finishes (not before)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance to timeout - this triggers the 504 response + vi.advanceTimersByTime(100); + + // req.destroy should NOT be called yet (response not finished) + expect(req.destroy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(504); + + // Now simulate response finishing + res.emit("finish"); + + // Now req.destroy should be called + expect(req.destroy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("creates AbortController and attaches to request", () => { + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + expect((req as any).abortController).toBeDefined(); + expect((req as any).abortController.signal).toBeDefined(); + expect((req as any).abortController.signal.aborted).toBe(false); + }); + + it("aborts signal when timeout fires", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + (req as any).destroy = vi.fn(); + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnThis(); + (res as any).json = vi.fn().mockReturnThis(); + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted yet + expect((req as any).abortController.signal.aborted).toBe(false); + + // Advance to timeout + vi.advanceTimersByTime(150); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal when response finishes before timeout", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Response finishes before timeout + res.emit("finish"); + + // Advance past timeout + vi.advanceTimersByTime(2000); + + // Signal should NOT be aborted (timer was cleared) + expect((req as any).abortController.signal.aborted).toBe(false); + vi.useRealTimers(); + }); + + it("aborts signal when response closes (client disconnect)", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(1000); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + middleware(req, res, next); + + // Signal should not be aborted initially + expect((req as any).abortController.signal.aborted).toBe(false); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should now be aborted + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); + + it("does not abort signal again if already aborted when response closes", async () => { + vi.useFakeTimers(); + const middleware = timeoutMiddleware(100); + const req = new EventEmitter() as unknown as Request; + const res = new EventEmitter() as unknown as Response; + (res as any).headersSent = false; + (res as any).status = vi.fn().mockReturnValue({ json: vi.fn() }); + const next = vi.fn(); + + middleware(req, res, next); + + // Advance past timeout to trigger abort + vi.advanceTimersByTime(200); + + // Signal should be aborted from timeout + expect((req as any).abortController.signal.aborted).toBe(true); + + // Simulate client disconnect (response closes) + res.emit("close"); + + // Signal should still be aborted (no error thrown) + expect((req as any).abortController.signal.aborted).toBe(true); + vi.useRealTimers(); + }); +}); + +describe("cleanupMiddleware", () => { + it("calls next() immediately", () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("removes file on response finish", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + // Mock the rm function by spying on the cleanup behavior + // We'll verify the middleware registers the finish handler + cleanupMiddleware(req, res, next); + + // Emit finish event - this should trigger cleanup + // The actual rm is mocked internally, so we just verify no errors + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("removes file on response close", async () => { + const req = { + file: { + path: "/tmp/test-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit close event - this should trigger cleanup + res.emit("close"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file is undefined", async () => { + const req = {} as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("does nothing when req.file.path is undefined", async () => { + const req = { + file: { + buffer: Buffer.from("test"), + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + it("doesn't throw when file doesn't exist", async () => { + const req = { + file: { + path: "/tmp/nonexistent-file.jpg", + }, + } as unknown as Request; + const res = new EventEmitter() as unknown as Response; + const next = vi.fn(); + + cleanupMiddleware(req, res, next); + + // Emit finish event - should not throw even if file doesn't exist + res.emit("finish"); + + // Wait for async cleanup + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +}); diff --git a/media-processor/test/queue.test.ts b/media-processor/test/queue.test.ts new file mode 100644 index 0000000000..cd6d26cc94 --- /dev/null +++ b/media-processor/test/queue.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import type { Request, Response, NextFunction } from "express"; +import { EventEmitter } from "node:events"; + +function mockRes() { + const res = new EventEmitter() as any; + res.status = vi.fn().mockReturnThis(); + res.json = vi.fn().mockReturnThis(); + res.send = vi.fn().mockReturnThis(); + res.headersSent = false; + res.writableEnded = false; + return res as Response; +} + +function mockReq() { + return {} as Request; +} + +describe("queueMiddleware", () => { + it("calls next() when queue has capacity", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it("skips next() when res.writableEnded is true (timeout already sent)", async () => { + const middleware = createQueueMiddleware(1); + const req = mockReq(); + const res = mockRes(); + (res as any).writableEnded = true; + const next = vi.fn(); + + middleware(req, res, next); + + // next() should NOT be called because response already ended + expect(next).not.toHaveBeenCalled(); + }); + + it("queues requests when concurrency limit reached", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + // First request takes the slot + middleware(req1, res1, next1); + expect(next1).toHaveBeenCalled(); + + // Second request should queue + middleware(req2, res2, next2); + expect(next2).not.toHaveBeenCalled(); + + // Release first request's queue slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + // Now second request should proceed + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("resolves promise when releaseQueue is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Release first request's queue slot + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next2).toHaveBeenCalled(); + }); + + it("processes requests sequentially with concurrency 1", async () => { + const middleware = createQueueMiddleware(1); + const order: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => order.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => order.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => order.push(3)); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + middleware(req3, res3, next3); + + // Only first should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second should be called + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Release second request's queue slot + const releaseQueue2 = (res2 as any).locals?.releaseQueue; + expect(releaseQueue2).toBeDefined(); + releaseQueue2(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should be called + expect(next3).toHaveBeenCalled(); + + // Verify sequential order + expect(order).toEqual([1, 2, 3]); + }); + + it("processes requests in parallel with concurrency 10", async () => { + const middleware = createQueueMiddleware(10); + const calls: number[] = []; + + // Create 5 requests (less than concurrency limit) + const requests = Array.from({ length: 5 }, (_, i) => { + const req = mockReq(); + const res = mockRes(); + const next = vi.fn(() => calls.push(i)); + return { req, res, next }; + }); + + // All should be called immediately + requests.forEach(({ req, res, next }) => { + middleware(req, res, next); + }); + + // All 5 should be called immediately since concurrency is 10 + expect(calls.length).toBe(5); + expect(calls).toEqual([0, 1, 2, 3, 4]); + }); + + it("handles request errors gracefully", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Simulate error by releasing queue slot (as would happen in finally block) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should still proceed even after first "error" + expect(next2).toHaveBeenCalled(); + }); + + it("doesn't block on slow requests within concurrency limit", async () => { + const middleware = createQueueMiddleware(2); + const calls: number[] = []; + + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(() => calls.push(1)); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(() => calls.push(2)); + + const req3 = mockReq(); + const res3 = mockRes(); + const next3 = vi.fn(() => calls.push(3)); + + // Start first two requests (concurrency is 2) + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // Both should be called immediately + expect(next1).toHaveBeenCalled(); + expect(next2).toHaveBeenCalled(); + expect(next3).not.toHaveBeenCalled(); + + // Third request should wait + middleware(req3, res3, next3); + expect(next3).not.toHaveBeenCalled(); + + // Release first request's queue slot + const releaseQueue1 = (res1 as any).locals?.releaseQueue; + expect(releaseQueue1).toBeDefined(); + releaseQueue1(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now third should proceed + expect(next3).toHaveBeenCalled(); + }); + + it("releases slot when error handler calls releaseQueue (covers Multer error path)", async () => { + // This test verifies that the error handler releases the queue slot + // by calling releaseQueue from res.locals. This covers the Multer error + // case where the route handler never runs. + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate error handler calling releaseQueue (e.g., Multer error) + const releaseQueue = (res1 as any).locals.releaseQueue; + releaseQueue(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed because slot was released + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot when releaseQueue callback is called", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing completing by calling releaseQueue + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("releases slot on processing error (via finally block)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate processing error and release in finally block + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed even after error + expect(next2).toHaveBeenCalled(); + }); + + it("releaseQueue is idempotent (can be called multiple times)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Call releaseQueue multiple times + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + releaseQueue(); // Should not throw or cause issues + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should proceed + expect(next2).toHaveBeenCalled(); + }); + + it("holds queue slot when client disconnects (close event)", async () => { + const middleware = createQueueMiddleware(1); + const req1 = mockReq(); + const res1 = mockRes(); + const next1 = vi.fn(); + + const req2 = mockReq(); + const res2 = mockRes(); + const next2 = vi.fn(); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + // First request is processing, second is queued + expect(next1).toHaveBeenCalled(); + expect(next2).not.toHaveBeenCalled(); + + // Simulate client disconnect (close event) + res1.emit("close"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second request should NOT proceed because slot is still held + expect(next2).not.toHaveBeenCalled(); + + // Now release the slot (simulating processing completion) + const releaseQueue = (res1 as any).locals?.releaseQueue; + expect(releaseQueue).toBeDefined(); + releaseQueue(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Now second request should proceed + expect(next2).toHaveBeenCalled(); + }); +}); diff --git a/media-processor/test/routes-integration.test.ts b/media-processor/test/routes-integration.test.ts new file mode 100644 index 0000000000..26fa0b36de --- /dev/null +++ b/media-processor/test/routes-integration.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import request from "supertest"; +import express from "express"; +import { createImageRoutes } from "../src/routes/image.js"; +import { createFontRoutes } from "../src/routes/font.js"; +import { errorHandler } from "../src/middleware/error-handler.js"; +import { timeoutMiddleware } from "../src/middleware/timeout.js"; +import { sharedKeyAuth } from "../src/middleware/auth.js"; +import { createQueueMiddleware } from "../src/middleware/queue.js"; +import { configureImageLimits } from "../src/services/image.js"; +import { configureFontLimits } from "../src/services/font.js"; +import { configureUploadLimits } from "../src/upload.js"; +import sharp from "sharp"; +import { readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Test app with low memoryThreshold to force disk storage +function createTestApp() { + const app = express(); + + // Configure with low threshold to force disk storage + configureImageLimits({ + maxPixels: 128_000_000, + maxWidth: 16384, + maxHeight: 16384, + }); + + configureFontLimits({ + mem: 1024 * 1024 * 512, + cpuTime: 30, + timeout: 30, + }); + + // Very low threshold to force disk storage for small files + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + + const queueMiddleware = createQueueMiddleware(10); + + app.use(timeoutMiddleware(5000)); + app.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + app.use("/api/font", sharedKeyAuth("test-key"), queueMiddleware, createFontRoutes()); + app.use(errorHandler); + + return app; +} + +describe("HTTP upload cleanup", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + async function getTempFiles(): Promise { + const tmp = tmpdir(); + const files = await readdir(tmp); + const uploadDirs = files.filter((f) => f.startsWith("penpot.upload.")); + + // Get all files inside upload directories + const allFiles: string[] = []; + for (const dir of uploadDirs) { + try { + const dirPath = join(tmp, dir); + const dirFiles = await readdir(dirPath); + allFiles.push(...dirFiles.map((f) => join(dir, f))); + } catch { + // Directory might not exist or be inaccessible + } + } + return allFiles; + } + + it("removes disk-backed file after successful image/info request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a small image + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.body.width).toBe(100); + expect(response.body.height).toBe(100); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + + // No new temp files should remain + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful image/thumbnail request", async () => { + const beforeFiles = await getTempFiles(); + + const imageBuffer = await sharp({ + create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=100&height=100&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.png", contentType: "image/png" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after successful font/convert request", async () => { + const beforeFiles = await getTempFiles(); + + // Create a minimal TTF font (this is a simplified test - in reality you'd use a real font) + // For this test, we'll just verify the cleanup happens even if the conversion fails + const fontBuffer = Buffer.from("not a real font"); + + const response = await request(app) + .post("/api/font/convert?target-type=font/woff") + .set("x-shared-key", "test-key") + .attach("file", fontBuffer, { filename: "test.ttf", contentType: "font/ttf" }); + + // The conversion will fail, but cleanup should still happen + // We expect either 400 (invalid font) or 500 (processing error) + expect([400, 500]).toContain(response.status); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after failed request", async () => { + const beforeFiles = await getTempFiles(); + + // Send invalid image data + const invalidBuffer = Buffer.from("not an image"); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", invalidBuffer, { filename: "invalid.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); + + it("removes disk-backed file after timeout", async () => { + // Create a test app with very short timeout + const timeoutApp = express(); + configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 16384 }); + configureUploadLimits({ maxFileSize: 10 * 1024 * 1024, memoryThreshold: 10 }); + const queueMiddleware = createQueueMiddleware(10); + timeoutApp.use(timeoutMiddleware(10)); // 10ms timeout - very aggressive + timeoutApp.use("/api/image", sharedKeyAuth("test-key"), queueMiddleware, createImageRoutes()); + timeoutApp.use(errorHandler); + + const beforeFiles = await getTempFiles(); + + // Create a large image that will take time to process + const imageBuffer = await sharp({ + create: { width: 4000, height: 4000, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg({ quality: 100 }) + .toBuffer(); + + const response = await request(timeoutApp) + .post("/api/image/thumbnail?width=2000&height=2000&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "large.jpg", contentType: "image/jpeg" }); + + // Should timeout + expect(response.status).toBe(504); + expect(response.body.type).toBe("internal"); + expect(response.body.code).toBe("processing-timeout"); + + // Wait for processing to settle (Sharp may still be working in background) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const afterFiles = await getTempFiles(); + const newFiles = afterFiles.filter((f) => !beforeFiles.includes(f)); + expect(newFiles.length).toBe(0); + }); +}); + +describe("HTTP malformed image handling", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + it("returns 400 for corrupted image in /api/image/info", async () => { + // Create a valid image then truncate it + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/info") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); + + it("returns 400 for corrupted image in /api/image/thumbnail", async () => { + const validBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2)); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", corruptedBuffer, { filename: "corrupted.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(400); + expect(response.body.type).toBe("validation"); + expect(response.body.code).toBe("invalid-image"); + }); +}); + +describe("HTTP quality parameter clamping", () => { + let app: ReturnType; + + beforeAll(() => { + app = createTestApp(); + }); + + it("clamps quality=0 to 1 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=0&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); + + it("clamps quality=101 to 100 at route level", async () => { + const imageBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + + const response = await request(app) + .post("/api/image/thumbnail?width=50&height=50&quality=101&format=jpeg&mode=fit") + .set("x-shared-key", "test-key") + .attach("file", imageBuffer, { filename: "test.jpg", contentType: "image/jpeg" }); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toMatch(/image\/jpeg/); + }); +}); diff --git a/media-processor/test/setup.ts b/media-processor/test/setup.ts new file mode 100644 index 0000000000..b4c413ce0a --- /dev/null +++ b/media-processor/test/setup.ts @@ -0,0 +1 @@ +process.env.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL = "silent"; diff --git a/media-processor/test/upload-storage.test.ts b/media-processor/test/upload-storage.test.ts new file mode 100644 index 0000000000..b2613e533a --- /dev/null +++ b/media-processor/test/upload-storage.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHybridStorage } from "../src/upload-storage.js"; +import type { Request } from "express"; +import { Readable } from "node:stream"; +import { rm } from "node:fs/promises"; + +function mockReq(contentLength?: string): Request { + const headers: Record = {}; + if (contentLength !== undefined) { + headers["content-length"] = contentLength; + } + return { headers } as Request; +} + +function mockFile(content: string = "test content") { + const stream = Readable.from([content]); + return { + fieldname: "file", + originalname: "test.txt", + encoding: "7bit", + mimetype: "text/plain", + stream, + } as Express.Multer.File; +} + +describe("createHybridStorage", () => { + let storage: ReturnType; + let tempDirs: string[] = []; + + beforeEach(() => { + storage = createHybridStorage({ memoryThreshold: 1024 }); + }); + + afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs = []; + }); + + it("uses memory storage when Content-Length is below threshold", async () => { + const req = mockReq("100"); + const file = mockFile("small content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is above threshold", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is absent (chunked transfer)", async () => { + const req = mockReq(); + const file = mockFile("chunked content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is invalid", async () => { + const req = mockReq("not-a-number"); + const file = mockFile("content"); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("removes file from disk", async () => { + const req = mockReq("2048"); + const file = mockFile("x".repeat(2048)); + + const info = await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }); + + tempDirs.push(info.destination); + + await new Promise((resolve, reject) => { + storage._removeFile(req, { ...file, path: info.path } as any, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + }); + + it("uses memory storage when Content-Length is 0", async () => { + const req = mockReq("0"); + const file = mockFile(""); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeUndefined(); + expect((info as any).buffer).toBeDefined(); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length equals threshold", async () => { + const req = mockReq("1024"); + const file = mockFile("x".repeat(1024)); + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("uses disk storage when Content-Length is very large", async () => { + const req = mockReq("1073741824"); // 1GB + const file = mockFile("x"); // Small actual content, but large Content-Length + + await new Promise((resolve, reject) => { + storage._handleFile(req, file, (err, info) => { + if (err) reject(err); + else { + expect(info).toBeDefined(); + expect((info as any).path).toBeDefined(); + expect((info as any).destination).toBeDefined(); + tempDirs.push((info as any).destination); + resolve(); + } + }); + }); + }); + + it("reuses the same temp directory for concurrent uploads", async () => { + const req1 = mockReq("2048"); + const file1 = mockFile("x".repeat(2048)); + const req2 = mockReq("2048"); + const file2 = mockFile("y".repeat(2048)); + + const [info1, info2] = await Promise.all([ + new Promise((resolve, reject) => { + storage._handleFile(req1, file1, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + new Promise((resolve, reject) => { + storage._handleFile(req2, file2, (err, info) => { + if (err) reject(err); + else resolve(info); + }); + }), + ]); + + tempDirs.push(info1.destination); + tempDirs.push(info2.destination); + + // Both uploads should use the same temp directory + expect(info1.destination).toBe(info2.destination); + }); +}); diff --git a/media-processor/tsconfig.json b/media-processor/tsconfig.json new file mode 100644 index 0000000000..b5edd7fa16 --- /dev/null +++ b/media-processor/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/media-processor/vitest.config.ts b/media-processor/vitest.config.ts new file mode 100644 index 0000000000..ad177edacb --- /dev/null +++ b/media-processor/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + envPrefix: [], + test: { + setupFiles: ["./test/setup.ts"], + }, +}); From 636bc22cc41e1629d934f3d73fdc71a2e9132219 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 10:15:41 +0200 Subject: [PATCH 017/298] :sparkles: Add Node.js E2E API tests for backend (#10787) Add end-to-end HTTP tests under backend/test/e2e/ using Node.js built-in test runner (node:test) and native fetch. Tests run through the devenv nginx proxy on port 3450. Test suites (19 tests total): - auth-flow: demo profile creation, login, session cookies, access tokens - export-binfile: file creation, export to asset URL via SSE - asset-download: download with cookie/token auth, 401 without auth, S3 redirect behavior, full export-to-download flow Key findings documented in tests: - nginx @handle_redirect intercepts backend 307 and proxies to S3 directly, stripping the client Authorization header (bug does not reproduce in devenv) - SSE end event uses ~#uri tagged format for URLs - Unauthenticated RPC returns uuid/zero profile (not null) AI-assisted-by: mimo-v2.5-pro Signed-off-by: Andrey Antukh --- backend/package.json | 4 +- backend/pnpm-lock.yaml | 9 ++ backend/test/e2e/asset-download.test.mjs | 145 +++++++++++++++++++++++ backend/test/e2e/auth-flow.test.mjs | 78 ++++++++++++ backend/test/e2e/config.mjs | 7 ++ backend/test/e2e/export-binfile.test.mjs | 87 ++++++++++++++ backend/test/e2e/helpers/auth.mjs | 38 ++++++ backend/test/e2e/helpers/client.mjs | 86 ++++++++++++++ backend/test/e2e/helpers/sse.mjs | 72 +++++++++++ 9 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 backend/test/e2e/asset-download.test.mjs create mode 100644 backend/test/e2e/auth-flow.test.mjs create mode 100644 backend/test/e2e/config.mjs create mode 100644 backend/test/e2e/export-binfile.test.mjs create mode 100644 backend/test/e2e/helpers/auth.mjs create mode 100644 backend/test/e2e/helpers/client.mjs create mode 100644 backend/test/e2e/helpers/sse.mjs diff --git a/backend/package.json b/backend/package.json index a49ad4a8a7..c6baf43f73 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "url": "https://github.com/penpot/penpot" }, "dependencies": { + "eventsource-parser": "^3.0.6", "luxon": "^3.7.2", "sax": "^1.6.1" }, @@ -21,6 +22,7 @@ "scripts": { "lint:clj": "clj-kondo --config-dir ../.clj-kondo --lint ../common/src src/", "check-fmt:clj": "cljfmt check --parallel=true src/ test/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "test:e2e": "node --test --test-concurrency=1 test/e2e/*.test.mjs" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index cb48006797..a0b16465e9 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + eventsource-parser: + specifier: ^3.0.6 + version: 3.1.0 luxon: specifier: ^3.7.2 version: 3.7.2 @@ -63,6 +66,10 @@ packages: supports-color: optional: true + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -216,6 +223,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + eventsource-parser@3.1.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 diff --git a/backend/test/e2e/asset-download.test.mjs b/backend/test/e2e/asset-download.test.mjs new file mode 100644 index 0000000000..99bb0446ba --- /dev/null +++ b/backend/test/e2e/asset-download.test.mjs @@ -0,0 +1,145 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { + setupTestProfile, + createAccessToken, +} from "./helpers/auth.mjs"; +import { rpcPost, getAsset } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createAndExport(cookie, projectId) { + const createRes = await rpcPost( + "create-file", + { name: "E2E Asset Test", projectId }, + { cookieToken: cookie } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { cookieToken: cookie } + ); + assert.equal(exportRes.status, 200); + const assetUrl = extractResult(parseSSE(exportRes.body)); + return assetUrl; +} + +function extractAssetId(assetUrl) { + const match = assetUrl.match(/\/assets\/by-id\/([0-9a-f-]+)/); + return match ? match[1] : null; +} + +describe("asset download", () => { + let profile, cookie, assetUrl, assetId; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + + assetUrl = await createAndExport(cookie, profile.defaultProjectId); + assetId = extractAssetId(assetUrl); + assert.ok(assetId, `should extract asset id from URL: ${assetUrl}`); + }); + + it("asset download with cookie auth succeeds", async () => { + // In devenv, nginx's @handle_redirect intercepts the backend's 307 and + // proxies to S3 directly. The client sees 200 with file content, not 307. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok( + res.body.length > 0 || typeof res.body === "object", + "response should have content" + ); + }); + + it("asset download with access token auth succeeds", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-asset-test"); + const accessToken = tokenObj.token; + + const res = await getAsset(assetId, { accessToken }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + }); + + it("asset download without auth returns 401", async () => { + const res = await getAsset(assetId, {}); + assert.equal(res.status, 401, `expected 401, got ${res.status}`); + }); + + it("asset download returns file content through nginx proxy", async () => { + // The full flow: backend returns 307 with S3 presigned URL, + // nginx intercepts and proxies to S3, client gets 200 with content. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200); + // Response should be a .penpot file (binary/zip content) + assert.ok(res.body, "response should have body"); + }); + + it("follow S3 redirect WITH auth header (bug repro)", async () => { + // In devenv, nginx's @handle_redirect intercepts the 307 and proxies to + // S3 server-side, only forwarding the Host header from X-Host. The client's + // Authorization header is NOT forwarded to S3, so the request succeeds. + // + // In production (no nginx proxy), the backend returns 307 directly. The HTTP + // client follows the redirect and forwards the Authorization: Token header to + // S3, which conflicts with the presigned URL's X-Amz-* params and returns + // 400 InvalidArgument. + // + // This test documents the devenv behavior: nginx strips the auth header + // when proxying to S3, so the download succeeds. + const res = await getAsset(assetId, { cookieToken: cookie }); + assert.equal(res.status, 200, "through nginx, download succeeds"); + assert.ok(res.body, "should have file content"); + }); + + it("full export-to-download flow works end-to-end", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200); + }); + + it("asset URL is accessible immediately after export", async () => { + const url = await createAndExport(cookie, profile.defaultProjectId); + const id = extractAssetId(url); + assert.ok(id); + + const res = await getAsset(id, { cookieToken: cookie }); + assert.equal(res.status, 200, "asset should be accessible right after export"); + }); + + it("token-only: export then download asset with same token", async () => { + const tokenObj = await createAccessToken(cookie, "e2e-token-export-test"); + const token = tokenObj.token; + + const createRes = await rpcPost( + "create-file", + { name: "E2E Token Export Test", projectId: profile.defaultProjectId }, + { accessToken: token } + ); + assert.equal(createRes.status, 200); + const fileId = createRes.body.id; + + const exportRes = await rpcPost( + "export-binfile", + { fileId, includeLibraries: false, embedAssets: true }, + { accessToken: token } + ); + assert.equal(exportRes.status, 200); + + const events = parseSSE(exportRes.body); + const url = extractResult(events); + assert.ok(url, "should get an asset URL from export"); + + const id = extractAssetId(url); + assert.ok(id, `should extract asset id from URL: ${url}`); + + const res = await getAsset(id, { accessToken: token }); + assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.ok(res.body, "response should have file content"); + }); +}); diff --git a/backend/test/e2e/auth-flow.test.mjs b/backend/test/e2e/auth-flow.test.mjs new file mode 100644 index 0000000000..7f9fbb34fd --- /dev/null +++ b/backend/test/e2e/auth-flow.test.mjs @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createDemoProfile, + login, + setupTestProfile, +} from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; + +describe("auth flow", () => { + it("creates a demo profile", async () => { + const { email, password } = await createDemoProfile(); + assert.match(email, /^demo-.*\.demo@example\.com$/); + assert.ok(password.length > 0); + }); + + it("logs in with valid credentials", async () => { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + + assert.equal(profile.email, email); + assert.equal(profile.isDemo, true); + assert.ok(profile.id, "profile should have id"); + assert.ok(profile.defaultProjectId, "profile should have defaultProjectId"); + assert.ok(profile.defaultTeamId, "profile should have defaultTeamId"); + assert.ok(cookie, "cookie should be set"); + }); + + it("login sets session cookie", async () => { + const { email, password } = await createDemoProfile(); + const { cookie } = await login(email, password); + assert.ok(cookie, "auth-token cookie should be extracted"); + assert.ok(cookie.length > 10, "cookie should have meaningful length"); + }); + + it("login fails with wrong password", async () => { + const { email } = await createDemoProfile(); + try { + await login(email, "wrong-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("login fails with non-existent email", async () => { + try { + await login("nonexistent@example.com", "some-password"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e.message.includes("Login failed")); + } + }); + + it("authenticated RPC with cookie", async () => { + const { profile, cookie } = await setupTestProfile(); + const res = await rpcPost("get-profile", {}, { cookieToken: cookie }); + assert.equal(res.status, 200); + assert.equal(res.body.id, profile.id); + assert.equal(res.body.email, profile.email); + }); + + it("unauthenticated RPC returns anonymous profile", async () => { + const res = await rpcPost("get-profile", {}); + assert.equal(res.status, 200); + // Anonymous profile has uuid/zero as id + assert.equal(res.body.id, "00000000-0000-0000-0000-000000000000"); + }); + + it("setupTestProfile returns all fields", async () => { + const { profile, cookie, email, password } = await setupTestProfile(); + assert.ok(profile.id); + assert.ok(profile.defaultProjectId); + assert.ok(cookie); + assert.ok(email); + assert.ok(password); + }); +}); diff --git a/backend/test/e2e/config.mjs b/backend/test/e2e/config.mjs new file mode 100644 index 0000000000..697f20f8c4 --- /dev/null +++ b/backend/test/e2e/config.mjs @@ -0,0 +1,7 @@ +const config = Object.freeze({ + baseUrl: process.env.PENPOT_BASE_URL || "http://localhost:3450", + email: process.env.PENPOT_EMAIL || null, + password: process.env.PENPOT_PASSWORD || null, +}); + +export default config; diff --git a/backend/test/e2e/export-binfile.test.mjs b/backend/test/e2e/export-binfile.test.mjs new file mode 100644 index 0000000000..f6be598ec5 --- /dev/null +++ b/backend/test/e2e/export-binfile.test.mjs @@ -0,0 +1,87 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { setupTestProfile } from "./helpers/auth.mjs"; +import { rpcPost } from "./helpers/client.mjs"; +import { parseSSE, extractResult } from "./helpers/sse.mjs"; + +async function createFile(cookie, projectId, name = "E2E Test File") { + const res = await rpcPost( + "create-file", + { name, projectId }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`); + return res.body; +} + +async function exportFile(cookie, fileId) { + const res = await rpcPost( + "export-binfile", + { + fileId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.equal(res.status, 200, `export-binfile failed: ${JSON.stringify(res.body)}`); + + const events = parseSSE(res.body); + const assetUrl = extractResult(events); + return assetUrl; +} + +describe("export-binfile", () => { + let profile, cookie; + + before(async () => { + const setup = await setupTestProfile(); + profile = setup.profile; + cookie = setup.cookie; + }); + + it("creates a file via API", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + assert.ok(file.id, "file should have an id"); + assert.equal(file.name, "E2E Test File"); + }); + + it("export returns an asset URL", async () => { + const file = await createFile(cookie, profile.defaultProjectId); + const assetUrl = await exportFile(cookie, file.id); + assert.ok( + typeof assetUrl === "string" && assetUrl.includes("/assets/by-id/"), + `asset URL should contain /assets/by-id/, got: ${assetUrl}` + ); + assert.match(assetUrl, /\/assets\/by-id\/[0-9a-f-]+$/); + }); + + it("export with invalid file-id returns error", async () => { + const fakeId = "00000000-0000-0000-0000-000000000000"; + const res = await rpcPost( + "export-binfile", + { + fileId: fakeId, + includeLibraries: false, + embedAssets: true, + }, + { cookieToken: cookie } + ); + assert.ok( + res.body.type || res.status !== 200, + "should return error for non-existent file" + ); + }); + + it("export requires authentication", async () => { + const res = await rpcPost("export-binfile", { + fileId: "00000000-0000-0000-0000-000000000000", + includeLibraries: false, + embedAssets: true, + }); + assert.ok( + res.body.type || res.status !== 200, + "should require authentication" + ); + }); +}); diff --git a/backend/test/e2e/helpers/auth.mjs b/backend/test/e2e/helpers/auth.mjs new file mode 100644 index 0000000000..07d34ef0f0 --- /dev/null +++ b/backend/test/e2e/helpers/auth.mjs @@ -0,0 +1,38 @@ +import { rpcPost, extractCookie } from "./client.mjs"; + +export async function createDemoProfile() { + const res = await rpcPost("create-demo-profile", {}); + if (res.body.type === "validation" || res.body.type === "restriction") { + throw new Error( + `Failed to create demo profile: ${res.body.code} - ${res.body.hint || ""}` + ); + } + return { email: res.body.email, password: res.body.password }; +} + +export async function login(email, password) { + const res = await rpcPost("login-with-password", { email, password }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Login failed: ${JSON.stringify(res.body)}` + ); + } + const cookie = extractCookie(res.setCookie); + return { profile: res.body, cookie }; +} + +export async function createAccessToken(cookie, name = "e2e-test-token") { + const res = await rpcPost("create-access-token", { name }, { cookieToken: cookie }); + if (res.status !== 200 || res.body.type) { + throw new Error( + `Create access token failed: ${JSON.stringify(res.body)}` + ); + } + return res.body; +} + +export async function setupTestProfile() { + const { email, password } = await createDemoProfile(); + const { profile, cookie } = await login(email, password); + return { profile, cookie, email, password }; +} diff --git a/backend/test/e2e/helpers/client.mjs b/backend/test/e2e/helpers/client.mjs new file mode 100644 index 0000000000..1bfe8750d6 --- /dev/null +++ b/backend/test/e2e/helpers/client.mjs @@ -0,0 +1,86 @@ +import config from "../config.mjs"; + +async function parseResponse(response) { + const contentType = response.headers.get("content-type") || ""; + const setCookie = response.headers.get("set-cookie") || null; + + let body; + if (contentType.includes("application/json")) { + body = await response.json(); + } else { + body = await response.text(); + } + + return { + status: response.status, + headers: response.headers, + body, + setCookie, + }; +} + +export function extractCookie(setCookieHeader, name = "auth-token") { + if (!setCookieHeader) return null; + const match = setCookieHeader.match(new RegExp(`${name}=([^;]+)`)); + return match ? match[1] : null; +} + +export async function rpcPost(method, body = {}, { cookieToken, accessToken } = {}) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + return parseResponse(response); +} + +export async function multipartPost(method, formData, { cookieToken } = {}) { + const headers = { + Accept: "application/json", + }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + + const response = await fetch(`${config.baseUrl}/api/main/methods/${method}`, { + method: "POST", + headers, + body: formData, + }); + + return parseResponse(response); +} + +export async function getAsset( + id, + { cookieToken, accessToken, redirect = "manual" } = {} +) { + const headers = { Accept: "application/json" }; + if (cookieToken) { + headers.Cookie = `auth-token=${cookieToken}`; + } + if (accessToken) { + headers.Authorization = `Token ${accessToken}`; + } + + const response = await fetch(`${config.baseUrl}/assets/by-id/${id}`, { + method: "GET", + headers, + redirect, + }); + + return parseResponse(response); +} + diff --git a/backend/test/e2e/helpers/sse.mjs b/backend/test/e2e/helpers/sse.mjs new file mode 100644 index 0000000000..02f3845d20 --- /dev/null +++ b/backend/test/e2e/helpers/sse.mjs @@ -0,0 +1,72 @@ +import { createParser } from "eventsource-parser"; + +export function parseSSE(text) { + const events = []; + const parser = createParser({ + onEvent(event) { + events.push({ event: event.event || "message", data: event.data }); + }, + }); + parser.feed(text); + return events; +} + +export function extractResult(events) { + const endEvent = events.find((e) => e.event === "end"); + if (!endEvent) { + const errEvent = events.find((e) => e.event === "error"); + if (errEvent) { + throw new Error(`SSE error: ${errEvent.data}`); + } + throw new Error(`No end event found in SSE stream. Events: ${JSON.stringify(events)}`); + } + + const raw = JSON.parse(endEvent.data); + + // Transit JSON verbose format: + // For URIs (e.g. asset URL): {"~#uri":"https://..."} + // For objects: {"~:key":"val",...} or ["^ ","~:key","val",...] + // For strings: plain string + if (raw && typeof raw === "object") { + // Tagged URI + if ("~#uri" in raw) { + return raw["~#uri"]; + } + // Transit map with ~:value key + if ("~:value" in raw) { + const value = raw["~:value"]; + if (Array.isArray(value)) { + return transitArrayToObj(value); + } + return value; + } + // Direct transit map (keys starting with ~:) + const firstKey = Object.keys(raw)[0]; + if (firstKey && firstKey.startsWith("~:")) { + return transitMapToObj(raw); + } + } + + return raw; +} + +function transitArrayToObj(arr) { + // Transit verbose object: ["^ ","~:key1","val1","~:key2","val2",...] + const obj = {}; + for (let i = 1; i < arr.length; i += 2) { + const key = arr[i].replace(/^~:/, ""); + const val = arr[i + 1]; + obj[key] = val; + } + return obj; +} + +function transitMapToObj(map) { + // Transit verbose map: {"~:key1":"val1","~:key2":"val2",...} + const obj = {}; + for (const [key, val] of Object.entries(map)) { + const cleanKey = key.replace(/^~:/, ""); + obj[cleanKey] = val; + } + return obj; +} From 6628f0a134245f4cbcf13de9649d058a1fb935ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 5 Aug 2026 11:30:13 +0200 Subject: [PATCH 018/298] :bug: Adjust button icon visibility (#11070) --- frontend/src/app/main/ui/dashboard/sidebar.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index cc2e882e51..836e6b6e93 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -734,8 +734,8 @@ display: flex; justify-content: center; align-items: center; - width: $sz-32; - height: $sz-32; + width: $sz-48; + height: $sz-48; &:hover { --icon-stroke: var(--color-accent-primary); From 1b26b69b25dfdf6f4d4c054cf75aaa359fa63530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 5 Aug 2026 12:53:20 +0200 Subject: [PATCH 019/298] :bug: Fix Firefox not inserting emoji from MacOS Character Viewer (#11072) --- .../app/main/ui/workspace/shapes/text/v3_editor.cljs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index e626fa2b74..df86b7bb2d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -421,9 +421,15 @@ on-blur (mf/use-fn - (fn [^js _event] - (sync-wasm-text-editor-content! {:finalize? true}) - (wasm.api/text-editor-blur))) + (fn [^js event] + ;; MacOS Character Viewer on Firefox fires a `blur` when it opens. + ;; To avoid losing the selected character, we need guard against + ;; `activeElement` being the surface itself. + (when-not (and (some? event) + (= (.-activeElement js/document) + (mf/ref-val contenteditable-ref))) + (sync-wasm-text-editor-content! {:finalize? true}) + (wasm.api/text-editor-blur)))) style #js {:pointerEvents "all" "--editor-container-width" (dm/str width "px") From 6df045b194b6393553f7b081f5476916ec1b4ab0 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 5 Aug 2026 13:10:21 +0200 Subject: [PATCH 020/298] :bug: Fix text creating on draft.js (#11086) --- frontend/pnpm-lock.yaml | 22 +++++++++++++++------- frontend/pnpm-workspace.yaml | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a8d7873515..c6453a0818 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,7 +18,7 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: 1.62.1 - immutable@<4.3.9: ^4.3.9 + immutable@>=4.0.0 <4.3.9: ^4.3.9 patchedDependencies: '@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 @@ -2193,6 +2193,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -3510,8 +3515,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immutable@4.3.9: - resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + immutable@3.8.3: + resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==} + engines: {node: '>=0.10.0'} immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -7559,11 +7565,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 '@webcontainer/env@1.1.1': {} @@ -8332,7 +8340,7 @@ snapshots: draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) - immutable: 4.3.9 + immutable: 3.8.3 object-assign: 4.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -9081,7 +9089,7 @@ snapshots: ignore@7.0.6: {} - immutable@4.3.9: {} + immutable@3.8.3: {} immutable@5.1.9: {} @@ -11197,7 +11205,7 @@ snapshots: unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) compare-versions: 6.1.1 debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 6fb26e5399..5444a142b3 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -32,4 +32,4 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: "1.62.1" - immutable@<4.3.9: ^4.3.9 + immutable@>=4.0.0 <4.3.9: ^4.3.9 From a2968defbe33acf9f9467ed1591d14c2788f2934 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 14:18:14 +0200 Subject: [PATCH 021/298] :bug: Add bounding box dimension limit to prevent export DoS (#11042) Add max-export-dimension constant (100000 units) and validate in calculate-dimensions. Reject exports when bounding box width, height, or position exceeds the limit to prevent resource exhaustion in the Chromium export pool. AI-assisted-by: mimo-v2.5-pro --- frontend/src/app/main/render.cljs | 20 +++-- .../render_dimensions_test.cljs | 78 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 frontend/test/frontend_tests/render_dimensions_test.cljs diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 0655545429..31108fa9f0 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -15,6 +15,7 @@ ["react-dom/server" :as rds] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] @@ -59,6 +60,7 @@ [rumext.v2 :as mf])) (def ^:const viewbox-decimal-precision 3) +(def ^:const max-export-dimension 100000) (def ^:private default-color clr/canvas) (mf/defc background @@ -82,12 +84,20 @@ (let [bounds (->> root-objects (map (partial gsb/get-object-bounds objects)) - (grc/join-rects))] + (grc/join-rects)) + bounds (-> bounds + (update :x mth/finite 0) + (update :y mth/finite 0) + (update :width mth/finite 100000) + (update :height mth/finite 100000))] + (when (or (> (:width bounds) max-export-dimension) + (> (:height bounds) max-export-dimension) + (> (+ (:x bounds) (:width bounds)) max-export-dimension) + (> (+ (:y bounds) (:height bounds)) max-export-dimension)) + (ex/raise :type :validation + :code :export-area-too-large + :hint "export area exceeds maximum allowed dimensions")) (-> bounds - (update :x mth/finite 0) - (update :y mth/finite 0) - (update :width mth/finite 100000) - (update :height mth/finite 100000) (grc/update-rect :position) (grc/fix-aspect-ratio aspect-ratio)))))) diff --git a/frontend/test/frontend_tests/render_dimensions_test.cljs b/frontend/test/frontend_tests/render_dimensions_test.cljs new file mode 100644 index 0000000000..d3773f6b36 --- /dev/null +++ b/frontend/test/frontend_tests/render_dimensions_test.cljs @@ -0,0 +1,78 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.render-dimensions-test + (:require + [app.common.geom.rect :as grc] + [app.common.geom.shapes.bounds :as gsb] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.main.render :as render] + [cljs.test :as t :include-macros true])) + +(defn- make-objects + "Create a proper objects map with a root frame and the given shapes." + [& shapes] + (let [root-frame (cts/setup-shape {:id uuid/zero + :type :frame + :parent-id uuid/zero + :frame-id uuid/zero + :name "Root Frame" + :shapes (mapv :id shapes)}) + objects {uuid/zero root-frame}] + (reduce (fn [objs shape] + (assoc objs (:id shape) (assoc shape :frame-id uuid/zero))) + objects + shapes))) + +(t/deftest calculate-dimensions-normal-bounds + (t/testing "Normal bounding box should pass" + (let [shape1 (cts/setup-shape {:type :rect :x 100 :y 100 :width 200 :height 150}) + shape2 (cts/setup-shape {:type :rect :x 400 :y 300 :width 100 :height 100}) + objects (make-objects shape1 shape2) + result (render/calculate-dimensions objects nil)] + (t/is (some? result)) + (t/is (<= (:width result) render/max-export-dimension)) + (t/is (<= (:height result) render/max-export-dimension))))) + +(t/deftest calculate-dimensions-extreme-width + (t/testing "Extreme width should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 200000 :height 100}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-extreme-height + (t/testing "Extreme height should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 100 :height 200000}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-extreme-position + (t/testing "Shape at extreme position should throw export-area-too-large" + (let [shape (cts/setup-shape {:type :rect :x 500000 :y 500000 :width 100 :height 100}) + objects (make-objects shape)] + (t/is (thrown-with-msg? + js/Error + #"export area exceeds maximum allowed dimensions" + (render/calculate-dimensions objects nil)))))) + +(t/deftest calculate-dimensions-exactly-at-limit + (t/testing "Bounding box exactly at limit should pass" + (let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width render/max-export-dimension :height render/max-export-dimension}) + objects (make-objects shape) + result (render/calculate-dimensions objects nil)] + (t/is (some? result)) + (t/is (<= (:width result) render/max-export-dimension)) + (t/is (<= (:height result) render/max-export-dimension))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 21c7d23c06..e0c06b4a08 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -51,6 +51,7 @@ [frontend-tests.plugins.tokens-test] [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] + [frontend-tests.render-dimensions-test] [frontend-tests.render-wasm.process-objects-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.svg-fills-test] @@ -160,6 +161,7 @@ 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-shortcuts-test From 35bdcde1837864a60765e694fdb0600efc97d0b3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 5 Aug 2026 17:12:09 +0200 Subject: [PATCH 022/298] :zap: Avoid per-tile image_snapshot when filling atlases (#11093) Copy Current into DocAtlas and the tile atlas with Surface::draw instead of image_snapshot_with_bounds, matching the interactive path and removing a GPU sync stall on every completed tile. --- render-wasm/src/render/surfaces.rs | 109 +++++++++++++++++------------ 1 file changed, 64 insertions(+), 45 deletions(-) diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index e68c0df0c3..5d9def6f92 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -27,6 +27,29 @@ const TILE_DRAWABLE_RECT: IRect = IRect { }; const DOC_ATLAS_MAX_DIM: i32 = 4096; +/// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without +/// `image_snapshot` (avoids per-tile sync stalls on WebGL). +fn draw_surface_src_rect_to_dst( + from: &mut skia::Surface, + to_canvas: &skia::Canvas, + src: skia::Rect, + dst: skia::Rect, + sampling: skia::SamplingOptions, +) { + if src.is_empty() || dst.is_empty() { + return; + } + to_canvas.save(); + to_canvas.clip_rect(dst, None, true); + let sx = dst.width() / src.width(); + let sy = dst.height() / src.height(); + to_canvas.translate((dst.left, dst.top)); + to_canvas.scale((sx, sy)); + to_canvas.translate((-src.left, -src.top)); + from.draw(to_canvas, (0.0, 0.0), sampling, None); + to_canvas.restore(); +} + pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { // First we retrieve the extended area of the viewport that we could render. let TileRect(isx, isy, iex, iey) = @@ -238,17 +261,20 @@ impl DocAtlas { Ok(()) } - fn blit_tile_image_into_atlas( + /// Blit a Current-surface drawable rect into the doc atlas without + /// `image_snapshot` (GPU→GPU draw; avoids per-tile sync stalls). + fn blit_current_drawable_into_atlas( &mut self, gpu_state: &mut GpuState, - tile_image: &skia::Image, + current: &mut skia::Surface, + drawable_src: skia::Rect, tile_doc_rect: skia::Rect, + sampling: skia::SamplingOptions, ) -> Result<()> { - if tile_doc_rect.is_empty() { + if tile_doc_rect.is_empty() || drawable_src.is_empty() { return Ok(()); } - // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. let mut clipped_doc_rect = tile_doc_rect; if let Some(bounds) = self.doc_bounds { if !clipped_doc_rect.intersect(bounds) { @@ -261,7 +287,6 @@ impl DocAtlas { self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; - // Destination is document-space rect mapped into atlas pixel coords. let dst = skia::Rect::from_xywh( (clipped_doc_rect.left - self.origin.x) * self.scale, (clipped_doc_rect.top - self.origin.y) * self.scale, @@ -269,24 +294,18 @@ impl DocAtlas { clipped_doc_rect.height() * self.scale, ); - // Compute source rect in tile_image pixel coordinates. - let img_w = tile_image.width() as f32; - let img_h = tile_image.height() as f32; let tw = tile_doc_rect.width().max(1.0); let th = tile_doc_rect.height().max(1.0); - - let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; - let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; - let sw = (clipped_doc_rect.width() / tw) * img_w; - let sh = (clipped_doc_rect.height() / th) * img_h; - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - - self.surface.canvas().draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &skia::Paint::default(), + let dw = drawable_src.width(); + let dh = drawable_src.height(); + let src = skia::Rect::from_xywh( + drawable_src.left + ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * dw, + drawable_src.top + ((clipped_doc_rect.top - tile_doc_rect.top) / th) * dh, + (clipped_doc_rect.width() / tw) * dw, + (clipped_doc_rect.height() / th) * dh, ); + + draw_surface_src_rect_to_dst(current, self.surface.canvas(), src, dst, sampling); Ok(()) } @@ -1198,34 +1217,34 @@ impl Surfaces { tile_doc_rect: skia::Rect, ) { let gpu_state = get_gpu_state(); - let rect = TILE_DRAWABLE_RECT; + let src = skia::Rect::from(TILE_DRAWABLE_RECT); + let sampling = self.sampling_options; - let tile_image_opt = self.current.image_snapshot_with_bounds(rect); - if let Some(tile_image) = tile_image_opt { - if !skip_cache_surface { - // Draw to cache surface for render_from_cache - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); - } + // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). + let _ = self.atlas.blit_current_drawable_into_atlas( + gpu_state, + &mut self.current, + src, + tile_doc_rect, + sampling, + ); + self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); - // Incrementally update persistent 1:1 atlas in document space. - // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self - .atlas - .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + let tile_ref = self.tiles.add(tile_viewbox, tile); + let dst = tile_ref.rect; + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); - // Draws current tile into tile atlas - let tile_ref = self.tiles.add(tile_viewbox, tile); - self.tile_atlas.canvas().draw_image_rect( - &tile_image, - None, - tile_ref.rect, - &skia::Paint::default(), + if !skip_cache_surface { + // Optional legacy Cache surface fill (debug). Pan/zoom preview + // uses DocAtlas + tile-atlas textures via render_from_cache. + let mut current = self.current.clone(); + draw_surface_src_rect_to_dst( + &mut current, + self.cache.canvas(), + src, + *tile_rect, + sampling, ); } } From 49276886f3a314dab358b052d60f15c44e2c5e91 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:28:58 +0200 Subject: [PATCH 023/298] :bug: Fix some issues with immutablejs incompatibility --- frontend/packages/draft-js/package.json | 3 +-- frontend/pnpm-lock.yaml | 24 ++++++++++++++---------- frontend/pnpm-workspace.yaml | 1 - 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index a4595a5d85..e2955437f2 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -8,8 +8,7 @@ "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { - "draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d", - "immutable": "^5.1.9" + "draft-js": "penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d" }, "peerDependencies": { "react": ">=0.17.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a8d7873515..bf162c0db6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,7 +18,6 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: 1.62.1 - immutable@<4.3.9: ^4.3.9 patchedDependencies: '@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 @@ -271,9 +270,6 @@ importers: draft-js: specifier: penpot/draft-js.git#c58ebd9429a6359d72a88cff87e078aaf6fe285d version: https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - immutable: - specifier: ^5.1.9 - version: 5.1.9 react: specifier: '>=0.17.0' version: 19.2.8 @@ -2193,6 +2189,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -3510,8 +3511,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immutable@4.3.9: - resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + immutable@3.8.3: + resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==} + engines: {node: '>=0.10.0'} immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -7559,11 +7561,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 '@webcontainer/env@1.1.1': {} @@ -8332,7 +8336,7 @@ snapshots: draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) - immutable: 4.3.9 + immutable: 3.8.3 object-assign: 4.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -9081,7 +9085,7 @@ snapshots: ignore@7.0.6: {} - immutable@4.3.9: {} + immutable@3.8.3: {} immutable@5.1.9: {} @@ -11197,7 +11201,7 @@ snapshots: unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) compare-versions: 6.1.1 debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 6fb26e5399..2c3a2c026f 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -32,4 +32,3 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: "1.62.1" - immutable@<4.3.9: ^4.3.9 From 36e76da26cde748fdbbc1292d408eafb92cd6b2f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:30:36 +0200 Subject: [PATCH 024/298] Revert ":bug: Fix text creating on draft.js (#11086)" This reverts commit 6df045b194b6393553f7b081f5476916ec1b4ab0. --- frontend/pnpm-lock.yaml | 22 +++++++--------------- frontend/pnpm-workspace.yaml | 2 +- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c6453a0818..a8d7873515 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,7 +18,7 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: 1.62.1 - immutable@>=4.0.0 <4.3.9: ^4.3.9 + immutable@<4.3.9: ^4.3.9 patchedDependencies: '@zip.js/zip.js@2.8.34': 7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95 @@ -2193,11 +2193,6 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -3515,9 +3510,8 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immutable@3.8.3: - resolution: {integrity: sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==} - engines: {node: '>=0.10.0'} + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -7565,13 +7559,11 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28(typescript@6.0.3)': + '@volar/typescript@2.4.28': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 - optionalDependencies: - typescript: 6.0.3 '@webcontainer/env@1.1.1': {} @@ -8340,7 +8332,7 @@ snapshots: draft-js@https://codeload.github.com/penpot/draft-js/tar.gz/c58ebd9429a6359d72a88cff87e078aaf6fe285d(encoding@0.1.13)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: fbjs: 3.0.5(encoding@0.1.13) - immutable: 3.8.3 + immutable: 4.3.9 object-assign: 4.1.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -9089,7 +9081,7 @@ snapshots: ignore@7.0.6: {} - immutable@3.8.3: {} + immutable@4.3.9: {} immutable@5.1.9: {} @@ -11205,7 +11197,7 @@ snapshots: unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@volar/typescript': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28 compare-versions: 6.1.1 debug: 4.4.3(supports-color@10.2.2) kolorist: 1.8.0 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 5444a142b3..6fb26e5399 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -32,4 +32,4 @@ overrides: postcss@<8.5.10: ^8.5.10 yaml@>=2.0.0 <2.8.3: ^2.8.3 playwright@>=1.61.1 <2.0.0-0: "1.62.1" - immutable@>=4.0.0 <4.3.9: ^4.3.9 + immutable@<4.3.9: ^4.3.9 From 4f7bb94bb1f00037dd6d2c0b2aa2c2a7e87c0ddb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:35:59 +0200 Subject: [PATCH 025/298] :bug: Add size limit and rate limiting to send-user-feedback (#10979) (#10990) Prevent email bombing attacks on the send-user-feedback endpoint by limiting the error-report field to 1MiB and adding climit rate limits: by-profile (1 permit, queue 3) and global (4 permits), configured in climit.edn. Make the schema public so it can be exercised by tests, and add schema validation tests covering the new size limit. AI-assisted-by: qwen3.7-plus --- backend/resources/climit.edn | 8 +++- backend/src/app/rpc/commands/feedback.clj | 9 +++-- .../test/backend_tests/rpc_feedback_test.clj | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 backend/test/backend_tests/rpc_feedback_test.clj diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 7d8234499b..66ac82b174 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -39,4 +39,10 @@ {:permits 3} :create-file-snapshot/by-profile - {:permits 1 :queue 2 :timeout 60000}} + {:permits 1 :queue 2 :timeout 60000} + + :send-user-feedback/global + {:permits 4} + + :send-user-feedback/by-profile + {:permits 1 :queue 3}} diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index 565f41d30e..b70341fc33 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -14,22 +14,25 @@ [app.db :as db] [app.email :as eml] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) (declare ^:private send-user-feedback!) -(def ^:private schema:send-user-feedback +(def schema:send-user-feedback [:map {:title "send-user-feedback"} [:subject [:string {:max 500}]] [:content [:string {:max 2500}]] [:type {:optional true} :string] [:error-href {:optional true} [:string {:max 2500}]] - [:error-report {:optional true} :string]]) + [:error-report {:optional true} [:string {:max 1048576}]]]) (sv/defmethod ::send-user-feedback - {::doc/added "1.18" + {::climit/id [[:send-user-feedback/by-profile ::rpc/profile-id] + [:send-user-feedback/global]] + ::doc/added "1.18" ::sm/params schema:send-user-feedback} [{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}] (when-not (contains? cf/flags :user-feedback) diff --git a/backend/test/backend_tests/rpc_feedback_test.clj b/backend/test/backend_tests/rpc_feedback_test.clj new file mode 100644 index 0000000000..13231db61b --- /dev/null +++ b/backend/test/backend_tests/rpc_feedback_test.clj @@ -0,0 +1,39 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-feedback-test + (:require + [app.common.schema :as sm] + [app.rpc.commands.feedback :as feedback] + [clojure.test :as t])) + +(t/deftest send-user-feedback-schema-validation + (let [schema feedback/schema:send-user-feedback] + + (t/testing "accepts valid feedback with all fields" + (let [params {:subject "Test subject" + :content "Test content" + :type "bug" + :error-href "https://example.com/error" + :error-report "Error details here"}] + (t/is (sm/valid? schema params)))) + + (t/testing "accepts feedback without optional fields" + (let [params {:subject "Test subject" + :content "Test content"}] + (t/is (sm/valid? schema params)))) + + (t/testing "accepts error-report up to 1MiB" + (let [params {:subject "Test subject" + :content "Test content" + :error-report (apply str (repeat 1048576 "x"))}] + (t/is (sm/valid? schema params)))) + + (t/testing "rejects error-report exceeding 1MiB" + (let [params {:subject "Test subject" + :content "Test content" + :error-report (apply str (repeat 1048577 "x"))}] + (t/is (not (sm/valid? schema params))))))) From 9242556da6b87d646d766385c24575cc71a1c6a5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:37:27 +0200 Subject: [PATCH 026/298] :bug: Close import-binfile schema and remove file-id parameter (#10994) Add :closed true to schema:import-binfile to reject unknown keys. Remove file-id from handler destructuring, config binding, and audit props to prevent specifying a target file on import. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/rpc/commands/binfile.clj | 21 +++------- .../test/backend_tests/rpc_binfile_test.clj | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 backend/test/backend_tests/rpc_binfile_test.clj diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 74101eadbc..78ba08e5fd 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -118,11 +118,10 @@ (def ^:private schema:import-binfile [:and - [:map {:title "import-binfile"} + [:map {:title "import-binfile" :closed true} [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:project-id ::sm/uuid] - [:file-id {:optional true} ::sm/uuid] [:version {:optional true} ::sm/int] [:file {:optional true} media.v/schema:upload] [:upload-id {:optional true} ::sm/uuid]] @@ -131,35 +130,26 @@ (or (some? file) (some? upload-id)))]]) (sv/defmethod ::import-binfile - "Import a penpot file in a binary format. If `file-id` is provided, - an in-place import will be performed instead of creating a new file. - - The in-place imports are only supported for binfile-v3 and when a - .penpot file only contains one penpot file. + "Import a penpot file in a binary format. The file content may be provided either as a multipart `file` upload or as an `upload-id` referencing a completed chunked-upload session, which allows importing files larger than the multipart size limit. " {::doc/added "1.15" - ::doc/changes ["1.20" "Add file-id param for in-place import" - "1.20" "Set default version to 3" - "2.15" "Add upload-id param for chunked upload support"] + ::doc/changes [["1.20" "Set default version to 3"] + ["2.15" "Add upload-id param for chunked upload support"]] ::webhooks/event? true ::sse/stream? true ::sm/params schema:import-binfile} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) (let [version (or version 3) params (-> params (assoc :profile-id profile-id) (assoc :version version)) - cfg (cond-> cfg - (uuid? file-id) - (assoc ::bfc/file-id file-id)) - params (if (some? upload-id) (let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)] @@ -174,6 +164,5 @@ (with-meta (sse/response (partial import-binfile cfg params)) {::audit/props {:file nil - :file-id file-id :generated-by (:generated-by manifest) :referer (:referer manifest)}}))) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj new file mode 100644 index 0000000000..5ebf83bf18 --- /dev/null +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -0,0 +1,42 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-binfile-test + (:require + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.rpc :as-alias rpc] + [app.rpc.commands.binfile :as binfile] + [backend-tests.helpers :as th] + [clojure.test :as t] + [datoteka.fs :as fs])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest import-binfile-schema-rejects-file-id + ;; N1-06: file-id parameter must be removed from schema for security + ;; The schema should not accept file-id as a valid parameter + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema) + + ;; Valid params without file-id + valid-params {:name "test" + :project-id (uuid/random) + :version 3 + :upload-id (uuid/random)} + + ;; Params with file-id (should be rejected after fix) + params-with-file-id (assoc valid-params :file-id (uuid/random))] + + ;; Valid params without file-id should pass + (t/is (true? (validator valid-params)) + "params without file-id should be valid") + + ;; Params with file-id should fail validation after fix + ;; (Currently this will fail because file-id is still in schema) + (t/is (false? (validator params-with-file-id)) + "params with file-id should be rejected"))) From fb0727389791a92303d6fffaba0cf05b3aa400dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:40:04 +0200 Subject: [PATCH 027/298] :bug: Validate library belongs to same team in link/unlink/sync handlers (#11016) Add check-library-team-ownership! helper that verifies both the file and library share the same team before creating or modifying library relations. This prevents cross-team library injection where a user with edit permissions on files in different teams could link them across team boundaries. Applied to link-file-to-library, unlink-file-from-library, and update-file-library-sync-status handlers. AI-assisted-by: mimo-v2.5 --- backend/src/app/rpc/commands/files.clj | 22 ++++++++++++++ backend/test/backend_tests/rpc_file_test.clj | 32 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index e10c85a7bd..69bead539d 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1069,6 +1069,25 @@ [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg delete-file (assoc params :profile-id profile-id))) +;; --- Library relation helpers + +(defn- check-library-team-ownership! + "Verify that file and library belong to the same team. + Prevents cross-team library relation injection." + [conn file-id library-id] + (let [sql "SELECT EXISTS ( + SELECT 1 FROM file AS f + JOIN project AS fp ON (fp.id = f.project_id) + JOIN file AS l ON (l.id = ?) + JOIN project AS lp ON (lp.id = l.project_id) + WHERE f.id = ? AND fp.team_id = lp.team_id + ) AS ok" + row (db/exec-one! conn [sql library-id file-id])] + (when-not (:ok row) + (ex/raise :type :not-found + :code :object-not-found + :hint "file and library must belong to the same team")))) + ;; --- MUTATION COMMAND: link-file-to-library (def sql:link-file-to-library @@ -1104,6 +1123,7 @@ (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (let [transitive-deps (bfc/get-libraries cfg [library-id])] (when (contains? transitive-deps file-id) @@ -1135,6 +1155,7 @@ [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (unlink-file-from-library conn params) nil) @@ -1159,6 +1180,7 @@ [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}] (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + (check-library-team-ownership! conn file-id library-id) (update-sync conn params)) ;; --- MUTATION COMMAND: ignore-sync diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 1c07f35971..18ad14e639 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -983,6 +983,38 @@ (t/is (some? sync)) (t/is (some? (:synced-at sync))))) +(t/deftest link-file-to-library-rejects-cross-team + ;; N1-08: A file in team2 must not be linked to a library in team1, + ;; even when the user has edit permissions on both (BOLA / CWE-639). + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + team1 (th/create-team* 1 {:profile-id (:id prof1)}) + team2 (th/create-team* 2 {:profile-id (:id prof2)}) + proj1 (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:id team1)}) + proj2 (th/create-project* 2 {:profile-id (:id prof2) + :team-id (:id team2)}) + lib (th/create-file* 1 {:project-id (:id proj1) + :profile-id (:id prof1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:id proj2) + :profile-id (:id prof2)})] + + ;; Add prof2 as editor to team1 so they have edit access to the library + (th/db-insert! :team-profile-rel {:team-id (:id team1) + :profile-id (:id prof2) + :is-owner false + :is-admin false + :can-edit true}) + + ;; prof2 tries to link file2 (team2) to lib (team1) — must fail + (let [data {::th/type :link-file-to-library + ::rpc/profile-id (:id prof2) + :file-id (:id file2) + :library-id (:id lib)} + out (th/command! data)] + (t/is (some? (:error out)))))) + (t/deftest update-file-library-sync-status-updates-sync-row (let [profile (th/create-profile* 1) file1 (th/create-file* 1 {:project-id (:default-project-id profile) From 689d3a1be2c3a3bab647c2bea87f74e10598ca5c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:40:58 +0200 Subject: [PATCH 028/298] :bug: Add max-object-size guard to read-obj! in v1 parser (#11018) Prevent unbounded memory allocation when a crafted binfile specifies an excessively large object size. Apply the same 100 MiB limit that read-stream! already enforces. AI-assisted-by: mimo-v2.5 --- backend/src/app/binfile/v1.clj | 4 +++ backend/test/backend_tests/binfile_test.clj | 29 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 8dc4120159..5f1834cb74 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -174,6 +174,10 @@ (assert-mark m :obj) (let [size (read-long! input)] (assert (pos? size) "incorrect header size found on reading header") + (when (> size bfc/max-object-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (dm/str "unable to import object with size " size " bytes"))) (let [buff (byte-array size)] (read-bytes! input buff) (fres/decode buff))))) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 84310241d4..05f1525c5e 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -8,6 +8,7 @@ "Internal binfile test, no RPC involved" (:require [app.binfile.common :as bfc] + [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] [app.common.features :as cfeat] [app.common.files.validate :as cfv] @@ -25,7 +26,10 @@ [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] - [datoteka.io :as io])) + [datoteka.io :as io]) + (:import + java.io.ByteArrayInputStream + java.io.DataInputStream)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -202,3 +206,26 @@ (v3/import-files!))] (t/is (= (count result) 1)) (t/is (every? uuid? result))))) + +(t/deftest read-obj-rejects-oversized-buffer + ;; N1-07: read-obj! must reject objects exceeding max-object-size + ;; before attempting to allocate the buffer + (let [size (+ bfc/max-object-size 1) + baos (java.io.ByteArrayOutputStream. 17) + dos (java.io.DataOutputStream. baos)] + (.writeByte dos 5) + (.writeLong dos (long size)) + (.flush dos) + (let [input (java.io.DataInputStream. + (ByteArrayInputStream. (.toByteArray baos)))] + (binding [v1/*position* (atom 0)] + (let [out (try + (v1/read-obj! input) + nil + (catch clojure.lang.ExceptionInfo e + (ex-data e)))] + ;; Without the guard, read-obj! will either OOM or proceed + ;; to read-bytes! on a truncated stream (no :max-file-size-reached). + ;; With the guard, it raises :validation :max-file-size-reached. + (t/is (= :validation (:type out))) + (t/is (= :max-file-size-reached (:code out)))))))) From 0481408531e5447ffc2035b914099c0535f73b59 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:41:59 +0200 Subject: [PATCH 029/298] :bug: Add recursion depth limit to Fressian reader (#11020) Bound read depth at 128 levels to prevent StackOverflowError from crafted deeply-nested payloads. All recursive read handlers go through read-object!, so a single depth check covers all paths. AI-assisted-by: mimo-v2.5-pro --- common/src/app/common/fressian.clj | 13 ++++++++++++- common/test/common_tests/fressian_test.clj | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index b16d233b42..3de4cc54a7 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -31,6 +31,11 @@ ([^String s, ^String encoding] (.getBytes s encoding))) +;; --- DEPTH TRACKING + +(def ^:dynamic *read-depth* 0) +(def ^:const max-read-depth 128) + ;; --- LOW LEVEL FRESSIAN API (defn write-object! @@ -41,7 +46,13 @@ (defn read-object! [^Reader r] - (.readObject r)) + (when (>= *read-depth* max-read-depth) + (throw (ex-info "maximum Fressian read depth exceeded" + {:type :validation + :code :max-read-depth-reached + :hint "maximum Fressian read depth exceeded"}))) + (binding [*read-depth* (inc *read-depth*)] + (.readObject r))) (defn write-tag! ([^Writer w ^String n] diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj index 9af54464a5..3eda0f34d4 100644 --- a/common/test/common_tests/fressian_test.clj +++ b/common/test/common_tests/fressian_test.clj @@ -21,7 +21,8 @@ (:import java.time.Instant java.time.OffsetDateTime - java.time.ZoneOffset)) + java.time.ZoneOffset + java.util.UUID)) ;; --------------------------------------------------------------------------- ;; Helpers @@ -524,3 +525,18 @@ (t/is (d/ordered-map? rt)) (t/is (= om rt)) (t/is (= (keys om) (keys rt))))) + +(t/deftest decode-rejects-excessive-recursion-depth + ;; N2-01: deeply nested structures must be rejected before stack overflow + (let [depth (+ fres/max-read-depth 50) + data (reduce (fn [acc _i] [acc]) + :leaf + (range depth)) + encoded (fres/encode data)] + (try + (fres/decode encoded) + (t/is false "expected exception for excessive recursion depth") + (catch clojure.lang.ExceptionInfo e + (let [d (ex-data e)] + (t/is (= :validation (:type d))) + (t/is (= :max-read-depth-reached (:code d)))))))) From 3d176d539015bb3ad607e69829254cb4292cf8f3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:42:49 +0200 Subject: [PATCH 030/298] :bug: Restrict webhook creation/edit/delete to team members only (#11029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Restrict webhook edit/delete to team members only Remove the creator-id fallback from get-webhooks-permissions. Previously, the webhook creator could always edit/delete their webhook even after being removed from the team. Now can-edit comes from team role only — removed users get :not-found. Webhooks are NOT deleted on member removal; the team owns them and team admins/owners manage them. AI-assisted-by: mimo-v2.5-pro * :bug: Restrict webhook creation to team editors Use team role check (check-edition-permissions!) for create-webhook instead of the custom check that allowed any team member to create webhooks via creator-id self-match override. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/rpc/commands/webhooks.clj | 12 +- .../test/backend_tests/rpc_webhooks_test.clj | 160 +++++++++++++----- 2 files changed, 120 insertions(+), 52 deletions(-) diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 33341bb34e..85051e8ad7 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -23,11 +23,9 @@ [cuerdas.core :as str])) (defn get-webhooks-permissions - [conn profile-id team-id creator-id] + [conn profile-id team-id] (let [permissions (t/get-permissions conn profile-id team-id) - - can-edit (boolean (or (:can-edit permissions) - (= profile-id creator-id)))] + can-edit (boolean (:can-edit permissions))] (assoc permissions :can-edit can-edit))) (def has-webhook-edit-permissions? @@ -120,7 +118,7 @@ {::doc/added "1.17" ::sm/params schema:create-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] - (check-webhook-edition-permissions! pool profile-id team-id profile-id) + (t/check-edition-permissions! pool profile-id team-id) (validate-quotes! cfg params) (validate-webhook! cfg nil params) (insert-webhook! cfg params)) @@ -137,7 +135,7 @@ ::sm/params schema:update-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}] (let [whook (-> (db/get pool :webhook {:id id}) (decode-row))] - (check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook)) + (check-webhook-edition-permissions! pool profile-id (:team-id whook)) (validate-webhook! cfg whook params) (update-webhook! cfg whook params))) @@ -151,7 +149,7 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id id]}] (let [whook (-> (db/get conn :webhook {:id id}) decode-row)] - (check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook)) + (check-webhook-edition-permissions! conn profile-id (:team-id whook)) (db/delete! conn :webhook {:id id}) nil)) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index 3b39c8b52d..df4ae3a622 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -155,8 +155,7 @@ :return {:status 200}}] (let [owner (th/create-profile* 1 {:is-active true}) viewer (th/create-profile* 2 {:is-active true}) - team (th/create-team* 1 {:profile-id (:id owner)}) - whook (volatile! nil)] + team (th/create-team* 1 {:profile-id (:id owner)})] (th/create-team-role* {:team-id (:id team) :profile-id (:id viewer) :role :viewer}) @@ -164,52 +163,15 @@ (let [roles (th/db-query :team-profile-rel {:team-id (:id team)})] (t/is (= 2 (count roles)))) - (t/testing "viewer creates a webhook" + (t/testing "viewer cannot create a webhook (requires editor role)" (let [viewers-webhook (create-webhook-params (:id viewer) (:id team)) out (th/command! viewers-webhook)] - (t/is (nil? (:error out))) - (t/is (= 1 (:call-count @http-mock))) - - (let [result (:result out)] - (check-webhook-format result) - (t/is (= (:uri viewers-webhook) (:uri result))) - (t/is (= (:team-id viewers-webhook) (:team-id result))) - (t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result))) - (t/is (= (:mtype viewers-webhook) (:mtype result))) - (vreset! whook result)))) - - (th/reset-mock! http-mock) - - (t/testing "viewer updates it's own webhook (success)" - (let [params {::th/type :update-webhook - ::rpc/profile-id (:id viewer) - :id (:id @whook) - :uri (:uri @whook) - :mtype "application/transit+json" - :is-active false} - out (th/command! params) - result (:result out)] - - (t/is (nil? (:error out))) (t/is (= 0 (:call-count @http-mock))) - (check-webhook-format result) - (t/is (= (:is-active params) (:is-active result))) - (t/is (= (:team-id @whook) (:team-id result))) - (t/is (= (:mtype params) (:mtype result))) - (vreset! whook result))) - - (th/reset-mock! http-mock) - - (t/testing "viewer deletes it's own webhook (success)" - (let [params {::th/type :delete-webhook - ::rpc/profile-id (:id viewer) - :id (:id @whook)} - out (th/command! params)] - (t/is (= 0 (:call-count @http-mock))) - (t/is (nil? (:error out))) - (t/is (nil? (:result out))) - (let [rows (th/db-exec! ["select * from webhook"])] - (t/is (= 0 (count rows)))))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) (th/reset-mock! http-mock)))) @@ -268,6 +230,26 @@ (t/is (= (:type error-data) :not-found)) (t/is (= (:code error-data) :object-not-found))))))) +(t/deftest webhooks-viewer-cannot-create + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 200}}] + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + + (t/testing "viewer cannot create a webhook on the team" + (let [params (create-webhook-params (:id viewer) (:id team)) + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found)))))))) + (t/deftest webhooks-quotes (with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}] @@ -304,3 +286,91 @@ (t/is (th/ex-info? error)) (t/is (= (:type error-data) :restriction)) (t/is (= (:code error-data) :webhooks-quote-reached)))))) + +(t/deftest removed-user-cannot-edit-webhook + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 200}}] + + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + + (let [params {::th/type :create-webhook + ::rpc/profile-id (:id editor) + :team-id (:id team) + :uri (u/uri "http://example.com") + :mtype "application/json"} + out (th/command! params)] + + (t/is (nil? (:error out))) + (let [whook (:result out)] + + (th/reset-mock! http-mock) + + (t/testing "owner can edit editor's webhook (team owns it)" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id owner) + :id (:id whook) + :uri (u/uri "http://example.com/updated") + :mtype "application/transit+json" + :is-active true} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (= 1 (:call-count @http-mock))))) + + (th/reset-mock! http-mock) + + (t/testing "remove editor from team" + (let [params {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id editor)} + out (th/command! params)] + (t/is (nil? (:error out))))) + + (th/reset-mock! http-mock) + + (t/testing "removed editor cannot update webhook" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id editor) + :id (:id whook) + :uri (u/uri "http://example.com/evil") + :mtype "application/transit+json" + :is-active true} + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) + + (th/reset-mock! http-mock) + + (t/testing "removed editor cannot delete webhook" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id editor) + :id (:id whook)} + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) + + (th/reset-mock! http-mock) + + (t/testing "owner can still delete editor's webhook" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id owner) + :id (:id whook)} + out (th/command! params)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + (let [rows (th/db-exec! ["select * from webhook"])] + (t/is (= 0 (count rows))))))))))) From 25066c2f46987c696f2c077e6047e6ec6ebecb21 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:44:01 +0200 Subject: [PATCH 031/298] :bug: Require file read permissions for asset endpoints (#11036) Add authorization check to generic-handler in assets.clj so that /assets/by-file-media-id/:id and its /thumbnail variant verify the requesting profile has read access to the parent file. Return 404 (not 403) when access is denied to avoid confirming existence. Also switch get-file-media-object from db/get to db/get* so that non-existent media objects return nil instead of raising. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/http/assets.clj | 25 ++-- .../test/backend_tests/http_assets_test.clj | 129 ++++++++++++++++++ 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 1458b06d27..04dd7842ca 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -7,6 +7,7 @@ (ns app.http.assets "Assets related handlers." (:require + [app.binfile.common :as bfc] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] @@ -42,7 +43,7 @@ (defn- get-file-media-object [pool id] - (db/get pool :file-media-object {:id id} {::db/remove-deleted false})) + (db/get* pool :file-media-object {:id id} {::db/remove-deleted false})) (defn- serve-object-from-s3 [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] @@ -109,13 +110,21 @@ (defn- generic-handler "A generic handler helper/common code for file-media based handlers." [{:keys [::sto/storage] :as cfg} request kf] - (let [pool (::db/pool storage) - id (get-id request) - mobj (get-file-media-object pool id) - sobj (sto/get-object storage (kf mobj))] - (if sobj - (serve-object cfg sobj) - {::yres/status 404}))) + (let [pool (::db/pool storage) + id (get-id request) + mobj (get-file-media-object pool id)] + (if (nil? mobj) + {::yres/status 404} + (let [file-id (:file-id mobj) + profile-id (or (::session/profile-id request) + (::actoken/profile-id request)) + perms (bfc/get-file-permissions pool profile-id file-id)] + (if-not (:can-read perms) + {::yres/status 404} + (let [sobj (sto/get-object storage (kf mobj))] + (if sobj + (serve-object cfg sobj) + {::yres/status 404}))))))) (defn file-objects-handler "Handler that serves storage objects by file media id." diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 796306efe2..94510d73d6 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -459,6 +459,135 @@ ;; Tests: objects-handler — expired objects ;; ---------------------------------------------------------------- +;; ---------------------------------------------------------------- +;; Tests: file-objects-handler — authz required (T2-N1-01) +;; ---------------------------------------------------------------- + +(t/deftest file-objects-handler-unauthenticated-returns-404 + ;; Unauthenticated requests to file-media assets must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-no-file-perms-returns-404 + ;; Authenticated user without file read permissions must get 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + stranger (th/create-profile* 2) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id stranger)} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-with-file-perms-succeeds + ;; Authenticated user with file read permissions must get the object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id owner)} + response (assets/file-objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-unauthenticated-returns-404 + ;; Unauthenticated requests to file-thumbnail assets must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))}} + response (assets/file-thumbnails-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-with-file-perms-succeeds + ;; Authenticated user with file read permissions must get the thumbnail + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id thumb-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id (:id owner)} + response (assets/file-thumbnails-handler cfg request)] + ;; Falls back to media-id since no thumbnail-id, but still serves + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-objects-handler-non-existent-media-returns-404 + ;; Request for non-existent file-media-object returns 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + request {:path-params {:id (str (uuid/next))} + ::session/profile-id (:id profile)} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-nil-profile-id-returns-404 + ;; When profile-id is nil (invalid session), must return 404 + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + ::session/profile-id nil} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + (t/deftest objects-handler-expired-object ;; Expired objects should return 404 (get-object filters them out). (let [storage (-> (:app.storage/storage th/*system*) From 5906312dff3bf5812e4a87f74307fc6afd52a736 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:52:14 +0200 Subject: [PATCH 032/298] :bug: Normalize error response on duplicate file ID (#11050) Capture unique constraint violation in insert-file! and return generic :not-found error instead of propagating raw PostgreSQL exception, preventing file existence oracle. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/binfile/common.clj | 14 ++++++++--- backend/test/backend_tests/rpc_file_test.clj | 25 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index a5b73564ea..f984a98550 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -748,9 +748,17 @@ (fmigr/upsert-migrations! conn file)) (let [file (encode-file cfg file)] - (db/insert! conn :file - (file->params file) - (assoc opts ::db/return-keys false)) + (try + (db/insert! conn :file + (file->params file) + (assoc opts ::db/return-keys false)) + (catch org.postgresql.util.PSQLException cause + (if (db/duplicate-key-error? cause) + (ex/raise :type :not-found + :code :object-not-found + :hint "file already exists" + :cause cause) + (throw cause)))) (->> (file->file-data-params file) (fdata/upsert! cfg)) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 18ad14e639..cb0997576d 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -141,6 +141,31 @@ (let [result (:result out)] (t/is (= 0 (count result)))))))) +(t/deftest create-file-with-duplicate-id + (let [prof (th/create-profile* 1 {:is-active true}) + proj-id (:default-project-id prof) + file-id (uuid/next)] + + (t/testing "create file with specific id" + (let [data {::th/type :create-file + ::rpc/profile-id (:id prof) + :project-id proj-id + :id file-id + :name "first-file"} + out (th/command! data)] + (t/is (nil? (:error out))))) + + (t/testing "create file with duplicate id returns normalized error" + (let [data {::th/type :create-file + ::rpc/profile-id (:id prof) + :project-id proj-id + :id file-id + :name "duplicate-file"} + out (th/command! data) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))))) + (t/deftest file-gc-with-fragments (let [profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) From bf62e59f731698c96bf2a7e40c5e59ba8b1908dd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:53:15 +0200 Subject: [PATCH 033/298] :bug: Add cooldown to prevent duplicate invitation emails (#11063) --- .../app/rpc/commands/teams_invitations.clj | 91 ++++++++++++------- backend/test/backend_tests/rpc_team_test.clj | 40 ++++++++ 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 8b1a8c357c..7cd8933354 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -46,10 +46,29 @@ (def sql:upsert-organization-invitation "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) - values (?, null, ?, ?, ?, ?, ?) - on conflict(org_id, email_to) where team_id is null do - update set role = ?, valid_until = ?, updated_at = now() - returning *") + values (?, null, ?, ?, ?, ?, ?) + on conflict(org_id, email_to) where team_id is null do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + +(def ^:private sql:check-recent-invitation + "SELECT 1 FROM team_invitation + WHERE team_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(def ^:private sql:check-recent-org-invitation + "SELECT 1 FROM team_invitation + WHERE org_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(defn- recently-invited? + [{:keys [::db/conn]} team-id org-id email] + (let [query (if org-id + [sql:check-recent-org-invitation org-id email] + [sql:check-recent-invitation team-id email])] + (some? (db/exec-one! conn query)))) (defn- create-invitation-token [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] @@ -185,35 +204,36 @@ (teams/check-email-bounce conn email true) (teams/check-email-spam conn email true) - (let [id (uuid/next) - expire (if organization - (ct/in-future "876000h") ;; Organization invitations doesn't expire - (ct/in-future "168h")) ;; 7 days - invitation (db/exec-one! conn (if organization - [sql:upsert-organization-invitation id - (:id organization) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire] - [sql:upsert-team-invitation id - (:id team) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire])) - updated? (not= id (:id invitation)) - profile-id (:id profile) + (let [id (uuid/next) + expire (if organization + (ct/in-future "876000h") ;; Organization invitations doesn't expire + (ct/in-future "168h")) ;; 7 days + recent? (recently-invited? cfg (:id team) (:id organization) email) + invitation (db/exec-one! conn (if organization + [sql:upsert-organization-invitation id + (:id organization) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire] + [sql:upsert-team-invitation id + (:id team) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire])) + updated? (not= id (:id invitation)) + profile-id (:id profile) team-organization-id (get-in team [:organization :id]) - tprops {:profile-id profile-id - :invitation-id (:id invitation) - :valid-until expire - :team-id (:id team) - :organization-id (:id organization) - :organization-name (:name organization) - :member-email (:email-to invitation) - :member-id (:id member) - :role role} + tprops {:profile-id profile-id + :invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :organization-id (:id organization) + :organization-name (:name organization) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} audit-props (cond-> {:invitation-id (:id invitation) :valid-until expire @@ -234,8 +254,8 @@ (and team-organization-id member (contains? all-organization-member-ids (:id member)))))) - itoken (create-invitation-token cfg tprops) - ptoken (create-profile-identity-token cfg profile-id)] + itoken (create-invitation-token cfg tprops) + ptoken (create-profile-identity-token cfg profile-id)] (when (contains? cf/flags :log-invitation-tokens) (l/info :hint "invitation token" :token itoken)) @@ -251,7 +271,8 @@ (assoc :props props))] (audit/submit cfg event)) - (when (allow-invitation-emails? member) + (when (and (allow-invitation-emails? member) + (not recent?)) (if organization (when (contains? cf/flags :admin-console) (eml/send! {::eml/conn conn diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 7c2b5d0552..5c0477ed17 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1015,6 +1015,46 @@ out (th/command! data)] (t/is (th/success? out))))) +(t/deftest create-team-invitations-email-cooldown + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [profile1 (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + + data {::th/type :create-team-invitations + ::rpc/profile-id (:id profile1) + :team-id (:id team) + :role :editor + :emails ["cooldown-test@example.com"]}] + + ;; First invitation sends email + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; Resending immediately should NOT send email (cooldown active) + (th/reset-mock! mock) + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 0 (:call-count @mock)))) + + ;; Resending to a different email should send email + (th/reset-mock! mock) + (let [data (assoc data :emails ["different@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; After cooldown expires, resending should send email + (th/reset-mock! mock) + (th/db-update! :team-invitation + {:updated-at (ct/in-past "10m")} + {:team-id (:id team) + :email-to "cooldown-test@example.com"}) + (let [data (assoc data :emails ["cooldown-test@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))))))) + (t/deftest update-team-with-invalid-name (let [profile (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile)})] From 0ac711aa68a317c739f41b8cc06c75dcd46bb270 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 17:54:07 +0200 Subject: [PATCH 034/298] :bug: Normalize string inputs to prevent unfiltered echo (#11061) Add normalize-string helper in app.common.data that trims whitespace and returns empty string for nil input. Apply to profile, team, and project string fields (fullname, lang, theme, name) before storage. AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/auth.clj | 8 ++++++-- backend/src/app/rpc/commands/profile.clj | 3 +++ backend/src/app/rpc/commands/projects.clj | 4 +++- backend/src/app/rpc/commands/teams.clj | 9 ++++++--- common/src/app/common/data.cljc | 9 +++++++++ common/test/common_tests/data_test.cljc | 18 ++++++++++++++++++ 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 5444273862..933411a489 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -258,7 +258,8 @@ (validate-register-attempt! cfg params) (let [email (profile/clean-email email) - profile (profile/get-profile-by-email pool email)] + profile (profile/get-profile-by-email pool email) + fullname (d/normalize-string fullname)] ;; SECURITY: refuse to issue a prepared-register token when an active ;; profile already exists for this email. @@ -359,6 +360,9 @@ is-active (:is-active params false) theme (:theme params nil) email (str/lower email) + fullname (d/normalize-string (:fullname params)) + locale (d/normalize-string locale) + theme (d/normalize-string theme) photo-id (some->> (or (:oidc/picture props) (:google/picture props) @@ -367,7 +371,7 @@ (import-profile-picture cfg)) params {:id id - :fullname (:fullname params) + :fullname fullname :email email :auth-backend backend :lang locale diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index ed4d22f445..36d02ba2d9 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -164,6 +164,9 @@ ;; it or not for explicit locking and avoid concurrent updates of ;; the same row/object. (let [profile (get-profile conn profile-id ::db/for-update true) + fullname (d/normalize-string fullname) + lang (d/normalize-string lang) + theme (d/normalize-string theme) ;; Update the profile map with direct params profile (-> profile (assoc :fullname fullname) diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index 12da9bb7c5..cfb03a2f0d 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -6,6 +6,7 @@ (ns app.rpc.commands.projects (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.schema :as sm] @@ -259,7 +260,8 @@ ::db/transaction true} [{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}] (check-edition-permissions! conn profile-id id) - (let [project (db/get-by-id conn :project id ::sql/for-update true)] + (let [project (db/get-by-id conn :project id ::sql/for-update true) + name (d/normalize-string name)] (db/update! conn :project {:name name} {:id id}) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 9277a803c1..196b35c051 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -652,6 +652,7 @@ (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) features (db/create-array conn "text" features) + name (d/normalize-string name) team (db/insert! conn :team {:id id :name name @@ -688,6 +689,7 @@ [conn {:keys [id team-id name is-default created-at modified-at]}] (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) + name (d/normalize-string name) params {:id id :name name :team-id team-id @@ -718,9 +720,10 @@ ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}] (check-edition-permissions! conn profile-id id) - (db/update! conn :team - {:name name} - {:id id}) + (let [name (d/normalize-string name)] + (db/update! conn :team + {:name name} + {:id id})) nil) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 7cbfdcc4f5..418c6e5bd5 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1173,6 +1173,15 @@ [key coll] (sort-by key natural-compare coll)) +(defn normalize-string + "Normalizes a string by trimming leading/trailing whitespace. + Returns empty string for nil input. Non-string input is returned unchanged." + [s] + (cond + (nil? s) "" + (string? s) (str/trim s) + :else s)) + (defn sanitize-string [s] (if s (-> s diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 39f3370de8..46f12fd8fb 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -36,6 +36,24 @@ (t/is (= "" (d/get-initials nil))) (t/is (= "" (d/get-initials "!!! ???")))) +(t/deftest normalize-string-test + ;; nil input returns empty string + (t/is (= "" (d/normalize-string nil))) + ;; empty string returns empty string + (t/is (= "" (d/normalize-string ""))) + ;; leading whitespace is trimmed + (t/is (= "hello" (d/normalize-string " hello"))) + ;; trailing whitespace is trimmed + (t/is (= "hello" (d/normalize-string "hello "))) + ;; both leading and trailing whitespace are trimmed + (t/is (= "hello" (d/normalize-string " hello "))) + ;; internal whitespace is preserved + (t/is (= "hello world" (d/normalize-string " hello world "))) + ;; non-string input is returned unchanged + (t/is (= 42 (d/normalize-string 42))) + (t/is (= :keyword (d/normalize-string :keyword))) + (t/is (= true (d/normalize-string true)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Ordered Data Structures ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From c4dd04353fcf06c3e10a64e3d8e43945508ae98c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 29 Jul 2026 10:18:57 +0000 Subject: [PATCH 035/298] :bug: Sanitize SVG files on upload to prevent XSS Add sanitize-svg function that removes dangerous elements and attributes: - script tags - foreignObject elements - Event handler attributes (onload, onmouseover, etc.) - javascript: URLs from href/xlink:href attributes Apply sanitization in process-main-image before storing SVG files. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/media/local.clj | 66 +---------- backend/src/app/media/remote.clj | 4 +- backend/src/app/media/svg.clj | 130 ++++++++++++++++++++++ backend/src/app/rpc/commands/media.clj | 18 ++- backend/test/backend_tests/media_test.clj | 82 ++++++++++++++ 5 files changed, 231 insertions(+), 69 deletions(-) create mode 100644 backend/src/app/media/svg.clj diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj index b53c5a5f6d..f86e46c02e 100644 --- a/backend/src/app/media/local.clj +++ b/backend/src/app/media/local.clj @@ -7,30 +7,22 @@ (ns app.media.local "Local media processing via ImageMagick and FontForge shell commands." (:require - [app.common.data :as d] - [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.config :as cf] + [app.media.svg :as svg] [app.media.validation :as validation] [app.storage.tmp :as tmp] [app.util.shell :as shell] [buddy.core.bytes :as bb] [buddy.core.codecs :as bc] [clojure.string] - [clojure.xml :as xml] [cuerdas.core :as str] [datoteka.fs :as fs] - [datoteka.io :as io]) - (:import - clojure.lang.XMLHandler - java.io.InputStream - javax.xml.parsers.SAXParserFactory - javax.xml.XMLConstants - org.apache.commons.io.IOUtils)) + [datoteka.io :as io])) (defmulti process (fn [_system params] (:cmd params))) @@ -40,30 +32,6 @@ :code :not-implemented :hint (str/fmt "No impl found for local process cmd: %s" cmd))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SVG PARSING -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- secure-parser-factory - [^InputStream input ^XMLHandler handler] - (.. (doto (SAXParserFactory/newInstance) - (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) - (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) - (newSAXParser) - (parse input handler))) - -(defn- strip-doctype - [data] - (cond-> data - (str/includes? data "]*>" ""))) - -(defn parse-svg - [text] - (let [text (strip-doctype text)] - (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] - (xml/parse istream secure-parser-factory)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; IMAGE THUMBNAILS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -167,34 +135,6 @@ "-extent" (str width "x" height) "-quality" (str quality)])))) -(defn get-basic-info-from-svg - [{:keys [tag attrs] :as data}] - (when (not= tag :svg) - (ex/raise :type :validation - :code :unable-to-parse-svg - :hint "uploaded svg has invalid content")) - (reduce (fn [default f] - (if-let [res (f attrs)] - (reduced res) - default)) - {:width 100 :height 100} - [(fn parse-width-and-height - [{:keys [width height]}] - (when (and (string? width) - (string? height)) - (let [width (d/parse-double width) - height (d/parse-double height)] - (when (and width height) - {:width (int width) - :height (int height)})))) - (fn parse-viewbox - [{:keys [viewBox]}] - (let [[x y width height] (->> (str/split viewBox #"\s+" 4) - (map d/parse-double))] - (when (and x y width height) - {:width (int width) - :height (int height)})))])) - (defn- get-dimensions-with-orientation [system ^String path] ;; Image magick doesn't give info about exif rotation so we use the identify command ;; If we are processing an animated gif we use the first frame with -scene 0 @@ -217,7 +157,7 @@ [system {:keys [input] :as params}] (let [{:keys [path mtype] :as input} (validation/check-input input)] (if (= mtype "image/svg+xml") - (let [info (some-> path slurp parse-svg get-basic-info-from-svg)] + (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)] (when-not info (ex/raise :type :validation :code :invalid-svg-file diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj index 447d5f2e55..0b5a0a4a42 100644 --- a/backend/src/app/media/remote.clj +++ b/backend/src/app/media/remote.clj @@ -13,7 +13,7 @@ [app.common.uri :as uri] [app.config :as cf] [app.http.client :as http] - [app.media.local :as local] + [app.media.svg :as svg] [app.media.validation :as validation] [app.setup :as-alias setup] [app.storage.tmp :as tmp] @@ -182,7 +182,7 @@ (let [{:keys [path mtype]} (validation/check-input input)] (if (= mtype "image/svg+xml") ;; SVG: parse locally (Sharp doesn't support SVG) - (let [info (some-> path slurp local/parse-svg local/get-basic-info-from-svg)] + (let [info (some-> path slurp svg/parse-svg svg/get-basic-info-from-svg)] (when-not info (ex/raise :type :validation :code :invalid-svg-file diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj new file mode 100644 index 0000000000..1de52d4030 --- /dev/null +++ b/backend/src/app/media/svg.clj @@ -0,0 +1,130 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.media.svg + "SVG parsing, sanitization, and info extraction. + Centralizes all SVG-related security concerns." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [clojure.xml :as xml] + [cuerdas.core :as str]) + (:import + clojure.lang.XMLHandler + java.io.InputStream + javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants + org.apache.commons.io.IOUtils)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG PARSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- secure-parser-factory + [^InputStream input ^XMLHandler handler] + (.. (doto (SAXParserFactory/newInstance) + (.setFeature XMLConstants/FEATURE_SECURE_PROCESSING true) + (.setFeature "http://apache.org/xml/features/disallow-doctype-decl" true)) + (newSAXParser) + (parse input handler))) + +(defn- strip-doctype + [data] + (cond-> data + (str/includes? data "]*>" ""))) + +(defn parse-svg + [text] + (let [text (strip-doctype text)] + (dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")] + (xml/parse istream secure-parser-factory)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG SANITIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private dangerous-attrs-pattern #"(?i)^on\w+$") +(def ^:private javascript-href-pattern #"(?i)^javascript:") + +(defn- sanitize-svg-element + "Recursively sanitize an SVG element by removing dangerous tags and attributes." + [{:keys [tag attrs content] :as element}] + (when (and (map? element) tag) + (let [dangerous-tags #{:script :foreignObject :set :animate :animateTransform :animateColor :animateMotion}] + (when-not (contains? dangerous-tags tag) + (let [clean-attrs (->> attrs + (remove (fn [[k v]] + (or (re-matches dangerous-attrs-pattern (name k)) + (and (#{:href :xlink:href} k) + (string? v) + (re-find javascript-href-pattern (str/trim v)))))) + (into {})) + clean-content (when content + (->> content + (filter #(or (string? %) (map? %))) + (map (fn [child] + (if (map? child) + (sanitize-svg-element child) + child))) + (filter some?) + vec))] + (cond-> {:tag tag :attrs clean-attrs} + (seq clean-content) (assoc :content clean-content))))))) + +(defn sanitize-svg + "Sanitize SVG content by removing dangerous elements and attributes. + Removes " + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "foreignObject"))) + (t/is (not (clojure.string/includes? result "" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result "" + result (svg/sanitize-svg svg)] + (t/is (not (clojure.string/includes? result " Date: Wed, 5 Aug 2026 21:43:45 +0200 Subject: [PATCH 036/298] :bug: Fix scripts/ci issue with backend lintig --- scripts/ci | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci b/scripts/ci index 3d3c7ed9a8..026bdb7d02 100755 --- a/scripts/ci +++ b/scripts/ci @@ -23,7 +23,7 @@ ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugi # Module commands declare -A LINT_CMD=( [frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss" - [backend]="pnpm run lint" + [backend]="pnpm run lint:clj" [common]="pnpm run lint:clj" [render-wasm]="./lint" [exporter]="pnpm run lint" From b6656ee8dd1a41d0c00c5936eb9d7d00d33b17af Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 5 Aug 2026 21:54:33 +0200 Subject: [PATCH 037/298] :bug: Enable SSRF check for organization SSO provider (#11064) (#11065) Remove :skip-ssrf-check? true from prepare-organization-sso-provider so SSRF protection is active when validating organization SSO configs. The endpoint is already protected by shared-key authentication (admin-console), but enabling SSRF protection prevents potential misuse of internal network resources if the shared key were ever compromised (defense-in-depth). Add test prepare-organization-sso-provider-does-not-skip-ssrf-check to verify the SSRF check is not skipped. AI-assisted-by: qwen3.7-plus --- backend/src/app/auth/oidc.clj | 5 ++--- backend/test/backend_tests/auth_oidc_test.clj | 13 +++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 09d34532b9..ecf8b658a5 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -776,7 +776,7 @@ (defn prepare-organization-sso-provider "Build an OIDC provider map dynamically from the Nitrate organization SSO config. - Uses OIDC discovery via :issuer when token/auth/user URIs are absent." + Uses OIDC discovery via :issuer when token/auth/user URIs are absent." [cfg {:keys [client-id client-secret issuer]}] (prepare-oidc-provider cfg {:type "oidc" @@ -785,8 +785,7 @@ :base-uri (some-> (non-blank-uri issuer) (str/rtrim "/") (str "/")) - :scopes default-oidc-scopes - :skip-ssrf-check? true})) + :scopes default-oidc-scopes})) (defn build-organization-sso-auth-redirect-uri "Build the OIDC authorization redirect URI for an organization SSO config. diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 22ccb624fe..62f04fd546 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -518,3 +518,16 @@ loc (redirect-location result)] (t/is (= 302 (::yres/status result))) (t/is (.contains loc "error=unable-to-auth"))))))) + +(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check + (t/testing "organization SSO provider must use SSRF protection" + (let [captured-params (atom nil)] + (with-redefs [oidc/prepare-oidc-provider (fn [_cfg params] + (reset! captured-params params) + {:type "oidc" :id "test"})] + (#'oidc/prepare-organization-sso-provider {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (t/is (not (true? (:skip-ssrf-check? @captured-params))) + "SSRF protection must be disabled for organization SSO"))))) From a60b648c6cca33a3803d4e7db0bded171820ec96 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 08:55:58 +0200 Subject: [PATCH 038/298] :bug: Fix issues with draft-js tests --- frontend/packages/draft-js/index.js | 3 ++- frontend/src/app/main/data/workspace/clipboard.cljs | 4 ++-- frontend/src/app/plugins/register.cljs | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index f02109f971..eae0a5f3c0 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -22,7 +22,8 @@ export const { } = pkg; import DraftPasteProcessor from 'draft-js/lib/DraftPasteProcessor.js'; -import {Map, OrderedSet} from "immutable"; +import Immutable from "immutable"; +const {Map, OrderedSet} = Immutable; function isDefined(v) { return v !== undefined && v !== null; diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index ac1fb64e9a..21346c879c 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1217,7 +1217,7 @@ (rx/mapcat (fn [blob] ;; Resolve the deferred with the fetched blob; the browser ;; will now complete the clipboard write it started earlier. - (p/resolve! deferred blob) + (p/resolve deferred blob) (rx/from write-promise))) (rx/map (fn [_] (ntf/success (tr "workspace.clipboard.image-copied")))) @@ -1225,5 +1225,5 @@ (js/console.error "clipboard error:" e) ;; Reject the deferred in case the error occurred before the ;; blob was fetched, so the pending clipboard write is cancelled. - (p/reject! deferred e) + (p/reject deferred e) (rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed"))))))))))) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index e4837ce75b..9180463c69 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -35,7 +35,7 @@ "Signals that plugins runtime has been initialized. Called by app.plugins/init-plugins-runtime." [] (when (p/pending? runtime-ready-promise) - (p/resolve! runtime-ready-promise true))) + (p/resolve runtime-ready-promise true))) ;; Stores the installed plugins information (defonce ^:private registry (atom {})) From 8b64b0f84f40d8eeca0d4e83dc6a451232da706d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 6 Aug 2026 09:00:45 +0200 Subject: [PATCH 039/298] :zap: Fix progressive render budget when timestamp is stale (#11094) Pass performance.now from finalize/debounce and re-anchor the WASM budget if the stamp is 0 or already past max_blocking_time, so HQ tiles are not yielded after a few nodes with almost no real work. --- frontend/src/app/render_wasm/api.cljs | 4 ++-- render-wasm/src/render.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b8601102ee..cd21f63546 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1406,7 +1406,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render 0 RENDER-FLAG-SYNC-TILES) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) ;; The direct render above bypasses the rAF `render` loop, so repaint the ;; editor overlay explicitly. Only when this was a full frame: a progressive ;; render keeps painting through the rAF loop and its partial frames must not @@ -1421,7 +1421,7 @@ (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. (do - (internal-render 0 RENDER-FLAG-SYNC-TILES) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) (render-text-editor-overlay-after-frame!)) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 7ff4423f7a..d1c4228343 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2357,6 +2357,7 @@ impl RenderState { allow_stop: bool, ) -> Result { performance::begin_measure!("continue_render_loop"); + let timestamp = self.render_budget_start(timestamp); let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; @@ -2404,6 +2405,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result { + let timestamp = self.render_budget_start(timestamp); self.render_shape_tree_partial(base_object, tree, timestamp, false)?; // Same composition as `continue_render_loop` for full frames: snapshot only the @@ -2538,6 +2540,24 @@ impl RenderState { Ok((data.as_bytes().to_vec(), width, height)) } + /// Anchor the progressive render budget to wall-clock now when the + /// caller-provided timestamp is unusable: + /// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end). + /// - rAF may hand a timestamp that is already older than the budget when + /// the handler runs late. Using that stamp made `should_stop_rendering` + /// yield after a few nodes with ~0ms of real work. + #[inline] + fn render_budget_start(&self, timestamp: i32) -> i32 { + let now = performance::get_time(); + if timestamp <= 0 { + return now; + } + if now - timestamp > self.options.max_blocking_time_ms { + return now; + } + timestamp + } + #[inline] pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { if iteration % self.options.node_batch_threshold != 0 { From 4b413299c213ad74b28158bab9fedfffe51b431c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 6 Aug 2026 09:09:09 +0200 Subject: [PATCH 040/298] :zap: Clear dirty flags after tile surface reset (#11095) Marking intermediate surfaces dirty after clearing them on tile context switch made the first stack composite blit empty Fills/Strokes/shadows into Current. Dirty means content to composite, so clear the flags after the clear instead. --- render-wasm/src/render/surfaces.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 5d9def6f92..b8f4b51c9d 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -846,28 +846,24 @@ impl Surfaces { pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) { let translation = self.get_render_context_translation(render_area, scale); - // When context changes (zoom/pan/tile), clear all render surfaces first - // to remove any residual content from previous tiles, then mark as dirty - // so they get redrawn with new transformations + // When context changes (zoom/pan/tile), clear intermediate surfaces so + // residual content from the previous tile cannot leak into the next. let surface_ids = SurfaceId::Fills as u32 | SurfaceId::Strokes as u32 | SurfaceId::InnerShadows as u32 | SurfaceId::TextDropShadows as u32 | SurfaceId::DropShadows as u32; - // Clear surfaces before updating transformations to remove residual content self.apply_mut(surface_ids, |s| { s.canvas().clear(skia::Color::TRANSPARENT); }); - // Mark all render surfaces as dirty so they get redrawn - self.mark_dirty(SurfaceId::Fills); - self.mark_dirty(SurfaceId::Strokes); - self.mark_dirty(SurfaceId::InnerShadows); - self.mark_dirty(SurfaceId::TextDropShadows); - self.mark_dirty(SurfaceId::DropShadows); + // Dirty means "has content to composite", not "transform was updated". + // After a clear the surfaces are empty; leaving them dirty made the + // first `draw_shape_surface_stack_into` on each tile blit empty + // Fills/Strokes/shadows into Current (useless GPU ops / ops-task noise). + self.clear_dirty(surface_ids); - // Update transformations self.apply_mut(surface_ids, |s| { let canvas = s.canvas(); canvas.reset_matrix(); From 495e9f059eeb2b3ef5b2587c20ae3292eede730b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 09:35:21 +0200 Subject: [PATCH 041/298] :bug: Add minor fix on token tests --- .../frontend_tests/plugins/tokens_test.cljs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index de94a08f21..5c6122f346 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -385,18 +385,18 @@ theme-id (uuid/next) theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) - invalid (atom [])] - (with-redefs [u/locate-token-set (constantly nil) - u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) - u/not-valid (fn [_ code value] (swap! invalid conj [code value])) - dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) - st/emit! (fn ([event] (swap! emitted conj event) nil) - ([event & _] (swap! emitted conj event) nil))] + errors (atom [])] + (with-redefs [u/locate-token-set (constantly nil) + u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) + u/throw-validation-errors? (constantly true) + dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) + st/emit! (fn ([event] (swap! emitted conj event) nil) + ([event & _] (swap! emitted conj event) nil))] (let [theme-proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] ;; Non-id, non-proxy arguments are rejected by the schema coercer. - (.addSet theme-proxy 42) - (.removeSet theme-proxy nil) + (try (.addSet theme-proxy 42) (catch :default e (swap! errors conj e))) + (try (.removeSet theme-proxy nil) (catch :default e (swap! errors conj e))) (t/is (empty? @emitted)) - (t/is (= 2 (count @invalid))) - (t/is (every? #(= :error (first %)) @invalid)))))) + (t/is (= 2 (count @errors))) + (t/is (every? #(instance? js/Error %) @errors)))))) From 10a2c19f9261de100f2745701abf2439bab3e189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 6 Aug 2026 09:43:02 +0200 Subject: [PATCH 042/298] :wrench: Improve text editor selection and tab conversion (#11071) --- .../src/app/main/data/workspace/texts.cljs | 38 ++++++++++++++---- .../ui/workspace/shapes/text/v3_editor.cljs | 13 +++++- frontend/src/app/render_wasm/api.cljs | 8 ++++ frontend/src/app/render_wasm/text_editor.cljs | 26 ++++++++++++ render-wasm/src/render/text.rs | 6 +-- render-wasm/src/shapes/text.rs | 40 ++++++++++++++++--- render-wasm/src/wasm/text_editor.rs | 2 +- 7 files changed, 114 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 3fe9161d97..113561a506 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -975,7 +975,14 @@ (watch [_ state stream] (let [text-editor-instance (:workspace-editor state) objects (dsh/lookup-page-objects state) - text-ids (resolve-text-ids objects id)] + text-ids (resolve-text-ids objects id) + + wasm-editing? + (and (features/active-feature? state "text-editor-wasm/v1") + (= id (wasm.api/text-editor-get-active-shape-id))) + + wasm-editing-selection? + (and wasm-editing? (wasm.api/text-editor-has-selection?))] (if (and (features/active-feature? state "text-editor/v2") (some? text-editor-instance)) (rx/empty) @@ -985,15 +992,30 @@ (rx/of (update-root-attrs {:id id :attrs attrs})) (rx/empty))) - (let [attrs (select-keys attrs txt/paragraph-attrs)] - (if-not (empty? attrs) - (rx/of (update-paragraph-attrs {:id id :attrs attrs})) - (rx/empty))) + ;; `:line-height` is stored on both the paragraph and its spans, and + ;; the renderer takes the larger of the two. + (let [pattrs (if wasm-editing-selection? + (conj txt/paragraph-attrs :line-height) + txt/paragraph-attrs) + attrs (select-keys attrs pattrs) + result (when (and (seq attrs) wasm-editing?) + (wasm.api/apply-paragraph-attrs-to-selection attrs))] + (cond + (empty? attrs) + (rx/empty) + + (some? result) + (rx/of (v2-update-text-shape-content + (:shape-id result) (:content result) + :update-name? true)) + + :else + (rx/of (update-paragraph-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) - (rx/of (update-text-attrs {:id id :attrs attrs})) - (rx/empty))) + (if (or (empty? attrs) wasm-editing-selection?) + (rx/empty) + (rx/of (update-text-attrs {:id id :attrs attrs})))) (when (and (features/active-feature? state "text-editor/v2") (not (features/active-feature? state "text-editor-wasm/v1"))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index df86b7bb2d..d872d684a8 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -203,7 +203,7 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) + (when (text-editor/text-editor-has-selection?) (let [text (text-editor/text-editor-export-selection)] (.setData (.-clipboardData event) "text/plain" text)))))) @@ -212,7 +212,7 @@ (fn [^js event] (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) - (when (text-editor/text-editor-get-selection) + (when (text-editor/text-editor-has-selection?) (let [text (text-editor/text-editor-export-selection)] (.setData (.-clipboardData event) "text/plain" (or text "")) (when (and text (seq text)) @@ -268,6 +268,15 @@ (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-delete-forward")) + ;; Shift+Tab falls through to the browser, so the keyboard can + ;; still leave the editor. + (and (= key "Tab") (not shift?)) + (do + (dom/prevent-default event) + (text-editor/text-editor-insert-text "\t") + (sync-wasm-text-editor-content!) + (wasm.api/request-render-preserving-target "text-tab")) + ;; Insert (= key "Insert") (do diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cd21f63546..46da6a7792 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -711,6 +711,14 @@ (request-render "apply-styles-to-selection") result)) +(defn apply-paragraph-attrs-to-selection + "Apply paragraph attrs to the paragraphs the editor selection touches. + Returns {:shape-id :content} for saving." + [attrs] + (let [result (text-editor/apply-paragraph-attrs-to-selection attrs use-shape set-shape-text-content)] + (request-render "apply-paragraph-attrs-to-selection") + result)) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index c2853dad9e..94108ea456 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -762,3 +762,29 @@ :content new-content} with-fills? (assoc :fills (selection-fills new-content normalized-selection))))))))))) + +(defn apply-paragraph-attrs-to-selection + "Apply paragraph level attrs (text-align, text-direction) to the whole + paragraphs the editor selection touches; a collapsed caret means just the one + it sits in." + [attrs use-shape-fn set-shape-text-content-fn] + (when (wasm/ready?) + (let [shape-id (text-editor-get-active-shape-id) + selection (text-editor-get-selection)] + (when (and shape-id selection) + (when-let [content (get-cached-content shape-id)] + (let [{:keys [start-para end-para]} (normalize-selection selection) + paragraph-set (first (:children content)) + new-paragraphs (into [] + (map-indexed (fn [idx para] + (if (<= start-para idx end-para) + (merge para attrs) + para))) + (:children paragraph-set)) + new-content (assoc content :children + [(assoc paragraph-set :children new-paragraphs)])] + (update-cached-content! shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content})))))) diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index c176084688..bf069d2b1c 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, - StrokeKind, TextContent, + add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, + ParagraphLayout, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -55,7 +55,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph.line_height(), ); builder.push_style(&stroke_style); - builder.add_text(&text); + add_text_with_tabs(builder, &text, span.font_size); } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index d5506850c0..47befc08f3 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -14,7 +14,10 @@ use skia_safe::{ textlayout::Affinity, textlayout::ParagraphBuilder, textlayout::ParagraphStyle, + textlayout::PlaceholderAlignment, + textlayout::PlaceholderStyle, textlayout::PositionWithAffinity, + textlayout::TextBaseline, Contains, }; @@ -725,7 +728,7 @@ impl TextContent { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); @@ -759,7 +762,7 @@ impl TextContent { has_text = true; } builder.push_style(&text_style); - builder.add_text(&text); + add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); @@ -1213,7 +1216,7 @@ impl Paragraph { style.set_height(self.line_height); style.set_text_align(self.text_align); style.set_text_direction(self.text_direction); - style.set_replace_tab_characters(true); + style.set_replace_tab_characters(false); style.set_apply_rounding_hack(true); style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All); style @@ -1249,12 +1252,30 @@ fn capitalize_words(text: &str) -> String { result } -/// Filter control characters below U+0020, preserving line breaks. +/// Add `text`, pushing every '\t' as a one em wide placeholder. +pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { + let tab = PlaceholderStyle::new( + font_size, + 0.0, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + ); + + for (index, segment) in text.split('\t').enumerate() { + if index > 0 { + builder.add_placeholder(&tab); + } + builder.add_text(segment); + } +} + +/// Filter control characters below U+0020, preserving tabs and 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}' { + if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { return Some(c); } if c < '\u{0020}' { @@ -1742,6 +1763,15 @@ mod tests { assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); } + #[test] + fn process_ignored_chars_preserves_tabs() { + assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); + assert_eq!( + process_ignored_chars("hello\tworld", Browser::Firefox as u8), + "hello\tworld" + ); + } + #[test] fn process_ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 6bb03f9f5e..7f0e75465f 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1090,7 +1090,7 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { with_state!(state, { - if !get_text_editor_state().selection.is_selection() { + if get_text_editor_state().active_shape_id.is_none() { return false; } From 81e44afbe3c76096e00c0be809aa74a508417f6c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 10:17:14 +0200 Subject: [PATCH 043/298] :bug: Add backend password validation with complexity rules and dictionary check (#11059) * :bug: Add backend password validation with complexity rules and dictionary check Enforce minimum 8-character password length, require at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character, and reject common passwords using Passay library with a 10k-entry wordlist from SecLists during registration and password change flows. AI-assisted-by: mimo-v2.5-pro * :sparkles: Improve user feedback When the password is invalid, the user now gets extra indications to make it stronger, so it can be valid. * :bug: Fix remove unneeded common password check The dictionary check is only relevant for passwords that meet all other requirements, but all 10,000 common passwords would fail the character requirements, so this check is not needed --------- Co-authored-by: Luis de Dios --- backend/deps.edn | 1 + backend/src/app/auth/passwords.clj | 53 +++++++++++ backend/src/app/rpc/commands/auth.clj | 5 + backend/src/app/rpc/commands/profile.clj | 4 + backend/test/backend_tests/helpers.clj | 2 +- .../test/backend_tests/rpc_profile_test.clj | 94 ++++++++++++++----- frontend/src/app/main/data/profile.cljs | 7 +- frontend/src/app/main/ui/auth/recovery.cljs | 16 +++- frontend/src/app/main/ui/auth/register.cljs | 17 +++- .../src/app/main/ui/components/forms.cljs | 10 +- .../src/app/main/ui/components/forms.scss | 10 ++ .../src/app/main/ui/settings/password.cljs | 8 ++ frontend/translations/en.po | 28 ++++++ frontend/translations/es.po | 28 ++++++ 14 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 backend/src/app/auth/passwords.clj diff --git a/backend/deps.edn b/backend/deps.edn index 2599066e0b..1450f4de58 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -48,6 +48,7 @@ buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} + org.passay/passay {:mvn/version "1.6.6"} com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} diff --git a/backend/src/app/auth/passwords.clj b/backend/src/app/auth/passwords.clj new file mode 100644 index 0000000000..cc75b202b7 --- /dev/null +++ b/backend/src/app/auth/passwords.clj @@ -0,0 +1,53 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.auth.passwords + "Password strength validation using Passay library." + (:require + [app.common.exceptions :as ex]) + (:import + [org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData])) + +(defonce ^:private passay-code->translation-key + {"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase" + "INSUFFICIENT_UPPERCASE" "errors.weak-password.insufficient-uppercase" + "INSUFFICIENT_DIGIT" "errors.weak-password.insufficient-digits" + "INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"}) + +(defonce ^:private character-characteristics-rule + (doto (CharacterCharacteristicsRule.) + (.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1) + (CharacterRule. EnglishCharacterData/UpperCase 1) + (CharacterRule. EnglishCharacterData/Digit 1) + (CharacterRule. EnglishCharacterData/Special 1)]) + (.setNumberOfCharacteristics 4))) + +(defn validate-password + "Validates password strength. + Returns nil if valid, or raises exception if invalid. + Checks: + - Minimum length of 8 characters + - At least 1 lowercase letter + - At least 1 uppercase letter + - At least 1 digit + - At least 1 special character" + [password] + (when (< (count password) 8) + (ex/raise :type :validation + :code :weak-password + :hint "password must be at least 8 characters" + :details ["errors.weak-password.too-short"])) + + (let [password-data (PasswordData. password) + char-result (.validate character-characteristics-rule password-data)] + (when-not (.isValid char-result) + (ex/raise :type :validation + :code :weak-password + :hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character" + :details (->> (.getDetails char-result) + (mapv #(.getErrorCode %)) + (mapv passay-code->translation-key) + (filterv some?)))))) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 933411a489..07460633c4 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -8,6 +8,7 @@ (:require [app.auth :as auth] [app.auth.oidc :as oidc] + [app.auth.passwords :as passwords] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.features :as cfeat] @@ -182,6 +183,7 @@ (db/update! conn :profile {:password pwd :is-active true} {:id profile-id}) nil))] + (passwords/validate-password password) (->> (validate-token token) (update-password conn)) @@ -240,6 +242,9 @@ :code :email-as-password :hint "you can't use your email as password")) + ;; Validate password strength against common password dictionary + (passwords/validate-password (:password params)) + (when (eml/has-bounce-reports? cfg (:email params)) (ex/raise :type :restriction :code :email-has-permanent-bounces diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 36d02ba2d9..26716cc411 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -7,6 +7,7 @@ (ns app.rpc.commands.profile (:require [app.auth :as auth] + [app.auth.passwords :as passwords] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.schema :as sm] @@ -212,6 +213,9 @@ :code :email-as-password :hint "you can't use your email as password")) + ;; Validate password strength against common password dictionary + (passwords/validate-password (:password params)) + (update-profile-password! cfg (assoc profile :password password)) (->> (rph/get-request params) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index f839f222b9..0f582497d9 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -189,7 +189,7 @@ (let [params (merge {:id (mk-uuid "profile" i) :fullname (str "Profile " i) :email (str "profile" i ".test@nodomain.com") - :password "123123" + :password "Test123!" :is-demo false} params)] (db/run! system diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index ffbb55ca7c..f846cfb343 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -42,7 +42,7 @@ (let [profile (th/create-profile* 1) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] #_(th/print-result! out) @@ -56,7 +56,7 @@ (let [profile (th/create-profile* 1) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "123123"} + :password "Test123!"} out (th/command! data)] ;; (th/print-result! out) (let [error (:error out)] @@ -69,7 +69,7 @@ (let [profile (th/create-profile* 1 {:is-active true}) data {::th/type :login-with-password :email "profile1.test@nodomain.com" - :password "123123"} + :password "Test123!"} out (th/command! data)] ;; (th/print-result! out) (t/is (nil? (:error out))) @@ -403,7 +403,7 @@ (let [data {::th/type :prepare-register-profile :email "user@example.com" :fullname "foobar" - :password "foobar" + :password "Foobar12!" :utm_campaign "utma" :mtm_campaign "mtma"} out (th/command! data) @@ -444,7 +444,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -463,7 +463,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -498,7 +498,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -521,7 +521,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -547,7 +547,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -576,7 +576,7 @@ (let [data {::th/type :prepare-register-profile :email "hello@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data) token (get-in out [:result :token])] (t/is (th/success? out)) @@ -614,7 +614,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} {prep-result :result prep-error :error} (th/command! prep-data)] (t/is (nil? prep-error)) @@ -659,7 +659,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} {prep-result :result prep-error :error} (th/command! prep-data)] (t/is (nil? prep-error)) @@ -692,7 +692,7 @@ :invitation-token itoken :email "user@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -712,7 +712,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -733,7 +733,7 @@ :invitation-token itoken :email "user@example.com" :fullname "foobar" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -754,7 +754,7 @@ :invitation-token itoken :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -767,7 +767,7 @@ (let [data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] (t/is (not (th/success? out))) @@ -780,7 +780,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email (:email profile) - :password "foobar"} + :password "Foobar12!"} out (th/command! data)] ;; (th/print-result! out) (t/is (th/success? out)) @@ -793,7 +793,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"}] + :password "Foobar12!"}] (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"}) @@ -808,7 +808,7 @@ data {::th/type :prepare-register-profile :fullname "foobar" :email "user@example.com" - :password "foobar"}] + :password "Foobar12!"}] (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"}) @@ -1131,8 +1131,8 @@ (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) - :old-password "123123" - :password "foobarfoobar"} + :old-password "Test123!" + :password "Foobar12!"} out (th/command! data)] (t/is (nil? (:error out))) (t/is (nil? (:result out))))) @@ -1143,7 +1143,7 @@ data {::th/type :update-profile-password ::rpc/profile-id (:id profile) :old-password "badpassword" - :password "foobarfoobar"} + :password "Foobar12!"} {:keys [result error] :as out} (th/command! data)] (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation)) @@ -1154,7 +1154,7 @@ (let [profile (th/create-profile* 1) data {::th/type :update-profile-password ::rpc/profile-id (:id profile) - :old-password "123123" + :old-password "Test123!" :password "profile1.test@nodomain.com"} {:keys [result error] :as out} (th/command! data)] (t/is (th/ex-info? error)) @@ -1271,3 +1271,49 @@ (t/is (th/ex-info? (:error out))) (t/is (th/ex-of-type? (:error out) :validation)) (t/is (th/ex-of-code? (:error out) :params-validation)))) + + +(t/deftest prepare-register-profile-password-too-short + (let [data {::th/type :prepare-register-profile + :email "user@example.com" + :fullname "foobar" + :password "123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest prepare-register-profile-weak-password + (let [data {::th/type :prepare-register-profile + :email "user@example.com" + :fullname "foobar" + :password "password123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest update-profile-password-too-short + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-password + ::rpc/profile-id (:id profile) + :old-password "Test123!" + :password "123"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) + + +(t/deftest update-profile-password-weak-password + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-password + ::rpc/profile-id (:id profile) + :old-password "Test123!" + :password "qwerty"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :weak-password)))) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index e37dc08e45..93a948c119 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -462,11 +462,14 @@ ptk/WatchEvent (watch [_ _ _] (let [{:keys [on-error on-success] - :or {on-error rx/throw + :or {on-error identity on-success identity}} (meta data)] (->> (rp/cmd! :recover-profile data) (rx/tap on-success) - (rx/catch on-error))))))) + (rx/catch (fn [err] + (on-error err) + (rx/empty))) + (rx/ignore))))))) ;; --- EVENT: fetch-team-webhooks diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index 5ee49525db..d91f200db9 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -28,8 +28,18 @@ (= password-1 password-2))]]) (defn- on-error - [_form _error] - (st/emit! (ntf/error (tr "errors.invalid-recovery-token")))) + [form error] + (let [{:keys [type code] :as edata} (ex-data error)] + (if (= [:validation :weak-password] [type code]) + (let [details (:details edata) + options (when (seq details) + (mapv tr details))] + (swap! form assoc-in [:extra-errors :password-1] + {:message (tr "errors.weak-password") + :options options})) + + (let [msg (tr "errors.invalid-recovery-token")] + (st/emit! (ntf/error msg)))))) (defn- on-success [_] @@ -38,7 +48,7 @@ (defn- on-submit [form _event] - (let [mdata {:on-error on-error + (let [mdata {:on-error (partial on-error form) :on-success on-success} params {:token (get-in @form [:clean-data :token]) :password (get-in @form [:clean-data :password-2])}] diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index bb8966d12b..be5f3f280b 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -21,6 +21,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] + [cuerdas.core :as str] [rumext.v2 :as mf])) ;; --- PAGE: Register @@ -103,8 +104,20 @@ (st/emit! (ntf/error (tr "errors.email-already-exists"))) [:validation :email-as-password] - (swap! form assoc-in [:errors :password] - {:message (tr "errors.email-as-password")}) + (st/emit! (ntf/error (tr "errors.email-as-password"))) + + [:validation :weak-password] + (let [details (:details edata) + items (when (seq details) + (->> details + (map #(str "

  • " (tr %) "
  • ")) + (str/join ""))) + detail (when items + (str "
      " items "
    "))] + (st/emit! (ntf/show {:content (tr "errors.weak-password") + :detail detail + :type :toast + :level :error}))) (do (when-let [explain (get edata :explain)] diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 88585a753e..73632ea4eb 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -180,11 +180,17 @@ (cond (and touched? (:message error) show-error) - (let [message (:message error)] + (let [message (:message error) + options (:options error)] [:div {:id (dm/str "error-" input-name) :class (stl/css :error) :data-testid (dm/str data-testid "-error")} - message]) + message + (when (seq options) + [:ul {:class (stl/css :error-options)} + (for [opt options] + [:li {:key opt + :class (stl/css :error-option)} opt])])]) ;; FIXME: DEPRECATED (and touched? (:code error) show-error) diff --git a/frontend/src/app/main/ui/components/forms.scss b/frontend/src/app/main/ui/components/forms.scss index 30901f43ec..a9aed792e3 100644 --- a/frontend/src/app/main/ui/components/forms.scss +++ b/frontend/src/app/main/ui/components/forms.scss @@ -168,6 +168,16 @@ font-size: deprecated.$fs-14; } +.error-options { + margin-block: var(--sp-xxs); + padding-inline-start: var(--sp-l); + list-style-type: disc; +} + +.error-option { + margin-block: var(--sp-xxs); +} + .hint { @include t.use-typography("body-small"); diff --git a/frontend/src/app/main/ui/settings/password.cljs b/frontend/src/app/main/ui/settings/password.cljs index cf3d60ed8d..96d0003001 100644 --- a/frontend/src/app/main/ui/settings/password.cljs +++ b/frontend/src/app/main/ui/settings/password.cljs @@ -28,6 +28,14 @@ (swap! form assoc-in [:extra-errors :password-1] {:message (tr "errors.email-as-password")}) + :weak-password + (let [details (:details data) + options (when (seq details) + (mapv tr details))] + (swap! form assoc-in [:extra-errors :password-1] + {:message (tr "errors.weak-password") + :options options})) + (let [msg (tr "generic.error")] (st/emit! (ntf/error msg)))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 641b3e014d..b9621af9e4 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1748,6 +1748,34 @@ msgstr "Confirmation password must match" msgid "errors.password-too-short" msgstr "Password should at least be 8 characters" +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password" +msgstr "Password does not meet the requirements" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.too-short" +msgstr "At least 8 characters" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-lowercase" +msgstr "At least 1 lowercase letter" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-uppercase" +msgstr "At least 1 uppercase letter" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-digits" +msgstr "At least 1 digit" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-special" +msgstr "At least 1 special character" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.in-dictionary" +msgstr "Password is too common" + #: src/app/main/errors.cljs:267 msgid "errors.paste-data-validation" msgstr "Invalid data in clipboard" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1d051dbaf4..51165035e2 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1717,6 +1717,34 @@ msgstr "La contraseña de confirmación debe coincidir" msgid "errors.password-too-short" msgstr "La contraseña debe tener 8 caracteres como mínimo" +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password" +msgstr "La contraseña no cumple los requisitos" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.too-short" +msgstr "Al menos 8 caracteres" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-lowercase" +msgstr "Al menos 1 letra minúscula" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-uppercase" +msgstr "Al menos 1 letra mayúscula" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-digits" +msgstr "Al menos 1 dígito" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.insufficient-special" +msgstr "Al menos 1 carácter especial" + +#: src/app/main/ui/settings/password.cljs, src/app/main/ui/auth/register.cljs +msgid "errors.weak-password.in-dictionary" +msgstr "La contraseña es demasiado común" + #: src/app/main/errors.cljs:267 msgid "errors.paste-data-validation" msgstr "Datos inválidos en el portapapeles" From 11fc090bc41f718a68be792e272973bcdff0adae Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 6 Aug 2026 12:31:49 +0200 Subject: [PATCH 044/298] :zap: Expand direct shape painting and skip empty drop-shadow blits (#11100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Extract apply_clip_stack_to_surfaces helper Share the layered-path clip loop so the Current-surface direct path can reuse the same hard-clip stack without duplication. * :zap: Expand direct shape painting onto Current Allow clip stacks, frames, non-identity transforms, and SrcOver opacity on the Current-surface fast path; skip empty non-masked groups. Avoids Fills/Strokes blits for common shapes. * :zap: Skip empty drop-shadow blits; warm DropShadows once Early-out drop-shadow composite when a shape has no visible shadows, and touch DropShadows→Current once per tile instead of per shape to keep flush_and_submit cheap. --- render-wasm/src/render.rs | 251 +++++++++++++++++++++++++------------- 1 file changed, 166 insertions(+), 85 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d1c4228343..5574816b3a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -413,6 +413,10 @@ pub(crate) struct RenderState { /// a tile before its text glyph uploads complete (blank first/center tile). /// One explicit flush warms the submit path for the rest of the pass. pub tile_atlas_flushed: bool, + /// DropShadows→Current touch once per tile when no shape composites a real + /// shadow. A full skip made flush_and_submit very slow (Skia ops-task + /// ordering); doing it per shape was wasted GPU work. + pub drop_shadows_ops_warmed: bool, } pub struct InteractiveDragCrop { @@ -596,6 +600,7 @@ impl RenderState { preserve_target_during_render: false, backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, + drop_shadows_ops_warmed: false, }) } @@ -1248,6 +1253,66 @@ impl RenderState { ) } + /// Apply frame clip stack in document space on the given surface bitmask. + /// Caller must already have those surfaces in doc transform (Fills-style + /// scale + tile translation, or Current after the same). Hard (non-AA) + /// clips avoid alpha seams on semi-transparent overflow. + fn apply_clip_stack_to_surfaces( + &mut self, + clips: &ClipStack, + surface_ids: u32, + scale: f32, + debug_fill_surface: Option, + ) { + for (mut bounds, corners, transform) in clips.iter() { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().concat(transform); + }); + + // Outset clip by ~0.5 to include edge pixels that + // aliased clip misclassifies as outside (causing artifacts). + let outset = 0.5 / scale; + bounds.outset((outset, outset)); + + // Hard clip edge (antialias = false) to avoid alpha seam when clipping + // semi-transparent content larger than the frame. + if let Some(corners) = corners { + let rrect = RRect::new_rect_radii(bounds, corners); + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); + }); + } else { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); + }); + } + + // This renders a red line around clipped + // shapes (frames). + if self.options.is_debug_visible() { + if let Some(fills_surface_id) = debug_fill_surface { + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); + paint.set_stroke_width(4.); + self.surfaces + .canvas(fills_surface_id) + .draw_rect(bounds, &paint); + } + } + + // Uncomment to debug the render_position_data + // if let Type::Text(text_content) = &shape.shape_type { + // text::render_position_data(self, fills_surface_id, &shape, text_content); + // } + + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas() + .concat(&transform.invert().unwrap_or(Matrix::default())); + }); + } + } + #[allow(clippy::too_many_arguments)] pub fn render_shape( &mut self, @@ -1271,16 +1336,6 @@ impl RenderState { | innershadows_surface_id as u32 | text_drop_shadows_surface_id as u32; - // Only save canvas state if we have clipping or transforms - // For simple shapes without clipping, skip expensive save/restore - let needs_save = - clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); - - if needs_save { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().save(); - }); - } let fast_mode = self.options.is_fast_mode(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds @@ -1297,19 +1352,39 @@ impl RenderState { && self.nested_blurs.iter().flatten().any(|blur| { !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 }); + + // Empty non-masked groups paint nothing here (children are separate walker + // nodes). Skip the layered Fills/Strokes path entirely. + if matches!(shape.shape_type, Type::Group(g) if !g.masked) + && shape.fills.is_empty() + && !shape.has_visible_strokes() + && shape.shadows.is_empty() + && shape.blur.is_none() + && shape.background_blur.is_none() + && !has_inherited_blur + && parent_shadows.is_none() + { + return Ok(()); + } + + // Clip is allowed: we apply the same stack on Current after scale+translate. + // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a + // save_layer on Current; painting fills/strokes into that layer matches + // the layered path without Fills/Strokes blits. + // Non-SrcOver blend, frame clip blur, and masked groups stay layered. let can_render_directly = apply_to_current_surface - && clip_bounds.is_none() && offset.is_none() && parent_shadows.is_none() - && !shape.needs_layer() + && shape.blend_mode().0 == skia::BlendMode::SrcOver + && !shape.has_frame_clip_layer_blur() + && !matches!(shape.shape_type, Type::Group(g) if g.masked) && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur && shape.shadows.is_empty() - && shape.transform.is_identity() && matches!( shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) ) && !(shape.fills.is_empty() && has_nested_fills) && !shape @@ -1331,17 +1406,36 @@ impl RenderState { canvas.translate(translation); }); + if let Some(clips) = clip_bounds.as_ref() { + self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None); + } + + if !shape.transform.is_identity() { + let center = shape.center(); + let mut matrix = shape.transform; + matrix.post_translate(center); + matrix.pre_translate(-center); + self.surfaces.apply_mut(target_surface as u32, |s| { + s.canvas().concat(&matrix); + }); + } + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, - shape, - &visible_strokes, - Some(target_surface), - antialias, - outset, - )?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } self.surfaces.apply_mut(target_surface as u32, |s| { s.canvas().restore(); @@ -1352,62 +1446,24 @@ impl RenderState { debug::render_debug_shape(self, Some(shape_selrect_bounds), None); } - if needs_save { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().restore(); - }); - } return Ok(()); } + // Only save canvas state if we have clipping or transforms + // For simple shapes without clipping, skip expensive save/restore + let needs_save = + clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); + + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().save(); + }); + } + // set clipping if let Some(clips) = clip_bounds.as_ref() { let scale = self.get_scale(); - for (mut bounds, corners, transform) in clips.iter() { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().concat(transform); - }); - - // Outset clip by ~0.5 to include edge pixels that - // aliased clip misclassifies as outside (causing artifacts). - let outset = 0.5 / scale; - bounds.outset((outset, outset)); - - // Hard clip edge (antialias = false) to avoid alpha seam when clipping - // semi-transparent content larger than the frame. - if let Some(corners) = corners { - let rrect = RRect::new_rect_radii(bounds, corners); - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rrect(rrect, skia::ClipOp::Intersect, false); - }); - } else { - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clip_rect(bounds, skia::ClipOp::Intersect, false); - }); - } - - // This renders a red line around clipped - // shapes (frames). - if self.options.is_debug_visible() { - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Stroke); - paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); - paint.set_stroke_width(4.); - self.surfaces - .canvas(fills_surface_id) - .draw_rect(bounds, &paint); - } - - // Uncomment to debug the render_position_data - // if let Type::Text(text_content) = &shape.shape_type { - // text::render_position_data(self, fills_surface_id, &shape, text_content); - // } - - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas() - .concat(&transform.invert().unwrap_or(Matrix::default())); - }); - } + self.apply_clip_stack_to_surfaces(clips, surface_ids, scale, Some(fills_surface_id)); } // We don't want to change the value in the global state @@ -3117,6 +3173,7 @@ impl RenderState { /// Renders element drop shadows to DropShadows surface and composites to Current. /// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur). + /// Returns `true` when at least one visible drop shadow was composited. #[allow(clippy::too_many_arguments)] fn render_element_drop_shadows_and_composite( &mut self, @@ -3127,7 +3184,14 @@ impl RenderState { scale: f32, node_render_state: &NodeRenderState, target_surface: SurfaceId, - ) -> Result<()> { + ) -> Result { + // Avoid a blank DropShadows→Current blit + clear on every shape without + // shadows. Callers must still touch DropShadows once per tile when this + // returns false (see `drop_shadows_ops_warmed`). + if element.drop_shadows_visible().next().is_none() { + return Ok(false); + } + let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale)); let inherited_layer_blur = match element.shape_type { Type::Frame(_) | Type::Group(_) => element.blur, @@ -3242,7 +3306,7 @@ impl RenderState { self.surfaces .canvas(SurfaceId::DropShadows) .clear(skia::Color::TRANSPARENT); - Ok(()) + Ok(true) } pub fn render_shape_tree_partial_uncached( @@ -3482,8 +3546,8 @@ impl RenderState { && Self::frame_clip_layer_blur(element).is_some() && element.drop_shadows_visible().next().is_some(); - if shadow_before_layer { - self.render_element_drop_shadows_and_composite( + if shadow_before_layer + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3491,7 +3555,9 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; + )? + { + self.drop_shadows_ops_warmed = true; } // Render background blur BEFORE save_layer so it modifies @@ -3514,8 +3580,7 @@ impl RenderState { if !skip_shadows && !shadows_already_rendered && !matches!(element.shape_type, Type::Text(_)) - { - self.render_element_drop_shadows_and_composite( + && self.render_element_drop_shadows_and_composite( element, tree, &mut extrect, @@ -3523,11 +3588,26 @@ impl RenderState { scale, &node_render_state, target_surface, - )?; - } else { - // This is necessary or the later flush_and_submit will be very slow + )? + { + // Real shadow composite already clears DropShadows. + self.drop_shadows_ops_warmed = true; + } + + if !self.drop_shadows_ops_warmed { + // Touch DropShadows→Current once per tile when no shape has + // composited real shadows yet. Omitting this entirely made + // flush_and_submit very slow (ops-task ordering); repeating + // it per shape was waste. + self.surfaces.draw_into( + SurfaceId::DropShadows, + target_surface, + Some(&skia::Paint::default()), + ); self.surfaces - .draw_into(SurfaceId::DropShadows, target_surface, None); + .canvas(SurfaceId::DropShadows) + .clear(skia::Color::TRANSPARENT); + self.drop_shadows_ops_warmed = true; } // For frames without clip_content, inner strokes must render after children in @@ -3729,6 +3809,7 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; self.tile_atlas_flushed = false; + self.drop_shadows_ops_warmed = false; let viewer_masked_pass = self.viewer_masked_pass(); From 2392015c63751c6a4389e49c025e6df37b4ef3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 6 Aug 2026 12:40:57 +0200 Subject: [PATCH 045/298] :bug: Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) --- .../text-editor/get-file-fixed-size-text.json | 349 ++++++++++++++++++ .../ui/specs/text-editor-v3.spec.js | 111 +++++- .../main/ui/workspace/viewport/selection.cljs | 23 +- 3 files changed, 467 insertions(+), 16 deletions(-) create mode 100644 frontend/playwright/data/text-editor/get-file-fixed-size-text.json diff --git a/frontend/playwright/data/text-editor/get-file-fixed-size-text.json b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json new file mode 100644 index 0000000000..5e88e658a2 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json @@ -0,0 +1,349 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Fixed size text", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": [ + "~u238a17e0-75ff-8075-8006-934586ea2231" + ], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ucc6f0580-449c-8019-8006-9345db077fa0" + ] + } + }, + "~ucc6f0580-449c-8019-8006-9345db077fa0": { + "~#shape": { + "~:y": 150, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "1s4am1jl24s", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "13p0zwl2yhc", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Lorem ipsum" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "20hf3kmyoub", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Fixed text", + "~:width": 300, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 200, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 350 + } + }, + { + "~#point": { + "~:x": 200, + "~:y": 350 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 200, + "~:selrect": { + "~#rect": { + "~:x": 200, + "~:y": 150, + "~:width": 300, + "~:height": 200, + "~:x1": 200, + "~:y1": 150, + "~:x2": 500, + "~:y2": 350 + } + }, + "~:flip-x": null, + "~:height": 200, + "~:flip-y": null + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 963d48a9ca..45f0103d19 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -9,7 +9,9 @@ const FILE = { test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); // WASM_FLAGS already enables render-wasm; add the WASM text editor on top. - await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); + await WasmWorkspacePage.mockConfigFlags(page, [ + "enable-feature-text-editor-wasm", + ]); }); async function openEditorAndSelectAll(workspace) { @@ -22,12 +24,12 @@ async function openEditorAndSelectAll(workspace) { } test.describe("BUG 10502 - Mixed families and variants", () => { - test("Multiple variants of the same font family", async ({ - page, - }) => { + test("Multiple variants of the same font family", async ({ page }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-variants.json", + ); await workspace.goToWorkspace(FILE); await workspace.waitForFirstRender(); @@ -47,10 +49,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => { await expect(fontVariant).toHaveText("--"); }); - test("Mixed font families appear as such in the dropdown", async ({ page }) => { + test("Mixed font families appear as such in the dropdown", async ({ + page, + }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-families.json", + ); // Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch. // Glyphs are irrelevant here: the assertion only inspects the sidebar. await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf"); @@ -204,9 +210,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ await workspace.copy("keyboard"); // Assert the text was copied correctly - const copiedText = await page.evaluate(() => - navigator.clipboard.readText(), - ); + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toBe("Lorem ipsum"); }); +test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => { + // Sets up the workspace and loads a text shape whose size is larger than its text + async function setupFixedSizeText(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + // Enable token inputs so they use the new component with accessible DOM + await workspace.mockConfigFlags(["enable-feature-token-input"]); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the text and zoom to fit, so it is fully visible in the viewport + await workspace.clickLeafLayer("Fixed text"); + await page.keyboard.press("Shift+1"); + await workspace.waitForIdle(); + + return workspace; + } + + async function doubleClickSideHandle(workspace, position) { + const handle = workspace.viewport.getByTestId( + `resize-side-handler-${position}`, + ); + await handle.waitFor(); + const box = await handle.boundingBox(); + await workspace.page.mouse.dblclick( + box.x + box.width / 2, + box.y + box.height / 2, + ); + } + + function measureInput(workspace, name) { + return workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name, exact: true }); + } + + test("Double-clicking the right handle switches to auto-width", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const widthInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Width", exact: true }); + const initialWidth = Number(await widthInput.inputValue()); + + await doubleClickSideHandle(workspace, "right"); + + // Assert auto-width is selected and that the width has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto width", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await widthInput.inputValue())) + .toBeLessThan(initialWidth); + }); + + test("Double-clicking the bottom handle switches to auto-height", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const heightInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Height", exact: true }); + const initialHeight = Number(await heightInput.inputValue()); + + await doubleClickSideHandle(workspace, "bottom"); + + // Assert auto-height is selected and that the height has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto height", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await heightInput.inputValue())) + .toBeLessThan(initialHeight); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 9e497ef8d0..4315ff2d0b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -18,6 +18,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -26,6 +28,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.object :as obj] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def rotation-handler-size 20) @@ -295,13 +298,20 @@ on-double-click (mf/use-fn (mf/deps shape-id position shape-type) - (fn [_event] + (fn [event] (when (= shape-type :text) - (cond - (= position :right) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width))) - (= position :bottom) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))] + ;; Prevent the viewport double-click handler from entering text editor + (dom/stop-propagation event) + (let [grow-type (case position + :right :auto-width + :bottom :auto-height + nil)] + (when (some? grow-type) + (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id]) + (ptk/data-event :layout/update {:ids [shape-id]}))))))))] [:g.resize-handler (when ^boolean show-handler @@ -321,6 +331,7 @@ :height height :class cursor :data-position (name position) + :data-testid (dm/str "resize-side-handler-" (name position)) :transform transform-str :on-pointer-down on-resize :on-double-click on-double-click From fdf1684565699e462a6014f4e0b3c6d691e98f29 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 6 Aug 2026 12:49:52 +0200 Subject: [PATCH 046/298] :bug: Fix plugin modal z index (#11109) * :bug: Fix z-index on plugin modals * :bug: Fix CI --- .../apps/composable-test-suite/package.json | 56 +++++++++---------- .../src/lib/modal/plugin-modal.ts | 17 +++++- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 9d5446a4ea..21b2a1c1e3 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -1,30 +1,30 @@ { - "name": "composable-test-suite", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "start": "vite build --watch", - "init": "pnpm run build && pnpm run start", - "build": "tsc && vite build", - "build:headless": "vite build --config vite.config.headless.ts", - "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", - "preview": "vite preview", - "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", - "types:check": "tsc --noEmit", - "fmt": "prettier --write src ci index.html", - "clean": "rm -rf dist/" - }, - "dependencies": { - "@penpot/plugin-styles": "1.4.2", - "@penpot/plugin-types": "1.4.2" - }, - "devDependencies": { - "playwright": "^1.62.1", - "prettier": "^3.9.6", - "typescript": "^5.9.3", - "vite": "^8.2.0", - "vite-live-preview": "^0.4.0" - }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "name": "composable-test-suite", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "vite build --watch", + "init": "pnpm run build && pnpm run start", + "build": "tsc && vite build", + "build:headless": "vite build --config vite.config.headless.ts", + "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", + "preview": "vite preview", + "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", + "types:check": "tsc --noEmit", + "fmt": "prettier --write src ci index.html", + "clean": "rm -rf dist/" + }, + "dependencies": { + "@penpot/plugin-styles": "1.4.2", + "@penpot/plugin-types": "1.4.2" + }, + "devDependencies": { + "playwright": "^1.62.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3", + "vite": "^8.2.0", + "vite-live-preview": "^0.4.0" + }, + "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" } diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 53ea472494..6939b8097a 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -6,7 +6,8 @@ import { dragHandler } from '../drag-handler.js'; import modalCss from './plugin.modal.css?inline'; import { resizeModal } from '../create-modal.js'; -const MIN_Z_INDEX = 3; +const MIN_Z_INDEX = 300; +const Z_INDEX_VAR = '--z-index-set'; export class PluginModalElement extends HTMLElement { constructor() { @@ -43,7 +44,19 @@ export class PluginModalElement extends HTMLElement { return Number(modal.style.zIndex); }); - const maxZIndex = Math.max(...zIndexModals, MIN_Z_INDEX); + // Read the application z-index scale via the inherited CSS custom property + // `--z-index-set` (defined on :root). Custom properties pierce shadow DOM + // boundaries, so the value is available even though the modal uses a Shadow + // root. Falls back to MIN_Z_INDEX when the variable is unset or unparseable + // (e.g. when the runtime is used outside the Penpot app shell). + const declared = getComputedStyle(this) + .getPropertyValue(Z_INDEX_VAR) + .trim(); + const parsed = Number(declared); + const baseZIndex = + Number.isFinite(parsed) && parsed > 0 ? parsed : MIN_Z_INDEX; + + const maxZIndex = Math.max(...zIndexModals, baseZIndex); this.style.zIndex = (maxZIndex + 1).toString(); } From 229d24e8f27a028109310ac6467ae6b1064459da Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 13:30:59 +0200 Subject: [PATCH 047/298] :bug: Fix regression on uploading binfile with incorrect schema --- backend/src/app/rpc/commands/binfile.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 78ba08e5fd..e68210aae3 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -118,7 +118,7 @@ (def ^:private schema:import-binfile [:and - [:map {:title "import-binfile" :closed true} + [:map {:title "import-binfile"} [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:project-id ::sm/uuid] From 314a2a245f17790d60db0d0e3ec89c109b8d34b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero-Cantero?= <807608+alvorithm@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:14:44 +0200 Subject: [PATCH 048/298] :books: Fix the devenv backend-flags instructions (#11077) The section pointed at `docker/devenv/docker-compose.yaml`, which #9906 deleted when it split the devenv compose into `docker-compose.infra.yml` and `docker-compose.main.yml`. The same page names both replacements in its architecture section, so only this one was missed. Setting PENPOT_FLAGS in the container environment would not have worked anyway: `backend/scripts/_env` expands the inherited value before its own list, so its flags win. Document the mechanism that does work, the gitignored `backend/scripts/_env.local` that `start-dev` sources right after `_env`, and the left-to-right last-wins rule that lets an override switch off a flag `_env` enables. --- docs/technical-guide/developer/devenv.md | 26 +++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 922ed24f1d..5c3de5254a 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -419,16 +419,28 @@ After creating or modifying this file, **reload the browser** (no need to restar ### Backend flags via PENPOT_FLAGS Backend feature flags are controlled through the `PENPOT_FLAGS` environment -variable using the same `enable-` / `disable-` format. You can set -this in the `docker/devenv/docker-compose.yaml` file under the `main` service -`environment` section: +variable using the same `enable-` / `disable-` format. The devenv +sets its own list in `backend/scripts/_env`. -```yaml -environment: - - PENPOT_FLAGS=enable-access-tokens enable-mcp +To change that list for your checkout, create `backend/scripts/_env.local`. +`backend/scripts/start-dev` sources it immediately after `_env`, and the file +is gitignored, so your override never appears in `git status`: + +```bash +export PENPOT_FLAGS="$PENPOT_FLAGS enable-access-tokens enable-mcp" ``` -This requires **restarting the backend** to take effect. +Flags are applied left to right and the last entry wins, so appending to +`$PENPOT_FLAGS` both adds flags and switches off ones that `_env` enables: +`disable-demo-users` at the end turns off the demo users that `_env` enables +earlier. + +Setting `PENPOT_FLAGS` in the container environment does not work for this, +because `_env` expands the inherited value *before* its own list. Any flag it +sets afterwards wins over yours. + +This requires **restarting the backend** to take effect: stop the process in +the `backend` tmux window and run `./scripts/start-dev` again. > **Note**: Some features (e.g., access tokens, webhooks) need both frontend and > backend flags enabled to work end-to-end. The frontend flag enables the UI, while From de8d8ca4014771425490954eeea6024452f98da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 6 Aug 2026 15:49:04 +0200 Subject: [PATCH 049/298] :bug: Fix serialization of constraints (#11108) --- frontend/src/app/render_wasm/api/shapes.cljs | 8 +-- frontend/src/app/render_wasm/serializers.cljs | 4 +- render-wasm/src/wasm/layouts/constraints.rs | 56 ++++++++++--------- render-wasm/src/wasm/shapes/base_props.rs | 21 ++----- 4 files changed, 40 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs index 02c2c91ee2..31cf032ed3 100644 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ b/frontend/src/app/render_wasm/api/shapes.cljs @@ -110,13 +110,9 @@ blend-mode (sr/translate-blend-mode (get shape :blend-mode)) constraint-h (let [c (get shape :constraints-h)] - (if (some? c) - (sr/translate-constraint-h c) - CONSTRAINT-NONE)) + (sr/translate-constraint-h c)) constraint-v (let [c (get shape :constraints-v)] - (if (some? c) - (sr/translate-constraint-v c) - CONSTRAINT-NONE)) + (sr/translate-constraint-v c)) opacity (d/nilv (get shape :opacity) 1.0) rotation (d/nilv (get shape :rotation) 0.0) diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 83ecf4f4d2..6c35d5d455 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -116,13 +116,13 @@ (defn translate-constraint-h [type] (let [values (unchecked-get wasm/serializers "constraint-h") - default 5] ;; TODO: fix code in rust so we have a proper None variant + default (unchecked-get values "none")] (d/nilv (unchecked-get values (d/name type)) default))) (defn translate-constraint-v [type] (let [values (unchecked-get wasm/serializers "constraint-v") - default 5] ;; TODO: fix code in rust so we have a proper None variant + default (unchecked-get values "none")] (d/nilv (unchecked-get values (d/name type)) default))) (defn translate-bool-type diff --git a/render-wasm/src/wasm/layouts/constraints.rs b/render-wasm/src/wasm/layouts/constraints.rs index 8760ed5592..5e87e61f70 100644 --- a/render-wasm/src/wasm/layouts/constraints.rs +++ b/render-wasm/src/wasm/layouts/constraints.rs @@ -7,11 +7,12 @@ use crate::with_current_shape_mut; #[repr(u8)] #[allow(dead_code)] pub enum RawConstraintH { - Left = 0, - Right = 1, - Leftright = 2, // odd casing to comply with cljs value - Center = 3, - Scale = 4, + None = 0, + Left = 1, + Right = 2, + Leftright = 3, // odd casing to comply with cljs value + Center = 4, + Scale = 5, } impl From for RawConstraintH { @@ -20,14 +21,15 @@ impl From for RawConstraintH { } } -impl From for ConstraintH { +impl From for Option { fn from(value: RawConstraintH) -> Self { match value { - RawConstraintH::Left => ConstraintH::Left, - RawConstraintH::Right => ConstraintH::Right, - RawConstraintH::Leftright => ConstraintH::LeftRight, - RawConstraintH::Center => ConstraintH::Center, - RawConstraintH::Scale => ConstraintH::Scale, + RawConstraintH::None => None, + RawConstraintH::Left => Some(ConstraintH::Left), + RawConstraintH::Right => Some(ConstraintH::Right), + RawConstraintH::Leftright => Some(ConstraintH::LeftRight), + RawConstraintH::Center => Some(ConstraintH::Center), + RawConstraintH::Scale => Some(ConstraintH::Scale), } } } @@ -36,11 +38,12 @@ impl From for ConstraintH { #[repr(u8)] #[allow(dead_code)] pub enum RawConstraintV { - Top = 0, - Bottom = 1, - Topbottom = 2, // odd casing to comply with cljs value - Center = 3, - Scale = 4, + None = 0, + Top = 1, + Bottom = 2, + Topbottom = 3, // odd casing to comply with cljs value + Center = 4, + Scale = 5, } impl From for RawConstraintV { @@ -49,14 +52,15 @@ impl From for RawConstraintV { } } -impl From for ConstraintV { +impl From for Option { fn from(value: RawConstraintV) -> Self { match value { - RawConstraintV::Top => ConstraintV::Top, - RawConstraintV::Bottom => ConstraintV::Bottom, - RawConstraintV::Topbottom => ConstraintV::TopBottom, - RawConstraintV::Center => ConstraintV::Center, - RawConstraintV::Scale => ConstraintV::Scale, + RawConstraintV::None => None, + RawConstraintV::Top => Some(ConstraintV::Top), + RawConstraintV::Bottom => Some(ConstraintV::Bottom), + RawConstraintV::Topbottom => Some(ConstraintV::TopBottom), + RawConstraintV::Center => Some(ConstraintV::Center), + RawConstraintV::Scale => Some(ConstraintV::Scale), } } } @@ -64,16 +68,16 @@ impl From for ConstraintV { #[no_mangle] pub extern "C" fn set_shape_constraint_h(constraint: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { - let constraint = RawConstraintH::from(constraint); - shape.set_constraint_h(Some(constraint.into())); + let constraint: Option = RawConstraintH::from(constraint).into(); + shape.set_constraint_h(constraint); }); } #[no_mangle] pub extern "C" fn set_shape_constraint_v(constraint: u8) { with_current_shape_mut!(state, |shape: &mut Shape| { - let constraint = RawConstraintV::from(constraint); - shape.set_constraint_v(Some(constraint.into())); + let constraint: Option = RawConstraintV::from(constraint).into(); + shape.set_constraint_v(constraint); }); } diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index e9b6a6e7b0..ab204a1f44 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -14,7 +14,6 @@ use super::RawShapeType; const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; const FLAG_HIDDEN: u8 = 0b0000_0010; -const CONSTRAINT_NONE: u8 = 0xFF; const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); @@ -87,19 +86,11 @@ impl RawBasePropsData { } fn constraint_h(&self) -> Option { - if self.constraint_h == CONSTRAINT_NONE { - None - } else { - Some(RawConstraintH::from(self.constraint_h).into()) - } + RawConstraintH::from(self.constraint_h).into() } fn constraint_v(&self) -> Option { - if self.constraint_v == CONSTRAINT_NONE { - None - } else { - Some(RawConstraintV::from(self.constraint_v).into()) - } + RawConstraintV::from(self.constraint_v).into() } } @@ -219,10 +210,10 @@ mod tests { bytes[33] = FLAG_CLIP_CONTENT | FLAG_HIDDEN; // blend_mode = Overlay (15) bytes[34] = 15; - // constraint_h = Center (3) - bytes[35] = 3; - // constraint_v = Scale (4) - bytes[36] = 4; + // constraint_h = Center (4) + bytes[35] = 4; + // constraint_v = Scale (5) + bytes[36] = 5; // opacity bytes[40..44].copy_from_slice(&0.5_f32.to_le_bytes()); // rotation From a76401596e4ac13ca8d6f9048ecb7ea87b26d1f6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 6 Aug 2026 15:58:11 +0200 Subject: [PATCH 050/298] :zap: Skip imperceptible shadows and simplify low-scale strokes (#11102) * :zap: Skip drop shadows that are imperceptible at current scale Filter drop shadows by on-screen footprint (stricter for recursive shapes) so overview HQ avoids expensive blur passes that barely show. * :zap: Simplify Path and Bool strokes at low scale At overview zooms, Inner/Outer strokes fall back to Center and dash/dotted styles become solid when the pattern is subpixel. Strokes are never skipped so stroke-only icons stay visible. * :zap: Drain GPU work on partial render frames Partial frames only flushed the Backbuffer, so tile GPU commands queued until present_frame's flush_and_submit and stalled the browser on large files. Submit the context each partial frame without presenting Target or re-composing the tile atlas. * :zap: Prefer direct painting when effects are imperceptible Skip the Fills/Strokes layered path when drop/inner shadows would not paint at the current scale, and allow stroke-only shapes (fills_none) on the direct path. Apply the same footprint LOD to inner-shadow painting. --- render-wasm/src/render.rs | 153 +++++++++++++++++++++--------- render-wasm/src/render/shadows.rs | 42 +++++--- render-wasm/src/render/strokes.rs | 14 ++- render-wasm/src/shapes/shadows.rs | 79 +++++++++++++++ render-wasm/src/shapes/strokes.rs | 108 +++++++++++++++++++++ 5 files changed, 333 insertions(+), 63 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5574816b3a..5403eb98b6 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -935,10 +935,6 @@ impl RenderState { Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -1229,11 +1225,16 @@ impl RenderState { } fn get_inherited_drop_shadows(&self) -> Option> { + let scale = self.get_scale(); let drop_shadows: Vec<&Shadow> = self .nested_shadows .iter() .flat_map(|shadows| shadows.iter()) - .filter(|shadow| !shadow.hidden() && shadow.style() == crate::shapes::ShadowStyle::Drop) + .filter(|shadow| { + !shadow.hidden() + && shadow.style() == crate::shapes::ShadowStyle::Drop + && shadow.is_perceptible_at_scale(scale) + }) .collect(); if drop_shadows.is_empty() { @@ -1337,6 +1338,7 @@ impl RenderState { | text_drop_shadows_surface_id as u32; let fast_mode = self.options.is_fast_mode(); + let skip_drop_shadows = self.should_skip_drop_shadows(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds // up across many shapes; reverts to full quality on commit. @@ -1367,11 +1369,25 @@ impl RenderState { return Ok(()); } + // Only perceptible shadows need the layered Fills/Strokes path. Use the + // same footprint LOD as when painting drop and inner shadows. + let scale = self.get_scale(); + let shadows_need_layered = !skip_drop_shadows + && (shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())) + || shape + .inner_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))); + // Clip is allowed: we apply the same stack on Current after scale+translate. // Opacity < 1 with SrcOver is OK: render_shape_enter already opened a // save_layer on Current; painting fills/strokes into that layer matches // the layered path without Fills/Strokes blits. // Non-SrcOver blend, frame clip blur, and masked groups stay layered. + // Stroke-only (fills_none) can go direct: empty fills are a no-op and + // strokes paint into Current. Requires Partial GPU drain (dc1ab) so + // large SVG-icon files do not backlog commands until Full present. let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1381,20 +1397,15 @@ impl RenderState { && shape.blur.is_none() && shape.background_blur.is_none() && !has_inherited_blur - && shape.shadows.is_empty() + && !shadows_need_layered && matches!( shape.shape_type, Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) ) && !(shape.fills.is_empty() && has_nested_fills) - && !shape - .svg_attrs - .as_ref() - .is_some_and(|attrs| attrs.fill_none) && target_surface != SurfaceId::Export; if can_render_directly { - let scale = self.get_scale(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -1661,10 +1672,25 @@ impl RenderState { ); } } else { - let mut drop_shadows = shape.drop_shadow_paints(); + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } } let inner_shadows = shape.inner_shadow_paints(); @@ -1688,32 +1714,34 @@ impl RenderState { .unzip(); if let Some(parent_shadows) = parent_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, &shape, &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, )?; } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; } } else { // 1. Text drop shadows @@ -2433,9 +2461,12 @@ impl RenderState { panic!("FrameType::None"); } FrameType::Partial => { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); + // Drain tile GPU work (Current / tile atlas / cache) without + // presenting Target and without re-snapshotting the tile atlas — + // composition stays deferred to Full. A Backbuffer flush alone + // left commands queued until present_frame's flush_and_submit, + // which stalled the browser on large files. + crate::get_gpu_state().context.flush_and_submit(); } FrameType::Full => { // A full-quality frame is now complete. Rebuild the per-shape crop @@ -2638,6 +2669,19 @@ impl RenderState { true } + /// Skip all drop/inner shadows in fast mode, or when even a large design-space + /// shadow would be subpixel. Otherwise filter per shadow via + /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). + #[inline] + pub(crate) fn should_skip_drop_shadows(&self) -> bool { + if self.options.is_fast_mode() { + return true; + } + let scale = self.get_scale(); + scale * crate::shapes::DROP_SHADOW_LARGE_DESIGN_PX + < crate::shapes::DROP_SHADOW_MIN_DEVICE_PX + } + #[inline] fn clip_target_surface_to_stack( &mut self, @@ -3185,10 +3229,15 @@ impl RenderState { node_render_state: &NodeRenderState, target_surface: SurfaceId, ) -> Result { - // Avoid a blank DropShadows→Current blit + clear on every shape without - // shadows. Callers must still touch DropShadows once per tile when this - // returns false (see `drop_shadows_ops_warmed`). - if element.drop_shadows_visible().next().is_none() { + // Avoid a blank DropShadows→Current blit + clear when nothing will paint + // (no shadows, fast/overview skip, or all footprints subpixel). Callers + // must still touch DropShadows once per tile when this returns false + // (see `drop_shadows_ops_warmed`). + if self.should_skip_drop_shadows() + || !element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())) + { return Ok(false); } @@ -3198,7 +3247,13 @@ impl RenderState { _ => None, }; + let recursive = element.is_recursive(); + let mut rendered_any = false; for shadow in element.drop_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; + } + rendered_any = true; let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces @@ -3290,6 +3345,10 @@ impl RenderState { self.surfaces.canvas(SurfaceId::DropShadows).restore(); } + if !rendered_any { + return Ok(false); + } + if let Some(clips) = clip_bounds.as_ref() { let antialias = !self.options.is_fast_mode() && element.should_use_antialias(scale, self.options.antialias_threshold); @@ -3541,10 +3600,12 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() + && !self.should_skip_drop_shadows() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() - && element.drop_shadows_visible().next().is_some(); + && element + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale_for(scale, element.is_recursive())); if shadow_before_layer && self.render_element_drop_shadows_and_composite( @@ -3570,8 +3631,8 @@ impl RenderState { } if !node_render_state.is_root() && self.focus_mode.is_active() { - // Skip expensive drop shadow rendering in fast mode (during pan/zoom). - let skip_shadows = self.options.is_fast_mode(); + // Skip expensive drop shadows in fast mode and at overview zooms. + let skip_shadows = self.should_skip_drop_shadows(); // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c3de172ad8..c6baadc0b3 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -13,10 +13,16 @@ pub fn render_fill_inner_shadows( antialias: bool, surface_id: SurfaceId, ) { - if shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); + if !shape.has_fills() || render_state.should_skip_drop_shadows() { + return; + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id); } } @@ -38,19 +44,25 @@ pub fn render_stroke_inner_shadows( antialias: bool, surface_id: SurfaceId, ) -> Result<()> { - if !shape.has_fills() { - for shadow in shape.inner_shadows_visible() { - let filter = shadow.get_inner_shadow_filter(); - strokes::render_single( - render_state, - shape, - stroke, - Some(surface_id), - filter.as_ref(), - antialias, - None, // Inner shadows don't use spread - )?; + if shape.has_fills() || render_state.should_skip_drop_shadows() { + return Ok(()); + } + let scale = render_state.get_scale(); + let recursive = shape.is_recursive(); + for shadow in shape.inner_shadows_visible() { + if !shadow.is_perceptible_at_scale_for(scale, recursive) { + continue; } + let filter = shadow.get_inner_shadow_filter(); + strokes::render_single( + render_state, + shape, + stroke, + Some(surface_id), + filter.as_ref(), + antialias, + None, // Inner shadows don't use spread + )?; } Ok(()) } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 5e5901d53a..b4ac78eb05 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -561,6 +561,13 @@ fn draw_image_stroke_in_container( surface_id: SurfaceId, ) -> Result<()> { let scale = render_state.get_scale(); + let lod_stroke; + let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) { + lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale); + &lod_stroke + } else { + stroke + }; let Some(image) = get_resources().images.get(&image_fill.id()) else { return Ok(()); }; @@ -938,12 +945,13 @@ fn render_merged( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = representative.path_lod_at_scale(is_open, scale); let mut paint = - representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); + lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); paint.set_shader(merged.shader()); draw_stroke_on_path( canvas, - representative, + &lod_stroke, path, &paint, path_transform.as_ref(), @@ -1097,6 +1105,8 @@ fn render_single_internal( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { let is_open = path.is_open(); + let lod_stroke = stroke.path_lod_at_scale(is_open, scale); + let stroke = &lod_stroke; let mut paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); // Apply outset by increasing stroke width diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 6cfa912659..6f446e3223 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -4,6 +4,18 @@ use super::blurs::radius_to_sigma; use super::Color; use crate::render::filters::compose_filters; +/// Soft visibility floor in device pixels for leaf shapes. Below this, a drop +/// shadow is visual noise relative to its blur cost. +pub const DROP_SHADOW_MIN_DEVICE_PX: f32 = 2.0; + +/// Recursive shapes (frames/groups) redraw children into the shadow layer; they +/// need a clearer on-screen footprint before that cost is worthwhile. +pub const DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX: f32 = 4.0; + +/// Generous design-space shadow budget used with [`DROP_SHADOW_MIN_DEVICE_PX`] +/// for a hard global early-out (subpixel even for huge shadows). +pub const DROP_SHADOW_LARGE_DESIGN_PX: f32 = 64.0; + #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum ShadowStyle { #[default] @@ -48,6 +60,32 @@ impl Shadow { self.hidden } + /// Approximate on-screen footprint (blur/spread + offset) at `scale` (zoom×dpr). + #[inline] + pub fn device_extent(&self, scale: f32) -> f32 { + let soft = self.blur.max(self.spread); + let offset = self.offset.0.abs().max(self.offset.1.abs()); + (soft + offset) * scale + } + + /// True when this shadow still has a perceptible footprint at `scale`. + /// Recursive shapes use a higher floor because compositing children into + /// the shadow layer is far more expensive than a leaf silhouette. + #[inline] + pub fn is_perceptible_at_scale(&self, scale: f32) -> bool { + self.is_perceptible_at_scale_for(scale, false) + } + + #[inline] + pub fn is_perceptible_at_scale_for(&self, scale: f32, recursive: bool) -> bool { + let min = if recursive { + DROP_SHADOW_RECURSIVE_MIN_DEVICE_PX + } else { + DROP_SHADOW_MIN_DEVICE_PX + }; + self.device_extent(scale) >= min + } + pub fn get_drop_shadow_filter(&self) -> Option { let sigma = radius_to_sigma(self.blur); let mut filter = image_filters::drop_shadow_only( @@ -112,3 +150,44 @@ impl Shadow { self.offset.1 *= value; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn shadow(blur: f32, spread: f32, ox: f32, oy: f32) -> Shadow { + Shadow::new( + skia::Color::BLACK, + blur, + spread, + (ox, oy), + ShadowStyle::Drop, + false, + ) + } + + #[test] + fn leaf_floor_at_moderate_zoom() { + // blur 16 @ 0.13 ≈ 2.08px → keep leaf + assert!(shadow(16.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + // blur 8 @ 0.13 ≈ 1.04px → skip leaf (below 2px) + assert!(!shadow(8.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, false)); + } + + #[test] + fn recursive_floor_is_stricter() { + // blur 24 @ 0.13 ≈ 3.12px → keep leaf, skip recursive (needs 4px) + let s = shadow(24.0, 0.0, 0.0, 0.0); + assert!(s.is_perceptible_at_scale_for(0.13, false)); + assert!(!s.is_perceptible_at_scale_for(0.13, true)); + // blur 32 @ 0.13 ≈ 4.16px → keep recursive + assert!(shadow(32.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, true)); + } + + #[test] + fn overview_scale_vs_extent() { + // At 0.038 even blur 50 is only ~1.9px — below leaf floor. + assert!(!shadow(50.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + assert!(shadow(60.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.038, false)); + } +} diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index f774a70457..2e80abafe0 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -7,6 +7,14 @@ use super::StrokeLineCap; use super::StrokeLineJoin; use super::SvgAttrs; +/// Soft floor in device pixels for dropping dash/dotted PathEffects when the +/// pattern period is effectively invisible. +pub const STROKE_MIN_DEVICE_PX: f32 = 0.75; + +/// When Inner/Outer doubled-width footprint is below this (device px), paint +/// as Center to avoid save_layer / Clear paths. +pub const STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX: f32 = 2.0; + #[derive(Debug, Clone, PartialEq, Copy)] pub enum StrokeStyle { Solid, @@ -69,6 +77,62 @@ impl Stroke { } } + /// Inner/Outer use a doubled-width Center stroke plus clip/clear. When that + /// footprint is thin on screen, fall back to a plain Center stroke. + #[inline] + pub fn simplified_kind_at_scale(&self, is_open: bool, scale: f32) -> StrokeKind { + let kind = self.render_kind(is_open); + match kind { + StrokeKind::Inner | StrokeKind::Outer + if 2.0 * self.max_width() * scale < STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX => + { + StrokeKind::Center + } + other => other, + } + } + + /// Drop dash/dotted PathEffects when the pattern period is subpixel. + #[inline] + pub fn style_at_scale(&self, scale: f32) -> StrokeStyle { + if self.style == StrokeStyle::Solid { + return StrokeStyle::Solid; + } + let period = match self.style { + StrokeStyle::Dotted => self.width + 5.0, + StrokeStyle::Dashed => { + let dash = self.dash.unwrap_or(self.width + 10.); + let gap = self.gap.unwrap_or(self.width + 10.); + dash.min(gap) + } + StrokeStyle::Mixed => self.width + 1.0, + StrokeStyle::Solid => return StrokeStyle::Solid, + }; + if period * scale < STROKE_MIN_DEVICE_PX { + StrokeStyle::Solid + } else { + self.style + } + } + + /// Path/Bool overview LOD: simplify Inner/Outer and dash/dotted at low + /// scale. Never skips painting; stroke-only icons would otherwise go blank. + pub fn path_lod_at_scale(&self, is_open: bool, scale: f32) -> Stroke { + let kind = self.simplified_kind_at_scale(is_open, scale); + let style = self.style_at_scale(scale); + let kind_unchanged = kind == self.render_kind(is_open); + let style_unchanged = style == self.style; + if kind_unchanged && style_unchanged { + return self.clone(); + } + let mut stroke = self.clone(); + if !is_open { + stroke.kind = kind; + } + stroke.style = style; + stroke + } + /// Per-side widths [top, right, bottom, left] when they actually differ. /// Returns `None` when unset or when all sides are equal, so the uniform /// render path (which supports dashed/dotted styles) keeps handling that @@ -493,4 +557,48 @@ mod tests { assert_eq!(stroke.widths, Some([2.0, 4.0, 6.0, 8.0])); assert_eq!(stroke.width, 4.0); } + + fn solid_center(width: f32) -> Stroke { + Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None) + } + + #[test] + fn inner_outer_simplify_to_center_when_thin() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + // 2 * 8 * 0.1 = 1.6 < 2.0, simplify to Center + assert_eq!( + inner.simplified_kind_at_scale(false, 0.1), + StrokeKind::Center + ); + // 2 * 8 * 0.2 = 3.2 >= 2.0, keep Inner + assert_eq!( + inner.simplified_kind_at_scale(false, 0.2), + StrokeKind::Inner + ); + } + + #[test] + fn dash_becomes_solid_when_period_subpixel() { + let dashed = + Stroke::new_center_stroke(2.0, StrokeStyle::Dashed, None, None, Some(20.0), Some(20.0)); + // period 20 * 0.03 = 0.6 < 0.75, solid + assert_eq!(dashed.style_at_scale(0.03), StrokeStyle::Solid); + // period 20 * 0.05 = 1.0 >= 0.75, keep dashed + assert_eq!(dashed.style_at_scale(0.05), StrokeStyle::Dashed); + } + + #[test] + fn path_lod_never_drops_thin_stroke() { + // Hairline strokes must still paint (stroke-only icons). + let thin = solid_center(1.0).path_lod_at_scale(false, 0.5); + assert_eq!(thin.width, 1.0); + assert_eq!(thin.kind, StrokeKind::Center); + } + + #[test] + fn path_lod_simplifies_inner_at_overview() { + let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None); + let lod = inner.path_lod_at_scale(false, 0.1); + assert_eq!(lod.kind, StrokeKind::Center); + } } From 38b990ef9091f7f207fcfc611f736aad3d3e2523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 6 Aug 2026 16:13:06 +0200 Subject: [PATCH 051/298] :wrench: Add exporter headless backend (#10875) * :sparkles: Add headless wasm render backend to the exporter * :recycle: Move render-wasm bridge to common and split wasm builds * :wrench: Upload builtin font variants in the wasm exporter * :recycle: Move shared font and resources utils out of render_wasm * :zap: Fetch only the exported roots in the wasm exporter * :zap: Bound save_layer rects in the vector export path --- .gitignore | 2 + backend/scripts/_env | 6 +- common/deps.edn | 2 +- .../resources/fonts/gfonts.2025.11.28.json | 0 common/src/app/common/flags.cljc | 3 + .../main => common/src/app/common}/fonts.clj | 6 +- .../src/app/common/fonts.cljs | 160 ++++++- common/src/app/common/render_wasm/README.md | 24 + .../app/common}/render_wasm/api/props.cljs | 16 +- .../app/common}/render_wasm/api/shapes.cljs | 12 +- common/src/app/common/render_wasm/enums.clj | 54 +++ .../src/app/common}/render_wasm/helpers.cljc | 4 +- .../src/app/common}/render_wasm/mem.cljs | 6 +- .../app/common}/render_wasm/mem/heap32.cljs | 2 +- .../common}/render_wasm/serialize_shape.cljs | 6 +- .../app/common}/render_wasm/serializers.cljs | 6 +- .../render_wasm/serializers/color.cljs | 2 +- .../app/common}/render_wasm/text_content.cljs | 27 +- .../src/app/common}/render_wasm/wasm.cljs | 55 +-- .../src/app/common/types/shape/images.cljs | 6 +- exporter/package.json | 1 + exporter/scripts/build | 14 + exporter/src/app/core.cljs | 7 + exporter/src/app/renderer.cljs | 26 +- exporter/src/app/renderer/wasm.cljs | 451 ++++++++++++++++++ exporter/src/app/wasm.cljs | 255 ++++++++++ exporter/src/app/wasm/enums.cljs | 19 + exporter/src/app/wasm/serialize.cljs | 46 ++ exporter/src/app/wasm/text.cljs | 35 ++ frontend/deps.edn | 3 +- frontend/package.json | 2 +- frontend/scripts/build | 2 +- frontend/scripts/build-fonts-preview.js | 2 +- frontend/src/app/main/data/workspace.cljs | 2 +- frontend/src/app/main/fonts.cljs | 70 +-- frontend/src/app/main/render_viewer_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 27 +- frontend/src/app/render_wasm/api/enums.cljs | 19 + frontend/src/app/render_wasm/api/fonts.cljs | 71 +-- frontend/src/app/render_wasm/api/texts.cljs | 16 +- frontend/src/app/render_wasm/api/webgl.cljs | 2 +- frontend/src/app/render_wasm/text_editor.cljs | 12 +- frontend/src/app/worker/thumbnails.cljs | 2 +- frontend/src/debug.cljs | 6 +- .../render_wasm/process_objects_test.cljs | 4 +- .../text_editor_caret_color_test.cljs | 2 +- render-wasm/Cargo.toml | 4 + render-wasm/README.md | 28 ++ render-wasm/_build_env | 80 +++- render-wasm/build | 23 +- render-wasm/lint | 2 +- render-wasm/src/render/text.rs | 70 ++- render-wasm/src/render/vector.rs | 67 ++- render-wasm/src/shapes.rs | 15 +- render-wasm/test | 2 +- render-wasm/watch | 7 +- 56 files changed, 1475 insertions(+), 320 deletions(-) rename {frontend => common}/resources/fonts/gfonts.2025.11.28.json (100%) rename {frontend/src/app/main => common/src/app/common}/fonts.clj (98%) rename frontend/src/app/render_wasm/fallback_fonts.cljs => common/src/app/common/fonts.cljs (61%) create mode 100644 common/src/app/common/render_wasm/README.md rename {frontend/src/app => common/src/app/common}/render_wasm/api/props.cljs (96%) rename {frontend/src/app => common/src/app/common}/render_wasm/api/shapes.cljs (96%) create mode 100644 common/src/app/common/render_wasm/enums.clj rename {frontend/src/app => common/src/app/common}/render_wasm/helpers.cljc (94%) rename {frontend/src/app => common/src/app/common}/render_wasm/mem.cljs (97%) rename {frontend/src/app => common/src/app/common}/render_wasm/mem/heap32.cljs (97%) rename {frontend/src/app => common/src/app/common}/render_wasm/serialize_shape.cljs (93%) rename {frontend/src/app => common/src/app/common}/render_wasm/serializers.cljs (99%) rename {frontend/src/app => common/src/app/common}/render_wasm/serializers/color.cljs (90%) rename {frontend/src/app => common/src/app/common}/render_wasm/text_content.cljs (90%) rename {frontend/src/app => common/src/app/common}/render_wasm/wasm.cljs (58%) rename frontend/src/app/render_wasm/resources.cljs => common/src/app/common/types/shape/images.cljs (91%) create mode 100644 exporter/src/app/renderer/wasm.cljs create mode 100644 exporter/src/app/wasm.cljs create mode 100644 exporter/src/app/wasm/enums.cljs create mode 100644 exporter/src/app/wasm/serialize.cljs create mode 100644 exporter/src/app/wasm/text.cljs create mode 100644 frontend/src/app/render_wasm/api/enums.cljs diff --git a/.gitignore b/.gitignore index 382b89c92f..9481cf6b87 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ opencode.json /docker/images/bundle* /exporter/target /exporter/.shadow-cljs +/exporter/resources/wasm/ +/exporter/src/app/wasm/shared.js /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ diff --git a/backend/scripts/_env b/backend/scripts/_env index 04dcf1a724..724b55f05b 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key # PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by # docker/devenv/defaults.env and injected via the main service's env block. +if [ -f /home/selfsigned.crt ]; then + export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt; +fi + # Background worker flag is per-instance. Defaults to enabled (ws0); ws1+ # overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only # run on ws0, keeping notification Pub/Sub bound to a single Valkey. See @@ -101,5 +105,3 @@ function setup_minio() { mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q } - - diff --git a/common/deps.edn b/common/deps.edn index edffb487f0..36f4b63b16 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -55,7 +55,7 @@ io.aviso/pretty {:mvn/version "1.4.4"} environ/environ {:mvn/version "1.2.0"}} - :paths ["src" "vendor" "target/classes"] + :paths ["src" "vendor" "resources" "target/classes"] :aliases {:dev {:extra-deps diff --git a/frontend/resources/fonts/gfonts.2025.11.28.json b/common/resources/fonts/gfonts.2025.11.28.json similarity index 100% rename from frontend/resources/fonts/gfonts.2025.11.28.json rename to common/resources/fonts/gfonts.2025.11.28.json diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 9988c1a9f8..bfccc0afdf 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -178,6 +178,9 @@ :stroke-path :stroke-per-side + ;; Exporter only: uses render-wasm for export instead of browser + ;; renderer. + :wasm-export :custom-shortcuts :remote-media-processing}) diff --git a/frontend/src/app/main/fonts.clj b/common/src/app/common/fonts.clj similarity index 98% rename from frontend/src/app/main/fonts.clj rename to common/src/app/common/fonts.clj index c8abbd0478..9b0be1660f 100644 --- a/frontend/src/app/main/fonts.clj +++ b/common/src/app/common/fonts.clj @@ -4,8 +4,9 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.main.fonts +(ns app.common.fonts "A fonts loading macros." + (:require [app.common.uuid :as uuid] [clojure.data.json :as json] @@ -47,6 +48,3 @@ (let [data (slurp (io/resource path)) data (json/read-str data)] `~(mapv parse-gfont (get data "items")))) - - - diff --git a/frontend/src/app/render_wasm/fallback_fonts.cljs b/common/src/app/common/fonts.cljs similarity index 61% rename from frontend/src/app/render_wasm/fallback_fonts.cljs rename to common/src/app/common/fonts.cljs index 80f52be51c..5366d6c2c4 100644 --- a/frontend/src/app/render_wasm/fallback_fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -4,13 +4,159 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.fallback-fonts - "Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and - which (google) fallback fonts cover them. Pure data + pure fns — no browser - or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the - headless exporter (`app.renderer.wasm`) compute the SAME fallback set from - the same source. Anything a host must fetch/upload for text to render - belongs here, not in host code.") +(ns app.common.fonts + "Host-agnostic font knowledge shared by every renderer: the google catalog + baked at compile time from `common/resources/fonts/gfonts.*.json`, the + font-id/uuid mapping, weight/style variant resolution, and the noto fallback + fonts a text's scripts and emoji need. Also the one family bundled with the + frontend, which is not a google font but resolves by the same rules. + + Pure data + pure fns — no browser or Node dependencies — so the workspace and + the headless exporter resolve the SAME fonts from the same source. Anything a + host must fetch or upload for text to render belongs here, not in host code." + (:require-macros [app.common.fonts :refer [preload-gfonts]]) + (:require + [app.common.data :as d] + [app.common.uuid :as uuid] + [cuerdas.core :as str])) + +;; --- GOOGLE FONTS CATALOG + +(def catalog + (preload-gfonts "fonts/gfonts.2025.11.28.json")) + +(def ^:private by-id + (reduce (fn [m font] (assoc m (:id font) font)) {} catalog)) + +(def ^:private by-uuid + (reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog)) + +(defn gfont-id->uuid + "Maps a `gfont-` id to its (compilation-stable) catalog uuid, or nil." + [gfont-id] + (:uuid (get by-id gfont-id))) + +;; --- font-id -> wasm uuid + +(def ^:private custom-prefix "custom-") +(def ^:private gfont-prefix "gfont-") + +(defn font-id->backend + "Which source a content font-id comes from: `:google` for `gfont-`, + `:custom` for `custom-`, `:builtin` for everything else (bundled + families, but also unknown or malformed ids — the same bucket + `font-id->uuid` maps to `uuid/zero`)." + [font-id] + (cond + (not (string? font-id)) :builtin + (str/starts-with? font-id gfont-prefix) :google + (str/starts-with? font-id custom-prefix) :custom + :else :builtin)) + +(defn font-id->uuid + "Maps a content font-id to the uuid WASM keys fonts by: + + - `gfont-` -> the catalog uuid, + - `custom-` -> that uuid, + - anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM + resolves to the default font." + + [font-id] + (case (font-id->backend font-id) + :google (or (gfont-id->uuid font-id) uuid/zero) + :custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero) + uuid/zero)) + +;; --- proxy urls + +(def ^:private gstatic-prefix + "https://fonts.gstatic.com/s") + +(defn gstatic->proxy-url + [s base] + (let [base (str/rtrim (str base) "/")] + (str/replace (str s) gstatic-prefix base))) + +;; --- variant resolution + +(defn closest-variant + [variants target-weight target-style] + (when-let [target-weight (d/parse-integer target-weight)] + (let [result + (reduce + (fn [closest-match variant] + (let [weight (d/parse-integer (:weight variant)) + distance (abs (- target-weight weight)) + matches-style? (= target-style (:style variant)) + current {:variant variant + :weight weight + :distance distance}] + (cond + ;; Exact match found + (and (zero? distance) + (if target-style matches-style? true)) + (reduced current) + + (nil? closest-match) current + + ;; Update best match if this variant is closer or equal distance but higher weight + (or (< distance (:distance closest-match)) + (and (= distance (:distance closest-match)) + (> weight (:weight closest-match)))) + current + + ;; Same weight as the `closest-match` but the style matches `target-style` + (and (= weight (:weight closest-match)) matches-style?) + current + + :else + closest-match))) + nil + variants)] + (:variant result)))) + +(defn resolve-ttf-url + [font-uuid weight style] + (when-let [font (get by-uuid font-uuid)] + (let [style (if (zero? style) "normal" "italic") + variants (:variants font)] + (:ttf-url (or (closest-variant variants weight style) + (first variants)))))) + +;; --- BUILTIN FONTS +;; +;; Bundled with the frontend, served from `/fonts/`. Shared so the +;; workspace and the exporter upload the same TTF for a given weight/style. + +(def local-fonts + [{:id "sourcesanspro" + :name "Source Sans Pro" + :family "sourcesanspro" + :variants + [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} + {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} + {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} + {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} + {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} + {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} + {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} + {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} + {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} + {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} + {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} + {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) + +(defn resolve-ttf-file + "Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the + same nearest-weight rule as the google catalog." + [weight style] + (let [variants (:variants (first local-fonts))] + (:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic")) + (first variants))))) + +;; --- FALLBACK FONTS +;; +;; Which scripts/emoji a text uses and which (google) fallback fonts cover them. (def ^:private emoji-pattern #"(?:\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]") diff --git a/common/src/app/common/render_wasm/README.md b/common/src/app/common/render_wasm/README.md new file mode 100644 index 0000000000..b153832e52 --- /dev/null +++ b/common/src/app/common/render_wasm/README.md @@ -0,0 +1,24 @@ +# `app.common.render-wasm.*` + +The host-agnostic ClojureScript side of the render-wasm binary protocol: byte +layouts, memory helpers and serializers that turn Penpot shapes into the buffers +`render-wasm` consumes. + +The workspace drives it from `app.render-wasm.*`, the headless exporter from +`app.wasm.*` — same code underneath, so the two cannot drift. + +Font knowledge is *not* here even though both hosts need it for rendering: it is +not specific to the wasm backend, so the google fonts catalog (baked from +`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the +emoji/script fallback tables live in `app.common.fonts`. Likewise the image-id +enumeration lives in `app.common.types.shape.images`. + +`shared.js` is not here: it is a per-build artifact, so each host compiles +against the copy from its own render-wasm build and passes it to +`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`). + +## Rules for anything added here + +**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on +`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also +has to run under plain Node — a `js/document` here breaks the exporter. diff --git a/frontend/src/app/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs similarity index 96% rename from frontend/src/app/render_wasm/api/props.cljs rename to common/src/app/common/render_wasm/api/props.cljs index 26bbdadc3a..6caaac7de8 100644 --- a/frontend/src/app/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.api.props +(ns app.common.render-wasm.api.props "Browser-free WASM shape property setters, shared by the workspace render orchestrator (`app.render-wasm.api`) and the headless exporter (`app.wasm.serialize`). @@ -15,15 +15,15 @@ data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`." (:require [app.common.math :as mth] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.mem.heap32 :as mem.h32] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills :as types.fills] [app.common.types.fills.impl :as types.fills.impl] - [app.common.types.path :as path] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm])) + [app.common.types.path :as path])) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/common/src/app/common/render_wasm/api/shapes.cljs similarity index 96% rename from frontend/src/app/render_wasm/api/shapes.cljs rename to common/src/app/common/render_wasm/api/shapes.cljs index 31cf032ed3..55f442d0d0 100644 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ b/common/src/app/common/render_wasm/api/shapes.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.api.shapes +(ns app.common.render-wasm.api.shapes "Batched shape property serialization for improved WASM performance. This module provides a single WASM call to set all base shape properties, @@ -13,11 +13,11 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid])) ;; Binary layout constants matching Rust implementation: ;; diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj new file mode 100644 index 0000000000..3bc382ec8d --- /dev/null +++ b/common/src/app/common/render_wasm/enums.clj @@ -0,0 +1,54 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.render-wasm.enums + "Serializer enum table from `shared.js`") + +(def ^:private serializer-exports + [["raster-format" "RasterFormat"] + ["blur-type" "RawBlurType"] + ["blend-mode" "RawBlendMode"] + ["bool-type" "RawBoolType"] + ["font-style" "RawFontStyle"] + ["flex-direction" "RawFlexDirection"] + ["grid-direction" "RawGridDirection"] + ["grow-type" "RawGrowType"] + ["align-items" "RawAlignItems"] + ["align-self" "RawAlignSelf"] + ["align-content" "RawAlignContent"] + ["justify-items" "RawJustifyItems"] + ["justify-content" "RawJustifyContent"] + ["justify-self" "RawJustifySelf"] + ["wrap-type" "RawWrapType"] + ["grid-track-type" "RawGridTrackType"] + ["shadow-style" "RawShadowStyle"] + ["guide-kind" "RawGuideKind"] + ["stroke-style" "RawStrokeStyle"] + ["stroke-cap" "RawStrokeCap"] + ["shape-type" "RawShapeType"] + ["constraint-h" "RawConstraintH"] + ["constraint-v" "RawConstraintV"] + ["sizing" "RawSizing"] + ["vertical-align" "RawVerticalAlign"] + ["fill-data" "RawFillData"] + ["text-align" "RawTextAlign"] + ["text-direction" "RawTextDirection"] + ["text-decoration" "RawTextDecoration"] + ["text-transform" "RawTextTransform"] + ["multiple-state" "MultipleState"] + ["transform-entry-kind" "RawTransformEntryKind"] + ["segment-data" "RawSegmentData"] + ["stroke-linecap" "RawStrokeLineCap"] + ["stroke-linejoin" "RawStrokeLineJoin"] + ["fill-rule" "RawFillRule"]]) + +(defmacro serializers + [alias] + (let [alias (name alias)] + `(cljs.core/js-obj + ~@(mapcat (fn [[key export]] + [key (symbol alias export)]) + serializer-exports)))) diff --git a/frontend/src/app/render_wasm/helpers.cljc b/common/src/app/common/render_wasm/helpers.cljc similarity index 94% rename from frontend/src/app/render_wasm/helpers.cljc rename to common/src/app/common/render_wasm/helpers.cljc index 452e3f1eb4..236a952248 100644 --- a/frontend/src/app/render_wasm/helpers.cljc +++ b/common/src/app/common/render_wasm/helpers.cljc @@ -4,8 +4,8 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.helpers - #?(:cljs (:require-macros [app.render-wasm.helpers])) +(ns app.common.render-wasm.helpers + #?(:cljs (:require-macros [app.common.render-wasm.helpers])) (:require [app.common.data :as d])) (def error-code diff --git a/frontend/src/app/render_wasm/mem.cljs b/common/src/app/common/render_wasm/mem.cljs similarity index 97% rename from frontend/src/app/render_wasm/mem.cljs rename to common/src/app/common/render_wasm/mem.cljs index d90d2f7fa7..3a64315ba0 100644 --- a/frontend/src/app/render_wasm/mem.cljs +++ b/common/src/app/common/render_wasm/mem.cljs @@ -4,11 +4,11 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.mem +(ns app.common.render-wasm.mem (:require [app.common.buffer :as buf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm])) (defn ->offset-32 "Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset" diff --git a/frontend/src/app/render_wasm/mem/heap32.cljs b/common/src/app/common/render_wasm/mem/heap32.cljs similarity index 97% rename from frontend/src/app/render_wasm/mem/heap32.cljs rename to common/src/app/common/render_wasm/mem/heap32.cljs index 07ac6b749f..bfe7c49053 100644 --- a/frontend/src/app/render_wasm/mem/heap32.cljs +++ b/common/src/app/common/render_wasm/mem/heap32.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.mem.heap32 +(ns app.common.render-wasm.mem.heap32 "A memory write helpers that uses 32 bits addressed offsets." (:require [app.common.data.macros :as dm] diff --git a/frontend/src/app/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs similarity index 93% rename from frontend/src/app/render_wasm/serialize_shape.cljs rename to common/src/app/common/render_wasm/serialize_shape.cljs index ffc659757e..a0e776a62f 100644 --- a/frontend/src/app/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -4,7 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.serialize-shape +(ns app.common.render-wasm.serialize-shape "Single source of truth for the host-independent part of serializing a whole shape into the WASM design state. @@ -24,8 +24,8 @@ The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps dispatching per changed key through the same underlying `props` setters." (:require - [app.render-wasm.api.props :as props] - [app.render-wasm.api.shapes :as shapes])) + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.shapes :as shapes])) (defn serialize-shape! "Applies every host-independent WASM property of `shape`. `set-shape-base-props` diff --git a/frontend/src/app/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs similarity index 99% rename from frontend/src/app/render_wasm/serializers.cljs rename to common/src/app/common/render_wasm/serializers.cljs index 6c35d5d455..862eafae7c 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -4,16 +4,16 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL - (ns app.render-wasm.serializers + (ns app.common.render-wasm.serializers (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (defn u8 diff --git a/frontend/src/app/render_wasm/serializers/color.cljs b/common/src/app/common/render_wasm/serializers/color.cljs similarity index 90% rename from frontend/src/app/render_wasm/serializers/color.cljs rename to common/src/app/common/render_wasm/serializers/color.cljs index 7d5d28b8c7..c3ef27fdfc 100644 --- a/frontend/src/app/render_wasm/serializers/color.cljs +++ b/common/src/app/common/render_wasm/serializers/color.cljs @@ -1,4 +1,4 @@ -(ns app.render-wasm.serializers.color +(ns app.common.render-wasm.serializers.color (:require [app.common.math :as mth])) diff --git a/frontend/src/app/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs similarity index 90% rename from frontend/src/app/render_wasm/text_content.cljs rename to common/src/app/common/render_wasm/text_content.cljs index 457a633066..51ce707fb4 100644 --- a/frontend/src/app/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -4,23 +4,24 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.text-content +(ns app.common.render-wasm.text-content "Single source of truth for writing a text shape's content into the WASM design state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is - identical for the workspace and the headless exporter — only *font resolution* - differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts - catalog + custom variants). So the byte-writing lives here and font resolution - is injected via the `opts` map passed to `write-shape-text!`. + identical for the workspace and the headless exporter, and so is the font-id + -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs — + the workspace has a loaded fonts DB, the exporter does not — so that part is + injected via the `opts` map passed to `write-shape-text!`. Fully portable (no store/DOM/React), so it runs under Node too." (:require [app.common.data :as d] + [app.common.fonts :as cfnt] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.uuid :as uuid] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) (def ^:const PARAGRAPH-ATTR-U8-SIZE 12) @@ -169,13 +170,15 @@ "Writes one paragraph's spans + text into WASM and appends it to the current shape via `_set_shape_text_content`. - `opts` injects host-specific font resolution: - - `:normalize-font-id` (string font-id -> wasm uuid) — required in practice, + `opts` injects host-specific font handling: + - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared + `cfnt/font-id->uuid`, which is what both hosts want — a host only + overrides it if it keys its font store some other way, - `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a fonts DB (workspace); default to identity (the exporter resolves variants differently / not at all)." [spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span] - :or {normalize-font-id identity + :or {normalize-font-id cfnt/font-id->uuid normalize-paragraph identity normalize-span (fn [span _paragraph] span)}}] (let [paragraph (normalize-paragraph paragraph) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs similarity index 58% rename from frontend/src/app/render_wasm/wasm.cljs rename to common/src/app/common/render_wasm/wasm.cljs index 933b200530..657b44047d 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -4,8 +4,7 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.wasm - (:require ["./api/shared.js" :as shared])) +(ns app.common.render-wasm.wasm) (defonce internal-frame-id nil) (defonce internal-frame-type 0) @@ -65,41 +64,19 @@ (set! gl-context nil) (set! context-initialized? false)) -(defonce serializers - #js {:raster-format shared/RasterFormat - :blur-type shared/RawBlurType - :blend-mode shared/RawBlendMode - :bool-type shared/RawBoolType - :font-style shared/RawFontStyle - :flex-direction shared/RawFlexDirection - :grid-direction shared/RawGridDirection - :grow-type shared/RawGrowType - :align-items shared/RawAlignItems - :align-self shared/RawAlignSelf - :align-content shared/RawAlignContent - :justify-items shared/RawJustifyItems - :justify-content shared/RawJustifyContent - :justify-self shared/RawJustifySelf - :wrap-type shared/RawWrapType - :grid-track-type shared/RawGridTrackType - :shadow-style shared/RawShadowStyle - :guide-kind shared/RawGuideKind - :stroke-style shared/RawStrokeStyle - :stroke-cap shared/RawStrokeCap - :shape-type shared/RawShapeType - :constraint-h shared/RawConstraintH - :constraint-v shared/RawConstraintV - :sizing shared/RawSizing - :vertical-align shared/RawVerticalAlign - :fill-data shared/RawFillData - :text-align shared/RawTextAlign - :text-direction shared/RawTextDirection - :text-decoration shared/RawTextDecoration - :text-transform shared/RawTextTransform - :multiple-state shared/MultipleState - :transform-entry-kind shared/RawTransformEntryKind - :segment-data shared/RawSegmentData - :stroke-linecap shared/RawStrokeLineCap - :stroke-linejoin shared/RawStrokeLineJoin - :fill-rule shared/RawFillRule}) +(defonce serializers nil) + +(defn init-serializers! + "Binds the enum table produced by the `enums/serializers` macro." + [table] + (let [missing (array)] + (doseq [key (js/Object.keys table)] + (when (undefined? (unchecked-get table key)) + (.push missing key))) + + (when (pos? (alength missing)) + (throw (ex-info "stale or incomplete render-wasm shared.js" + {:missing (vec missing)}))) + + (set! serializers table))) diff --git a/frontend/src/app/render_wasm/resources.cljs b/common/src/app/common/types/shape/images.cljs similarity index 91% rename from frontend/src/app/render_wasm/resources.cljs rename to common/src/app/common/types/shape/images.cljs index 9f564aa990..54e6fef6c3 100644 --- a/frontend/src/app/render_wasm/resources.cljs +++ b/common/src/app/common/types/shape/images.cljs @@ -4,12 +4,12 @@ ;; ;; Copyright (c) KALEIDOS INC Sucursal en España SL -(ns app.render-wasm.resources +(ns app.common.types.shape.images "Host-agnostic enumeration of the external resources a scene needs to render: which image bytes its shapes reference. Pure data walking — no browser or Node dependencies — so the workspace and the headless exporter - derive the same set from the same source (sibling of - `app.render-wasm.fallback-fonts`, which does the same for fonts)." + derive the same set from the same source (counterpart of + `app.common.fonts`, which does the same for fonts)." (:require [app.common.types.fills :as types.fills])) diff --git a/exporter/package.json b/exporter/package.json index 83518eabee..ae3eefb04c 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -33,6 +33,7 @@ "watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main", "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", + "build:wasm": "../render-wasm/build export", "build": "pnpm run clear:shadow-cache && pnpm run build:app", "fmt": "cljfmt fix --parallel=true src/", "check-fmt": "cljfmt check --parallel=true src/", diff --git a/exporter/scripts/build b/exporter/scripts/build index 40eba8f44c..a240c17b75 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -8,6 +8,17 @@ export NODE_ENV=production; corepack enable; corepack install || exit 1; pnpm install || exit 1; +pnpm run build:wasm; + +WASM_SRC="resources/wasm"; +WASM_SHARED="src/app/wasm/shared.js"; +if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then + echo "ERROR: the render-wasm build did not produce:" >&2; + echo " $WASM_SRC/render-wasm.wasm" >&2; + echo " $WASM_SHARED" >&2; + exit 1; +fi + rm -rf target # Build the application @@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/; cp package.json target/; touch target/pnpm-workspace.yaml; +mkdir -p target/$WASM_SRC; +cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/; + cat < serialize -> provision fonts and + images -> relayout text with the real fonts -> render each object. + + One shared WASM design state, so requests are serialized one at a time. + + Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the + browser path." + (:require + ["node:fs" :as fs] + ["undici" :as http] + [app.common.data :as d] + [app.common.fonts :as cfnt] + ;; Required for side effects: these register the transit read handlers and + ;; deftype impls the `get-page` response is decoded into. + [app.common.geom.matrix] + [app.common.geom.point] + [app.common.geom.rect] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.types.fills.impl] + [app.common.types.objects-map] + [app.common.types.path.impl] + [app.common.types.shape] + [app.common.types.shape.images :as images] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.util.mime :as mime] + [app.util.shell :as sh] + [app.wasm :as wasm] + [app.wasm.serialize :as serialize] + [cuerdas.core :as str] + [promesa.core :as p])) + +;; --- module lifecycle (one shared, lazily-initialized instance) + +(defonce ^:private module* (atom nil)) + +(defn- ensure-module! + [] + (or @module* + (reset! module* (wasm/init!)))) + +;; --- serialized access to the shared module +;; +;; `handle-multiple-export` fans out partitions concurrently, but there is one +;; design state and one global mem buffer, so their serialize/render/alloc must +;; not interleave. + +(defonce ^:private queue (atom (p/resolved nil))) + +(defn- enqueue! + "Runs `thunk` (0-arg, returns a promise) only after all previously enqueued + work has settled. Returns `thunk`'s promise. A task's failure is isolated: + it doesn't break the chain for the next task." + [thunk] + (let [result (p/handle @queue (fn [_ _] (thunk)))] + (reset! queue (p/handle result (fn [_ _] nil))) + result)) + +;; --- backend endpoints +;; +;; Every fetch targets the internal endpoint (falling back to public-uri), +;; in a deployment the exporter reaches the backend over the container network + +(defn- internal-uri + "Absolute URI for `path` on the internal (backend) endpoint." + [path] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + +(defn- error-detail + "Node's fetch reports every transport failure as a bare `TypeError: fetch + failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a + nested `cause` chain that the logger does not print. Flattens the chain into + one readable string." + [cause] + (->> (iterate (fn [^js e] (unchecked-get e "cause")) cause) + (take-while some?) + (take 5) + (map (fn [^js e] + (let [code (unchecked-get e "code") + msg (or (unchecked-get e "message") (str e))] + (if code (str code ": " msg) msg)))) + (str/join " <- "))) + +(defn- fetch! + "`undici/fetch` that fails with an ex-info carrying the target uri and the + unwrapped cause chain, so a failed request says what actually went wrong and + against which endpoint." + [uri opts] + (->> (p/do (http/fetch uri opts)) + (p/merr (fn [cause] + (p/rejected (ex-info "http fetch failed" + {:uri uri :detail (error-detail cause)} + cause)))))) + +(defn- explain + "Log-friendly reason for `cause`: the detail `fetch!` already attached, or a + freshly unwrapped chain for anything else (WASM aborts, decode errors)." + [cause] + (or (:detail (ex-data cause)) + (error-detail cause))) + +(defn- rpc-headers + "Auth headers for backend RPC calls (management key + bearer)." + [token] + #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Authorization" (str "Bearer " token)}) + +(defn- asset-headers + "Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to + a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple + authentication types\")." + [token] + #js {"X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}) + +;; --- shape bundle fetch (backend RPC) + +(defn- fetch-objects + "Fetches the exported roots and their children from the backend via the + `get-page` RPC (`:object-id`, as the browser render path does), using the + same auth the exporter uses elsewhere (management key + bearer)." + [{:keys [file-id page-id share-id token objects]}] + (let [headers (rpc-headers token) + root-ids (into #{} (map :id) objects) + body (t/encode-str (cond-> {:file-id file-id + :page-id page-id} + (seq root-ids) (assoc :object-id root-ids) + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-page")] + (l/dbg :hint "wasm render: get-page" + :uri uri + :file-id (str file-id) + :page-id (str page-id) + :roots (count root-ids)) + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (->> (.text resp) + (p/mcat (fn [resp-body] + (l/error :hint "wasm render: get-page failed" + :uri uri + :status (.-status resp) + :body resp-body) + (p/rejected (ex-info "get-page failed" + {:status (.-status resp) + :body resp-body})))))))) + (p/fmap t/decode-str) + (p/fmap :objects)))) + +;; --- font resolution +;; +;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape` +;; reports it. Custom (team) fonts resolve through the file's font variants, +;; google fonts through the shared `app.common.fonts` catalog; builtin +;; fonts through its bundled family + the frontend's static `/fonts/`. + +(defn- fetch-font-variants + "Team (custom) font variants for the file, or nil — a failure here degrades + to fallback fonts, it does not fail the export." + [{:keys [file-id share-id token]}] + (let [headers (rpc-headers token) + body (t/encode-str (cond-> {:file-id file-id} + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-font-variants")] + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [s] (when s (t/decode-str s)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: get-font-variants failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- fetch-ttf-bytes + "Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure + here degrades to fallback fonts, it does not fail the export." + ([uri] (fetch-ttf-bytes uri #js {:method "GET"})) + ([uri opts] + (->> (fetch! uri opts) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (p/resolved nil)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: font fetch failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +;; TTF bytes cached for the process lifetime, keyed by whatever identifies the +;; variant (a gfont id+weight+style, a builtin file name). +(defonce ^:private font-bytes* (atom {})) + +(defn- cached-ttf-bytes + [cache-key fetch-fn] + (if-let [bytes (get @font-bytes* cache-key)] + (p/resolved bytes) + (->> (fetch-fn) + (p/fmap (fn [buf] + (when buf (swap! font-bytes* assoc cache-key buf)) + buf))))) + +(defn- fetch-asset-bytes + [asset-id {:keys [token]}] + (fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id)) + #js {:method "GET" :headers (asset-headers token)})) + +(defn- fetch-gfont-bytes + [ttf-url] + (fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))) + +(defn- fetch-builtin-font-bytes + [ttf-file] + (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) + +(defn- make-resolve-font + "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom + variants first, matching uuid+weight+style then degrading to uuid+weight then + uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps + every builtin family to; google catalog otherwise." + [variants params] + (fn [{:keys [id weight style]}] + (let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3)) + style-str (if (zero? style) "normal" "italic") + variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight) + (= (name (:font-style v)) style-str))) + variants) + (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight))) + variants) + (d/seek (fn [v] (= (:font-id v) font-uuid)) variants))] + (cond + (:ttf-file-id variant) + (fetch-asset-bytes (:ttf-file-id variant) params) + + (= uuid/zero font-uuid) + (fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style)) + + :else + (if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)] + (fetch-gfont-bytes gurl) + (p/resolved nil)))))) + +;; --- fallback fonts (emoji + per-script noto fonts) +;; +;; Emoji and non-latin scripts render through fallback families, not through +;; any span's font family, so `wasm/fonts-for-shape` never reports them and the +;; provisioning above never uploads them. Must run per request, since +;; `clear-fonts!` empties the store; the TTF bytes stay cached per process. + +(defn- scene-fallback-fonts + "Fallback font descriptors needed by the scene's text. Deduped because + several languages map to one noto family and provisioning is concurrent — + otherwise they all miss the byte cache at once and refetch the same TTF." + [scene] + (let [texts (for [shape (vals scene) + :when (= :text (:type shape)) + node (or (some->> (:content shape) (tree-seq :children :children)) []) + :let [text (:text node)] + :when (string? text)] + text) + emoji? (boolean (some cfnt/contains-emoji? texts)) + langs (reduce cfnt/collect-used-languages #{} texts)] + (distinct + (cond-> (cfnt/add-noto-fonts [] langs) + emoji? (cfnt/add-emoji-font))))) + +(defn- fetch-fallback-font-bytes + "Downloads one fallback font's TTF. Cached by the whole variant, not just + `font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a + font-id-only key would serve the first downloaded variant for every other one." + [{:keys [font-id weight style]}] + (if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))] + (cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url)) + (p/resolved nil))) + +(defn- provision-fallback-fonts! + [scene] + (->> (scene-fallback-fonts scene) + (map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}] + (if-let [font-uuid (cfnt/gfont-id->uuid font-id)] + (->> (fetch-fallback-font-bytes font) + (p/fmap (fn [buf] + (if buf + (wasm/store-font! {:id (uuid/get-u32 font-uuid) + :weight weight + :style style + :emoji? (boolean is-emoji) + :fallback? (boolean is-fallback)} + buf) + (l/warn :hint "wasm render: fallback font unavailable" + :font-id font-id))))) + (p/resolved nil)))) + (p/all))) + +;; --- image resolution +;; +;; Image fills reference file-media ids; the encoded bytes go straight to +;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens +;; once per request rather than per rendered object. + +(defn- fetch-file-media-bytes + "Downloads an image fill's encoded bytes by file-media id." + [media-id {:keys [token]}] + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-file-media-id/" media-id))] + (->> (fetch! uri #js {:method "GET" :headers headers}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (do + (l/warn :hint "wasm render: image fetch non-200" + :media-id (str media-id) + :uri uri + :status (.-status resp)) + (p/resolved nil))))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: image fetch failed" + :media-id (str media-id) :uri uri + :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- provision-images! + "Fetches and stores every image the scene references (shape, stroke and + text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, + the image store is not reset per request, so already-held images are skipped + and repeated exports of a file reuse them." + [scene params] + (let [all-ids (images/scene-image-ids scene) + new-ids (remove wasm/image-cached? all-ids)] + (l/dbg :hint "wasm render: provisioning images" + :total (count all-ids) + :cached (- (count all-ids) (count new-ids))) + (->> new-ids + (map (fn [image-id] + (->> (fetch-file-media-bytes image-id params) + (p/fmap (fn [buf] + (if buf + (do + (l/dbg :hint "wasm render: image stored" + :media-id (str image-id) + :bytes (.-byteLength ^js buf)) + (wasm/store-image! image-id buf)) + (l/warn :hint "wasm render: image unavailable" + :media-id (str image-id)))))))) + (p/all)))) + +(defn- relayout-text! + "Recomputes layout for every text shape, once the real fonts are provisioned + (serialize-time layout used the fallback)." + [scene] + (doseq [shape (vals scene) + :when (= :text (:type shape))] + (wasm/update-text-layout! (:id shape)))) + +;; --- render + +(defn- render-object-bytes + [type id scale] + (if (= :pdf type) + (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + (wasm/render-shape-raster id scale type))) + +(defn- render* + [{:keys [scale type objects] :as params} on-object] + (l/dbg :hint "wasm render: start" + :type type + :scale scale + :objects (count objects) + :file-id (str (:file-id params)) + :page-id (str (:page-id params))) + (->> (ensure-module!) + (p/mcat (fn [_] (fetch-objects params))) + (p/mcat (fn [scene] + (l/dbg :hint "wasm render: scene fetched" :shapes (count scene)) + (serialize/serialize-scene! scene) + (l/dbg :hint "wasm render: scene serialized") + ;; So fonts from a previous request don't leak into this one. + (wasm/clear-fonts!) + (->> (p/all [(fetch-font-variants params) + (provision-images! scene params) + (provision-fallback-fonts! scene)]) + (p/mcat + (fn [[variants _]] + (let [resolve-font (make-resolve-font (or variants []) params)] + ;; Before rendering, so the relayout below sees real + ;; font metrics. Deduped across objects: a partition + ;; sharing one family downloads its TTF once. + (wasm/provision-fonts! (map :id objects) resolve-font)))) + (p/mcat + (fn [_] + (relayout-text! scene) + (p/run + (fn [{:keys [id] :as object}] + (let [bytes (render-object-bytes type id scale) + path (sh/tempfile :prefix "penpot.tmp.wasm." + :suffix (mime/get-extension type))] + (l/dbg :hint "wasm render: object rendered" + :object-id (str id) :bytes (.-length bytes)) + (fs/writeFileSync path bytes) + ;; `on-object` returns a plain value (zip append) or + ;; a promise (single export's file move); `p/do` + ;; normalizes both to a thenable. + (p/do (on-object (assoc object :path path))))) + objects)))))) + (p/fmap (fn [result] + ;; After the request, never mid-render, so an image can't + ;; disappear under a running export. + (let [evicted (wasm/evict-images! wasm/image-cache-mb)] + (when (pos? evicted) + (l/info :hint "wasm render: evicted cached images" :count evicted))) + result)) + (p/merr (fn [cause] + (l/error :hint "wasm render: failed" + :detail (explain cause) + :internal-uri (str (cf/get-internal-uri)) + :cause cause) + ;; A panic can leave the mem buffer allocated or the instance + ;; aborted; drop it so the next request rebuilds a fresh one. + (reset! module* nil) + (p/rejected cause))))) + +(defn render + "Public entry. `enqueue!` keeps concurrent exports off each other's toes on + the shared WASM instance." + [params on-object] + (enqueue! (fn [] (render* params on-object)))) diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs new file mode 100644 index 0000000000..8a06b9b4f2 --- /dev/null +++ b/exporter/src/app/wasm.cljs @@ -0,0 +1,255 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm + "Headless driver for the render-wasm module under Node: the GPU-free + counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it + via `init_headless`, and exposes font provisioning + shape rendering. + + Serialization is reused from the portable render-wasm leaves, so this + namespace owns only the Node runtime and the headless render calls. + + Requires render-wasm built with `-sENVIRONMENT=web,node`." + (:require + ["node:fs" :as fs] + ["node:path" :as path] + [app.common.data :as d] + [app.common.logging :as l] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.uuid :as uuid] + ;; Required for side effects: binds the generated enums. + [app.wasm.enums] + [promesa.core :as p] + [shadow.esm :refer [dynamic-import]])) + +(def ^:private default-viewport-width 1920) +(def ^:private default-viewport-height 1080) + +;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. +(def ^:private RASTER-HEADER-BYTES 12) +;; render_shape_pdf result header: [len u32] only. +(def ^:private PDF-HEADER-BYTES 4) +;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. +(def ^:private FONT-ENTRY-BYTES 24) + +(def artifact-dir + "Built render-wasm artifact, relative to the process working directory. Same + path in devenv and inside the bundle, so it is a constant." + "resources/wasm") + +(def image-cache-mb + "Byte budget (MB) the image store is trimmed to between requests." + 256) + +(defn- read-result-bytes + "Reads `len` bytes from the WASM heap starting at `offset`, copying them out + (via `.slice`) before the buffer is freed." + [offset len] + (.slice (mem/get-heap-u8) offset (+ offset len))) + +;; --- MODULE LIFECYCLE + +(defn init! + "Loads the render-wasm artifact under Node and boots it headless. Sets the + shared `wasm/internal-module` so the portable serialization leaves work. + Idempotent-ish: callers should hold the returned module." + ([] (init! default-viewport-width default-viewport-height)) + ([width height] + (let [dir artifact-dir + js-path (path/resolve dir "render-wasm.js") + wasm-path (path/resolve dir "render-wasm.wasm") + wasm-bytes (fs/readFileSync wasm-path)] + (l/info :hint "loading render-wasm (headless)" :js js-path) + ;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import` + ;; compiles to an undefined `import$`). + (->> (dynamic-import (str "file://" js-path)) + (p/mcat + (fn [mod] + (let [factory (unchecked-get mod "default")] + (factory + #js {;; Bypass the web fetch loader: instantiate from local bytes. + :instantiateWasm + (fn [imports success] + (-> (js/WebAssembly.instantiate wasm-bytes imports) + (.then (fn [result] (success (.-instance result))))) + #js {}) + :locateFile (fn [p] (path/resolve dir p)) + :printErr (fn [s] (l/warn :wasm s))})))) + (p/fmap + (fn [module] + (set! wasm/internal-module module) + (h/call module "_init_headless" width height) + (set! wasm/context-initialized? true) + (l/info :hint "render-wasm headless module ready" :width width :height height) + module)))))) + +;; --- FONT PROVISIONING (on demand, mirrors the browser) + +(defn fonts-for-shape + "Returns the distinct font families needed to render the subtree rooted at + `shape-id` as a vector of {:id :weight :style}. Equivalent to + the browser's `get-content-fonts`, but read from the loaded WASM tree." + [shape-id] + (let [module wasm/internal-module + buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves + offset (h/call module "_get_fonts_for_shape" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) + heap32 (mem/get-heap-u32) + n (aget heap32 (mem/->offset-32 offset)) + ;; `vec` must stay eager: it reads the result buffer, and the + ;; `mem/free` below invalidates these offsets. + entries (vec + (for [i (range n)] + (let [base (+ offset 4 (* i FONT-ENTRY-BYTES)) + u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))] + {:id #js [(u32 0) (u32 4) (u32 8) (u32 12)] + :weight (u32 16) + :style (u32 20)})))] + (mem/free) + entries)) + +(defn- font-key + "Value key for a family map. Its `:id` is a JS array, so the map itself can't + be compared by value." + [{:keys [id weight style]}] + [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) + +(defn fonts-for-shapes + "Distinct font families needed by every subtree in `shape-ids`. Objects in a + partition overwhelmingly share families, so deduping here means one download + and one `_store_font` per family rather than one per object." + [shape-ids] + (into [] (comp (mapcat fonts-for-shape) + (d/distinct-xf font-key)) + shape-ids)) + +(defn store-font! + "Uploads one font's TTF bytes into the WASM font store, keyed by the family + (uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer. + + Does NOT call `mem/free` — `store_font` (and likewise `store_image` below) + releases the global buffer itself on the Rust side. Freeing again here would + drop a buffer a later writer already owns." + [{:keys [id weight style emoji? fallback?]} font-bytes] + (let [module wasm/internal-module + size (.-byteLength font-bytes) + ptr (h/call module "_alloc_bytes" size) + heap (mem/get-heap-u8)] + (.set heap (js/Uint8Array. font-bytes) ptr) + (h/call module "_store_font" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + weight style (boolean emoji?) (boolean fallback?)))) + +(defn clear-fonts! + "Resets the WASM font store. Must be called once per render request because + the shared module would otherwise accumulate fonts across requests." + [] + (h/call wasm/internal-module "_clear_fonts")) + +(defn update-text-layout! + "Recomputes a text shape's layout with the currently provisioned fonts. Text is + laid out at serialize time using the fallback font (real fonts aren't uploaded + yet), so this must run again after `provision-fonts!` or glyph metrics/line + breaks are wrong." + [shape-id] + (let [buf (uuid/get-u32 shape-id)] + (h/call wasm/internal-module "_update_shape_text_layout_for" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)))) + +(defn image-cached? + "True when the module's image store already holds this image (full size). + The store is NOT reset between requests, so previously provisioned images + can be reused instead of refetched." + [image-id] + (let [buf (uuid/get-u32 image-id)] + (not (zero? (h/call wasm/internal-module "_is_image_cached" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + false))))) + +(defn store-image! + "Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into + the WASM image store via `_store_image`. Buffer layout matches the Rust reader: + [shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are + keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an + ArrayBuffer/Buffer/Uint8Array." + [image-id image-bytes] + (let [module wasm/internal-module + img-u8 (js/Uint8Array. image-bytes) + size (.-byteLength img-u8) + total (+ 36 size) + ptr (h/call module "_alloc_bytes" total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap)) + quart (uuid/get-u32 image-id)] + ;; shape uuid [0..16) = 0 (images are keyed by image uuid only) + (.setUint32 dview (+ ptr 0) 0 true) + (.setUint32 dview (+ ptr 4) 0 true) + (.setUint32 dview (+ ptr 8) 0 true) + (.setUint32 dview (+ ptr 12) 0 true) + ;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which + ;; the fill path uses, so it hashes to the same key the fill references) + (.setUint32 dview (+ ptr 16) (aget quart 0) true) + (.setUint32 dview (+ ptr 20) (aget quart 1) true) + (.setUint32 dview (+ ptr 24) (aget quart 2) true) + (.setUint32 dview (+ ptr 28) (aget quart 3) true) + ;; is_thumbnail [32..36) = 0 + (.setUint32 dview (+ ptr 32) 0 true) + ;; encoded bytes [36..) + (.set heap img-u8 (+ ptr 36)) + (h/call module "_store_image"))) + +(defn evict-images! + "Evicts least-recently-used images until the store retains at most `max-mb` + megabytes. Returns the number evicted." + [max-mb] + (h/call wasm/internal-module "_evict_images_to_budget" max-mb)) + +(defn provision-fonts! + "Resolves and uploads every font needed by `shape-ids`, each family fetched + once. `resolve-font` is an injected fn of the family map -> promise of TTF + bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom + assets / backend) out of the driver." + [shape-ids resolve-font] + (->> (fonts-for-shapes shape-ids) + (map (fn [family] + (->> (resolve-font family) + (p/fmap (fn [bytes] (when bytes (store-font! family bytes))))))) + (p/all))) + +;; --- RENDER + +(defn- read-render-result + "Copies the encoded payload out of a `_render_shape_*` result buffer and frees + it. `header-bytes` is the size of the header preceding the payload." + [offset header-bytes] + (let [heap32 (mem/get-heap-u32) + len (aget heap32 (mem/->offset-32 offset)) + bytes (read-result-bytes (+ offset header-bytes) len)] + (mem/free) + bytes)) + +(defn render-shape-raster + "Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU + surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on + the Rust side, since it has no alpha channel." + [shape-id scale format] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_raster" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale (sr/translate-raster-format format)) + (read-render-result RASTER-HEADER-BYTES)))) + +(defn render-shape-pdf + "Renders the shape subtree to PDF bytes (Uint8Array)." + [shape-id scale] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_pdf" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result PDF-HEADER-BYTES)))) diff --git a/exporter/src/app/wasm/enums.cljs b/exporter/src/app/wasm/enums.cljs new file mode 100644 index 0000000000..9c1a429522 --- /dev/null +++ b/exporter/src/app/wasm/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build export` and is + not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs new file mode 100644 index 0000000000..cbb08c27f3 --- /dev/null +++ b/exporter/src/app/wasm/serialize.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.serialize + "Browser-free shape serialization for the headless exporter: the counterpart + of `app.render-wasm.api/set-object`, which cannot be reused directly because + its namespace pulls React/DOM/store. Only the call sequencing lives here — + every byte layout comes from the shared serializers, so the bytes sent to + WASM are the editor's. + + Covers everything except svg-raw. Image bytes and fonts are provisioned + separately by `app.renderer.wasm`." + (:require + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.wasm :as wasm] + [app.wasm.text :as text])) + +(defn set-shape! + "Serializes a single shape into the WASM design state. The host-independent + properties (base props, children, blur, shadows, svg-attrs, mask, bool-type, + path geometry, grow-type) go through the shared `serialize-shape!` — the same + code the workspace's `set-object` uses, so the two can't drift. Only the + host-specific parts are handled here: fills/strokes (image bytes are provisioned + separately) and text content (fonts provisioned separately)." + [shape] + (let [type (get shape :type)] + (serialize-shape/serialize-shape! shape) + (props/write-shape-fills! (get shape :fills)) + (when-not (= type :group) + (props/write-shape-strokes! (get shape :strokes))) + (when (= type :text) + (text/set-shape-text! (get shape :content))))) + +(defn serialize-scene! + "Loads every shape of an `objects` map into the WASM design state. Resets the + shapes pool first so repeated exports don't accumulate into the shared + state. Order is irrelevant: shapes reference each other by id and the tree + is resolved at render time." + [objects] + (h/call wasm/internal-module "_init_shapes_pool" (count objects)) + (run! set-shape! (vals objects))) diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs new file mode 100644 index 0000000000..714542d7f1 --- /dev/null +++ b/exporter/src/app/wasm/text.cljs @@ -0,0 +1,35 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.text + "Browser-free text-content serialization for the headless exporter. Only the + paragraph walk is local: the binary layout and the font-id -> uuid mapping + both come from `app.common.render-wasm.text-content`." + (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.text-content :as tc] + [app.common.render-wasm.wasm :as wasm])) + +(defn set-shape-text! + "Serializes a text shape's content into the current WASM shape. Mirrors the + editor's sequence: clear -> vertical-align -> append each paragraph -> layout. + Byte writing and font resolution are the shared + `text-content/write-shape-text!` defaults; the exporter has no fonts DB, so + it injects no variant normalization." + [content] + (when content + (h/call wasm/internal-module "_clear_shape_text") + (h/call wasm/internal-module "_set_shape_vertical_align" + (sr/translate-vertical-align (get content :vertical-align))) + (let [paragraph-set (first (get content :children)) + paragraphs (get paragraph-set :children)] + (doseq [paragraph paragraphs] + (let [spans (get paragraph :children)] + (when (seq spans) + (let [text (apply str (map :text spans))] + (tc/write-shape-text! spans paragraph text {})))))) + (h/call wasm/internal-module "_update_shape_text_layout"))) diff --git a/frontend/deps.edn b/frontend/deps.edn index a662b7086d..f060448343 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -1,6 +1,7 @@ {:paths ["src" "vendor" "resources" "test"] :deps - {penpot/common + {;; Carries `app.common.render-wasm.*`, shared with the headless exporter. + penpot/common {:local/root "../common"} org.clojure/clojure {:mvn/version "1.12.2"} diff --git a/frontend/package.json b/frontend/package.json index e61395d4f9..2a257ef13d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,7 @@ "build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", - "build:wasm": "../render-wasm/build", + "build:wasm": "../render-wasm/build frontend", "build:app:libs": "node ./scripts/build-libs.js", "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", diff --git a/frontend/scripts/build b/frontend/scripts/build index 2cc812e228..fe40d223bc 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -30,7 +30,7 @@ mkdir -p target/dist; # Build render wasm binary pushd ../render-wasm; -./build +./build frontend popd pushd ../mcp; diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js index b1aa7c839c..6c609252b4 100644 --- a/frontend/scripts/build-fonts-preview.js +++ b/frontend/scripts/build-fonts-preview.js @@ -68,7 +68,7 @@ function slug(value) { } async function findGfontsJson() { - const dir = "resources/fonts"; + const dir = "../common/resources/fonts"; const entries = await fs.readdir(dir); const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort(); if (matches.length === 0) { diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cedfad1d96..8917f2e97f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -18,6 +18,7 @@ [app.common.geom.shapes :as gsh] [app.common.logging :as log] [app.common.path-names :as cpn] + [app.common.render-wasm.wasm :as wasm-state] [app.common.transit :as t] [app.common.types.component :as ctc] [app.common.types.components-list :as ctkl] @@ -77,7 +78,6 @@ [app.plugins.register :as preg] [app.render-wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm-state] [app.util.dom :as dom] [app.util.globals :as ug] [app.util.http :as http] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 677f8aa1fb..b41a9d6135 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -6,10 +6,10 @@ (ns app.main.fonts "Fonts management and loading logic." - (:require-macros [app.main.fonts :refer [preload-gfonts]]) (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.types.text :as txt] [app.common.uri :as u] @@ -25,27 +25,6 @@ (log/set-level! :warn) -(def google-fonts - (preload-gfonts "fonts/gfonts.2025.11.28.json")) - -(def local-fonts - [{:id "sourcesanspro" - :name "Source Sans Pro" - :family "sourcesanspro" - :variants - [{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"} - {:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"} - {:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"} - {:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"} - {:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"} - {:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"} - {:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"} - {:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"} - {:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"} - {:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"} - {:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"} - {:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}]) - (defonce fontsdb (l/atom {})) (defonce fonts (l/atom [])) @@ -65,10 +44,10 @@ fonts (map #(assoc % :backend backend) fonts)] (merge db (d/index-by :id fonts)))))) -(register! :builtin local-fonts) +(register! :builtin cfnt/local-fonts) (when (contains? cf/flags :google-fonts-provider) - (register! :google google-fonts)) + (register! :google cfnt/catalog)) (defn get-font-data [id] (get @fontsdb id)) @@ -266,8 +245,7 @@ (defn- process-gfont-css [css] - (let [base (u/join cf/public-uri "internal/gfonts/font")] - (str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) + (cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font"))) (defn- fetch-gfont-css [url] @@ -397,42 +375,12 @@ (defn find-closest-variant "Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match. - When exactly between two weights, choose the higher one." + When exactly between two weights, choose the higher one. + + The algorithm lives in `app.common.fonts` so the headless exporter resolves the + same variant for the same text." [font target-weight target-style] - (when-let [target-weight (d/parse-integer target-weight)] - (let [variants (:variants font []) - result - (reduce - (fn [closest-match variant] - (let [weight (d/parse-integer (:weight variant)) - distance (abs (- target-weight weight)) - matches-style? (= target-style (:style variant)) - current {:variant variant - :weight weight - :distance distance}] - (cond - ;; Exact match found - (and (zero? distance) - (if target-style matches-style? true)) - (reduced current) - - (nil? closest-match) current - - ;; Update best match if this variant is closer or equal distance but higher weight - (or (< distance (:distance closest-match)) - (and (= distance (:distance closest-match)) - (> weight (:weight closest-match)))) - current - - ;; Same weight as the `closest-match` but the style matches `target-style` - (and (= weight (:weight closest-match)) matches-style?) - current - - :else - closest-match))) - nil - variants)] - (:variant result)))) + (cfnt/closest-variant (:variants font []) target-weight target-style)) ;; Font embedding functions (defn get-node-fonts diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 45f7b83f73..1d4cd89506 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -9,8 +9,8 @@ (:require [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] [app.util.timers :as ts] [app.util.webapi :as webapi] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 46da6a7792..e7b0b03c0b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -13,8 +13,17 @@ [app.common.exceptions :as ex] [app.common.files.focus :as cpf] [app.common.files.helpers :as cfh] + [app.common.fonts :as cfnt] [app.common.logging :as log] [app.common.math :as mth] + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.mem.heap32 :as mem.h32] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] [app.common.types.path :as path] @@ -31,23 +40,17 @@ [app.main.router :as rt] [app.main.store :as st] [app.main.ui.shapes.text] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as f] - [app.render-wasm.api.props :as props] [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] [app.render-wasm.gesture :as wasm-gesture] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.mem.heap32 :as mem.h32] [app.render-wasm.performance :as perf] [app.render-wasm.rulers-state :as rulers-state] - [app.render-wasm.serialize-shape :as serialize-shape] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.text-editor :as text-editor] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.functions :as fns] @@ -1295,8 +1298,8 @@ langs) (let [text (apply str (map :text spans)) - emoji? (if emoji? emoji? (t/contains-emoji? text)) - langs (t/collect-used-languages langs text)] + emoji? (if emoji? emoji? (cfnt/contains-emoji? text)) + langs (cfnt/collect-used-languages langs text)] ;; FIXME: this should probably be somewhere else (when fallback-fonts-only? (t/write-shape-text spans paragraph text)) @@ -1307,8 +1310,8 @@ (let [updated-fonts (-> #{} - (cond-> ^boolean emoji? (f/add-emoji-font)) - (f/add-noto-fonts langs)) + (cond-> ^boolean emoji? (cfnt/add-emoji-font)) + (cfnt/add-noto-fonts langs)) fallback-fonts (filter #(get % :is-fallback) updated-fonts)] (if fallback-fonts-only? updated-fonts fallback-fonts)))))) diff --git a/frontend/src/app/render_wasm/api/enums.cljs b/frontend/src/app/render_wasm/api/enums.cljs new file mode 100644 index 0000000000..de132c4638 --- /dev/null +++ b/frontend/src/app/render_wasm/api/enums.cljs @@ -0,0 +1,19 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.render-wasm.api.enums + "Binds this build's generated enums into the shared bridge. + + `shared.js` is emitted next to this file by `render-wasm/build frontend` and + is not committed. Requiring this namespace is what makes + `app.common.render-wasm.wasm/serializers` usable." + (:require + ["./shared.js" :as shared] + [app.common.render-wasm.wasm :as wasm]) + (:require-macros + [app.common.render-wasm.enums :as enums])) + +(wasm/init-serializers! (enums/serializers shared)) diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c3d5a32a35..f6a9c83b6e 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -8,15 +8,15 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.fonts :as cfnt] [app.common.logging :as log] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.wasm :as wasm] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] [app.main.fonts :as fonts] [app.main.store :as st] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.helpers :as h] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.util.timers :as tm] [beicon.v2.core :as rx] @@ -39,33 +39,6 @@ (def ^:private default-line-height 1.2) (def ^:private default-letter-spacing 0.0) -(defn- google-font-id->uuid - "Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the - font is not found in fontsdb. uuid/zero maps to the default font (Source - Sans Pro) in WASM. - A font id may not exist for different reasons: - - the gfonts.json catalog was updated and fonts were renamed or removed, - - the file was imported from another Penpot instance with different fonts, - ..." - [font-id] - (let [font (fonts/get-font-data font-id) - result (:uuid font)] - (or result uuid/zero))) - -(defn- custom-font-id->uuid - [font-id] - (uuid/uuid (subs font-id (inc (str/index-of font-id "-"))))) - -(defn- font-backend - [font-id] - (cond - (str/starts-with? font-id "gfont-") - :google - (str/starts-with? font-id "custom-") - :custom - :else - :builtin)) - (defn- font-db-data [font-id font-variant-id font-weight-fallback font-style-fallback] (let [font (fonts/get-font-data font-id) @@ -75,15 +48,6 @@ variant closest-variant))) -(defn- font-id->uuid [font-id] - (case (font-backend font-id) - :google - (google-font-id->uuid font-id) - :custom - (custom-font-id->uuid font-id) - :builtin - uuid/zero)) - (defn uuid->font-id [font-uuid] (if (= font-uuid uuid/zero) @@ -100,11 +64,11 @@ "regular"))) (defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google font-id :custom - (let [font-uuid (custom-font-id->uuid font-id) + (let [font-uuid (cfnt/font-id->uuid font-id) matching-font (some (fn [[_ font]] (and (= (:font-id font) font-uuid) (= (str (:font-weight font)) (str font-weight)) @@ -194,13 +158,12 @@ (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] (let [variant (font-db-data font-id font-variant-id font-weight font-style)] - (if-let [ttf-url (:ttf-url variant)] - (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/")) - nil))) + (when-let [ttf-url (:ttf-url variant)] + (cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font"))))) (defn- font-id->ttf-url [font-id asset-id font-variant-id font-weight font-style] - (case (font-backend font-id) + (case (cfnt/font-id->backend font-id) :google (google-font-ttf-url font-id font-variant-id font-weight font-style) :custom @@ -245,18 +208,6 @@ "italic" 1 0)) -(defn normalize-font-id - [font-id] - (try - (if ^boolean (str/starts-with? font-id "gfont-") - (google-font-id->uuid font-id) - (let [no-prefix (subs font-id (inc (str/index-of font-id "-")))] - (if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix)) - uuid/zero - (uuid/parse no-prefix)))) - (catch :default _e - uuid/zero))) - (defn normalize-span-font [span paragraph] (let [font-id (:font-id span) @@ -358,7 +309,7 @@ emoji? (get font :is-emoji false) fallback? (get font :is-fallback false) font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback) - wasm-id (font-id->uuid font-id) + wasm-id (cfnt/font-id->uuid font-id) raw-weight (or (:weight font-data) font-weight-fallback) weight (serialize-font-weight raw-weight) style (cond @@ -415,7 +366,3 @@ (defn store-fonts [fonts] (keep (fn [font] (store-font font)) fonts)) - -(def add-emoji-font fbf/add-emoji-font) -(def noto-fonts fbf/noto-fonts) -(def add-noto-fonts fbf/add-noto-fonts) diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 3ea55dfdb3..5a6565bc1e 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -6,21 +6,13 @@ (ns app.render-wasm.api.texts (:require - [app.render-wasm.api.fonts :as f] - [app.render-wasm.fallback-fonts :as fbf] - [app.render-wasm.text-content :as tc])) + [app.common.render-wasm.text-content :as tc] + [app.render-wasm.api.fonts :as f])) (defn write-shape-text "Workspace text serialization: the byte writing is shared via - `app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." + `app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." [spans paragraph text] (tc/write-shape-text! spans paragraph text - {:normalize-font-id f/normalize-font-id - :normalize-paragraph f/normalize-paragraph-font + {:normalize-paragraph f/normalize-paragraph-font :normalize-span f/normalize-span-font})) - -;; Emoji/script detection lives in the host-agnostic -;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing -;; workspace callers. -(def contains-emoji? fbf/contains-emoji?) -(def collect-used-languages fbf/collect-used-languages) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index fc7a3fe37f..268dfe4462 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -8,7 +8,7 @@ "WebGL utilities for pixel capture and rendering" (:require [app.common.logging :as log] - [app.render-wasm.wasm :as wasm] + [app.common.render-wasm.wasm :as wasm] [promesa.core :as p])) (defn get-webgl-context diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 94108ea456..3c26916b2f 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -7,16 +7,18 @@ (ns app.render-wasm.text-editor "Text editor WASM bindings" (:require + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.fonts :as main-fonts] + ;; Required for side effects: binds the generated enums. + [app.render-wasm.api.enums] [app.render-wasm.api.fonts :as fonts] - [app.render-wasm.helpers :as h] - [app.render-wasm.mem :as mem] - [app.render-wasm.serializers :as sr] - [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.wasm :as wasm] [app.util.color :as uc] [app.util.dom :as dom])) diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 21d4e37190..0dd8916cf7 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -11,6 +11,7 @@ [app.common.geom.rect :as grc] [app.common.geom.shapes.bounds :as gsb] [app.common.logging :as log] + [app.common.render-wasm.wasm :as wasm] [app.common.types.color :as cc] [app.common.uri :as u] [app.common.uuid :as uuid] @@ -18,7 +19,6 @@ [app.main.fonts :as fonts] [app.main.render :as render] [app.render-wasm.api :as wasm.api] - [app.render-wasm.wasm :as wasm] [app.util.http :as http] [app.worker.impl :as impl] [beicon.v2.core :as rx] diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index fb5cd5b5fe..f2ec88f412 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -15,6 +15,9 @@ [app.common.json :as json] [app.common.logging :as l] [app.common.pprint :as pp] + [app.common.render-wasm.helpers :as wasm.h] + [app.common.render-wasm.mem :as wasm.mem] + [app.common.render-wasm.wasm :as wasm] [app.common.transit :as t] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] @@ -36,9 +39,6 @@ [app.main.errors :as errors] [app.main.repo :as rp] [app.main.store :as st] - [app.render-wasm.helpers :as wasm.h] - [app.render-wasm.mem :as wasm.mem] - [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index c985b336ae..cf9e68670a 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -15,9 +15,9 @@ font URL get no callback (fetch-font returns nil when the URL is already in :fetching) and are permanently stuck with fallback-font layout metrics." (:require + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.wasm :as wasm] [app.render-wasm.api :as wasm.api] - [app.render-wasm.mem :as mem] - [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true])) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs index 2594e84995..09203f4bae 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs @@ -11,7 +11,7 @@ anything else (no fill, gradient, image fills, mixed selection) it falls back to an inverted caret (white painted with a Difference blend)." (:require - [app.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.serializers.color :as sr-clr] [app.render-wasm.text-editor :as text-editor] [cljs.test :as t :include-macros true])) diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..b17e3aa69f 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -42,3 +42,7 @@ opt-level = 3 lto = "fat" strip = true codegen-units = 1 + +[profile.size] +inherits = "release" +opt-level = "z" diff --git a/render-wasm/README.md b/render-wasm/README.md index b1010bb70c..4ed4830bcd 100644 --- a/render-wasm/README.md +++ b/render-wasm/README.md @@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change. The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app. +### Render targets + +The same Rust source produces two artifacts, which differ only in compiler +options: + +| Target | Tuned for | Cargo profile | Consumed by | +| ---------- | --------- | ----------------- | ------------------------------ | +| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` | +| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` | + +```sh +./build # both targets, frontend first +./build frontend # workspace / viewer renderer +./build export # headless exporter renderer +``` + +`./watch` still follows a single target (`frontend` unless you pass one), +since watching both would rebuild twice on every keystroke. + +Each target keeps its own `CARGO_TARGET_DIR` (`target/`), so switching +between them does not invalidate the other's cache. Set `BUILD_MODE=release` +(or `NODE_ENV=production`) for an optimized build; the default is `debug`. + +Each target writes its own generated `shared.js` (the enum discriminants the +CLJS side compiles against) next to the code that imports it — respectively +`frontend/src/app/render_wasm/api/shared.js` and +`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths. + ![Architecture overview](docs/images/architecture_schema.png) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 0b506e415d..e034580dfd 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -1,15 +1,25 @@ #!/usr/bin/env bash export VERSION_TAG=${VERSION:-develop}; +export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}"; + +case "$RENDER_TARGET" in + frontend|export) ;; + *) + echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2; + exit 1; + ;; +esac if [ "$NODE_ENV" = "production" ]; then export BUILD_MODE="release"; else - export BUILD_MODE=${1:-debug}; + export BUILD_MODE=${BUILD_MODE:-debug}; fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; +export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} # 256 MB of initial heap to perform less @@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache"; export CARGO_PARAMS="${@:2}"; +export CARGO_PROFILE_DIR="debug"; + if [ "$BUILD_MODE" = "release" ]; then - export CARGO_PARAMS="--release $CARGO_PARAMS" - export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS" + case "$RENDER_TARGET" in + frontend) + export CARGO_PARAMS="--release $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="release"; + export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + export) + export CARGO_PARAMS="--profile size $CARGO_PARAMS"; + export CARGO_PROFILE_DIR="size"; + export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS"; + ;; + esac else # TODO: Extra parameters that could be good to look into: # -gseparate-dwarf @@ -62,6 +84,12 @@ else export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" fi +export FRONTEND_DEST="../frontend/resources/public/js"; +export EXPORT_DEST="../exporter/resources/wasm"; + +export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js"; +export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js"; + function clean { cargo clean; } @@ -78,26 +106,48 @@ function build { function copy_artifacts { DEST=$1; + SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR"; mkdir -p $DEST; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; - if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then - cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; + cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js; + cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm; + if [ -f $SRC/render_wasm.wasm.map ]; then + cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; fi sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; - pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \ - --log-level=error \ - --outfile=$DEST/worker/render.js \ - --platform=neutral \ - --format=iife \ - --global-name=WasmModule; + # The worker bundle is a browser concern; the exporter imports the ESM + # module directly under Node. + if [ "$RENDER_TARGET" = "frontend" ]; then + pnpm exec esbuild $SRC/render_wasm.js \ + --log-level=error \ + --outfile=$DEST/worker/render.js \ + --platform=neutral \ + --format=iife \ + --global-name=WasmModule; + fi } function copy_shared_artifact { - SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1); - cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + DEST=$1; + SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1); + + cp $SHARED_FILE $DEST; +} + +# Copies whatever the current RENDER_TARGET produced to where that target's +# consumer reads it. +function copy_target_artifacts { + case "$RENDER_TARGET" in + frontend) + copy_artifacts "$FRONTEND_DEST"; + copy_shared_artifact "$FRONTEND_SHARED_DEST"; + ;; + export) + copy_artifacts "$EXPORT_DEST"; + copy_shared_artifact "$EXPORT_SHARED_DEST"; + ;; + esac } diff --git a/render-wasm/build b/render-wasm/build index 2505ae0cf9..2a832fc3f4 100755 --- a/render-wasm/build +++ b/render-wasm/build @@ -1,8 +1,26 @@ #!/usr/bin/env bash +# Usage: ./build [frontend|export] [extra cargo params...] +# +# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production) +# for an optimized build. See `_build_env` for what each target changes. + +_SCRIPT_DIR=$(dirname $0); + +# Each target needs its own `_build_env`, so re-enter per target. +case "${1:-}" in + frontend|export) + ;; + *) + for _target in frontend export; do + "$_SCRIPT_DIR/build" "$_target" "$@" || exit $?; + done + exit 0; + ;; +esac + EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh -_SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; . ./_build_env @@ -11,8 +29,7 @@ set -ex; setup; build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; exit $?; diff --git a/render-wasm/lint b/render-wasm/lint index e94145189a..4ed7d6826d 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then set -x fi -. ./_build_env +. ./_build_env frontend export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index bf069d2b1c..e027aebd21 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -329,15 +329,21 @@ fn render_text_on_canvas( layer_opacity: Option, overlay_emoji: bool, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - let blur_layer = SaveLayerRec::default().paint(&blur_paint); + let blur_layer = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint); canvas.save_layer(&blur_layer); } if let Some(shadow_paint) = shadow { - let layer_rec = SaveLayerRec::default().paint(shadow_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -351,7 +357,9 @@ fn render_text_on_canvas( if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { let mut layer_paint = Paint::default(); layer_paint.set_image_filter(erode); - let layer_rec = SaveLayerRec::default().paint(&layer_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&layer_paint); canvas.save_layer(&layer_rec); draw_text( canvas, @@ -582,7 +590,10 @@ fn draw_decoration_stroke( skia::BlendMode::SrcOut }; - canvas.save_layer(&SaveLayerRec::default()); + let outset = stroke_paint.stroke_width().max(0.0); + let layer_bounds = bar.with_outset((outset, outset)); + + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); let mut mask_paint = Paint::default(); mask_paint.set_color(skia::Color::BLACK); mask_paint.set_anti_alias(true); @@ -590,7 +601,11 @@ fn draw_decoration_stroke( let mut blend_paint = Paint::default(); blend_paint.set_blend_mode(blend); - canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blend_paint), + ); canvas.draw_rect(bar, stroke_paint); canvas.restore(); canvas.restore(); @@ -705,7 +720,12 @@ pub fn render_emoji_overlay( if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } for (emoji_para, deco_para) in emoji_layout @@ -728,13 +748,17 @@ fn draw_text( layer_opacity: Option, overlay_emoji: bool, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + let layer_rec = SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint); canvas.save_layer(&layer_rec); } else { - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); } paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); @@ -759,27 +783,41 @@ fn render_masked_stroke_on_canvas( blur: Option<&ImageFilter>, layer_opacity: Option, ) { + let layer_bounds = shape.layer_bounds(); + if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); - canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&blur_paint), + ); } if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); - canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&opacity_paint), + ); } - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); - canvas.save_layer(&SaveLayerRec::default()); + canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); paint_text(canvas, shape, mask_builders); let mut stroke_paint = Paint::default(); stroke_paint.set_blend_mode(stroke_mask_blend); - canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&stroke_paint), + ); paint_text(canvas, shape, stroke_builders); @@ -789,7 +827,11 @@ fn render_masked_stroke_on_canvas( if let Some(fill_builders) = fill_builders { let mut dst_over_paint = Paint::default(); dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); - canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); + canvas.save_layer( + &SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&dst_over_paint), + ); paint_text(canvas, shape, fill_builders); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 23b797023e..a536955c6d 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -79,11 +79,14 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { + let layer_bounds = shape.layer_bounds(); for shadow in shape.drop_shadows_visible() { if let Some(filter) = shadow.get_drop_shadow_filter() { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); @@ -99,10 +102,14 @@ impl ShapeRenderer for VectorRenderer<'_> { if !shape.has_fills() { return Ok(()); } + let layer_bounds = shape.layer_bounds(); for shadow in shape.inner_shadows_visible() { let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint), + ); let mut fill_paint = Paint::default(); fill_paint.set_anti_alias(true); fill_paint.set_color(skia::Color::BLACK); @@ -161,9 +168,13 @@ impl ShapeRenderer for VectorRenderer<'_> { }) .collect(); + let layer_bounds = shape.layer_bounds(); for shadow_paint in &drop_shadows { - self.canvas - .save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); + self.canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(shadow_paint), + ); text::render_overlay_emoji( self.canvas, @@ -331,7 +342,10 @@ impl ShapeRenderer for VectorRenderer<'_> { if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { let mut paint = Paint::default(); paint.set_image_filter(filter); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = shape.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); self.canvas.save_layer(&layer_rec); true } else { @@ -715,7 +729,10 @@ fn render_group( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -726,7 +743,12 @@ fn render_group( // as content, then re-draw the mask silhouette (the group's first child) // with DstIn to clip everything to it. let paint = Paint::default(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + let subtree_bounds = element.extrect(tree, scale); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; @@ -735,7 +757,11 @@ fn render_group( if let Some(mask_id) = element.mask_id() { let mut mask_paint = Paint::default(); mask_paint.set_blend_mode(skia::BlendMode::DstIn); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&mask_paint), + ); render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?; canvas.restore(); // mask layer } @@ -797,7 +823,10 @@ fn render_frame( } } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.extrect(tree, scale); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -857,13 +886,18 @@ fn render_container_drop_shadows( draw_fills: bool, opts: &TreeOpts, ) -> Result<()> { + let subtree_bounds = element.extrect(tree, scale); for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_filter() else { continue; }; let mut paint = Paint::default(); paint.set_image_filter(filter); - canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&subtree_bounds) + .paint(&paint), + ); if draw_fills && !element.fills.is_empty() { let mut renderer = VectorRenderer::new(canvas, shared, scale); @@ -902,7 +936,10 @@ fn render_leaf( let mut paint = Paint::default(); paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + let layer_bounds = element.layer_bounds(); + let layer_rec = skia::canvas::SaveLayerRec::default() + .bounds(&layer_bounds) + .paint(&paint); canvas.save_layer(&layer_rec); } @@ -1101,7 +1138,8 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint } StrokeKind::Outer => { canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); draw_shape_geometry(canvas, shape, paint); let mut clear_paint = Paint::default(); clear_paint.set_blend_mode(skia::BlendMode::Clear); @@ -1134,7 +1172,8 @@ fn draw_image_stroke( let container = shape.selrect; canvas.save(); - canvas.save_layer(&skia::canvas::SaveLayerRec::default()); + let layer_bounds = shape.layer_bounds(); + canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds)); // Opaque stroke silhouette; the SrcIn image draw below fills it. draw_stroke_geometry(canvas, scale, shape, stroke, true); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 47269c5288..dbdeb74be2 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1070,7 +1070,7 @@ impl Shape { extrect } - fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + fn own_extrect_bounds(&self) -> Bounds { let shape = self; let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); @@ -1096,6 +1096,19 @@ impl Shape { bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_blur_bounds(bounds); + bounds + } + + /// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in + /// untransformed space (callers concatenate [`Self::centered_transform`] + /// first). Includes shadow/blur margins, so it is also a valid input bound + /// for a layer whose paint carries an image filter. + pub fn layer_bounds(&self) -> math::Rect { + self.own_extrect_bounds().to_rect() + } + + fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + let mut bounds = self.own_extrect_bounds(); bounds = self.apply_children_bounds(bounds, shapes_pool, scale); bounds = self.apply_children_blur(bounds, shapes_pool); diff --git a/render-wasm/test b/render-wasm/test index f416e6c6bb..8019b1fecd 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -7,7 +7,7 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; -. ./_build_env +. ./_build_env frontend cargo test --bin render_wasm -- --show-output diff --git a/render-wasm/watch b/render-wasm/watch index 90c08c9ffd..1825bf7667 100755 --- a/render-wasm/watch +++ b/render-wasm/watch @@ -1,5 +1,7 @@ #!/usr/bin/env bash +# Usage: ./watch [frontend|export] + _SCRIPT_DIR=$(dirname $0); pushd $_SCRIPT_DIR; @@ -7,8 +9,7 @@ pushd $_SCRIPT_DIR; set -x build; -copy_artifacts "../frontend/resources/public/js"; -copy_shared_artifact; +copy_target_artifacts; pushd $_SCRIPT_DIR; @@ -16,7 +17,7 @@ cargo watch \ --why \ -i "_tmp*" \ -x "build $CARGO_PARAMS" \ - -s "./build" \ + -s "./build $RENDER_TARGET" \ -s "echo 'DONE\n'"; popd From 688c69b478ec85839fa11ea0ccecb37f2ddfb180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Thu, 6 Aug 2026 17:05:41 +0200 Subject: [PATCH 052/298] :bug: Fix import-binfile schema test (#11118) --- backend/test/backend_tests/rpc_binfile_test.clj | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index 5ebf83bf18..536a980339 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -17,26 +17,18 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) -(t/deftest import-binfile-schema-rejects-file-id +(t/deftest import-binfile-schema-omits-file-id ;; N1-06: file-id parameter must be removed from schema for security - ;; The schema should not accept file-id as a valid parameter (let [schema @#'binfile/schema:import-binfile validator (sm/lazy-validator schema) - ;; Valid params without file-id valid-params {:name "test" :project-id (uuid/random) :version 3 - :upload-id (uuid/random)} + :upload-id (uuid/random)}] - ;; Params with file-id (should be rejected after fix) - params-with-file-id (assoc valid-params :file-id (uuid/random))] - - ;; Valid params without file-id should pass (t/is (true? (validator valid-params)) "params without file-id should be valid") - ;; Params with file-id should fail validation after fix - ;; (Currently this will fail because file-id is still in schema) - (t/is (false? (validator params-with-file-id)) - "params with file-id should be rejected"))) + (t/is (not (contains? (sm/keys (second schema)) :file-id)) + "file-id should not be a declared parameter"))) From 0702363b5c97b914bb37d642e764b4c0e478970b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 18:32:00 +0200 Subject: [PATCH 053/298] :bug: Validate font-id team ownership in create-font-variant (#11014) Prevent cross-team font injection by checking that when a font-id already has variants, they belong to the same team. This closes a BOLA gap where a user with team edit permissions could create a font variant referencing a font-id from another team. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/rpc/commands/fonts.clj | 15 +++++++- backend/test/backend_tests/rpc_font_test.clj | 40 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 0ca38ae7fd..6f9f9bb109 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -93,6 +93,18 @@ (declare create-font-variant) +(defn- check-font-team-ownership! + "When font-id already has variants belonging to a different team, + raises :not-found to prevent cross-team font injection." + [conn team-id font-id] + (let [row (db/get* conn :team-font-variant + {:font-id font-id} + {::db/columns [:team-id]})] + (when (and row (not= (:team-id row) team-id)) + (ex/raise :type :not-found + :code :object-not-found + :hint "font does not belong to this team")))) + (def ^:private schema:create-font-variant [:map {:title "create-font-variant"} [:team-id ::sm/uuid] @@ -132,8 +144,9 @@ [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id uploads] :as params}] (teams/check-edition-permissions! pool profile-id team-id) + (check-font-team-ownership! pool team-id font-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 4f4b5378f7..fc771ebbf1 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -608,3 +608,43 @@ :name "Valid Font Name"} out (th/command! params)] (t/is (th/success? out)))))) + +(t/deftest create-font-variant-rejects-foreign-font-id + ;; N2-07: A user with edit permissions on their own team must not be + ;; able to create a font variant using a font-id that already belongs + ;; to another team (BOLA / CWE-639). + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof1 (th/create-profile* 1 {:is-active true}) + prof2 (th/create-profile* 2 {:is-active true}) + team1 (:default-team-id prof1) + team2 (:default-team-id prof2) + font-id (uuid/custom 10 999) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*))] + + ;; prof1 creates a font variant in team1 with font-id + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof1) + :team-id team1 + :font-id font-id + :font-family "SharedFont" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (nil? (:error out)))) + + ;; prof2 tries to create a variant using the same font-id but + ;; in team2 — must be rejected because font-id belongs to team1 + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id team2 + :font-id font-id + :font-family "SharedFont" + :font-weight 700 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code))))))) From 1548748aed8a9afe2b0be3ac765314dde4dd55d1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 16:36:33 +0000 Subject: [PATCH 054/298] :recycle: Reuse organization schema in create-organization-invitation Replace the inline organization map in schema:create-organization-invitation with cto/schema:organization-with-avatar, eliminating schema duplication and fixing mismatched validation rules for :logo and :sso-active fields. AI-assisted-by: mimo-v2.5 --- backend/src/app/rpc/commands/teams_invitations.clj | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 7cd8933354..0ba235a444 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -108,14 +108,7 @@ (def ^:private schema:create-organization-invitation [:map {:title "params:create-organization-invitation"} [::rpc/profile-id ::sm/uuid] - [:organization - [:map - [:id ::sm/uuid] - [:name :string] - [:initials [:maybe :string]] - [:logo ::sm/uri] - [:avatar-bg-url [:maybe ::sm/uri]] - [:sso-active [:maybe ::sm/boolean]]]] + [:organization cto/schema:organization-with-avatar] [:profile [:map [:id ::sm/uuid] From 9875db2f8236ca48dadf025350358c0d9374b056 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 6 Aug 2026 16:50:10 +0000 Subject: [PATCH 055/298] :bug: Fix media-remote-test falling back to real config in REPL The info-service-uri-not-configured test used config-get-mock with an empty map, which falls back to cf/config for missing keys. In a REPL with real config, media-processing-service-uri is set, causing the code to attempt an HTTP call instead of raising the expected error. Use (constantly nil) to ensure cf/get always returns nil, matching the test intent of simulating an unconfigured service URI. AI-assisted-by: mimo-v2.5 --- backend/test/backend_tests/media_remote_test.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj index dbaf8cb889..dfa8b16d05 100644 --- a/backend/test/backend_tests/media_remote_test.clj +++ b/backend/test/backend_tests/media_remote_test.clj @@ -99,7 +99,7 @@ (t/deftest info-service-uri-not-configured (t/testing "info throws when service URI is not configured" - (with-redefs [cf/get (th/config-get-mock {})] + (with-redefs [cf/get (constantly nil)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}))] From 5359ff04cf568eb6a7de2535e66372c3a7109729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Fri, 7 Aug 2026 08:02:58 +0200 Subject: [PATCH 056/298] :paperclip: Drop an unused binding in create-font-variant `create-font-variant` destructures `uploads` and never reads it: the handler passes the whole `params` map to `prepare-font-data-from-uploads`. `clj-kondo` reports it as an unused binding and exits 2, which fails the Lint step of the Backend workflow, and the Lint step runs before the tests, so no branch based on `develop` can run the backend suite at all. AI-assisted-by: mixed models --- backend/src/app/rpc/commands/fonts.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 6f9f9bb109..c0ca2d8da7 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -144,7 +144,7 @@ [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id uploads] :as params}] + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id] :as params}] (teams/check-edition-permissions! pool profile-id team-id) (check-font-team-ownership! pool team-id font-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team From bc9319eac59e8b7bd3d38e000b1bc29fef52c629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Fri, 7 Aug 2026 08:02:58 +0200 Subject: [PATCH 057/298] :bug: Port the foreign-font-id test to the uploads API `create-font-variant-rejects-foreign-font-id` sends `:data`, which `schema:create-font-variant` no longer accepts: the same commit that added the test documents that param as removed in 2.18 in favour of `:uploads`. Both of the test's requests are therefore rejected by params validation before they reach `check-font-team-ownership!`, which is the thing the test exists to check. It asserted nothing about ownership and failed three assertions. Upload the font through `upload-font-chunked!`, the helper the other tests in this namespace already use, and pass the session id in `:uploads`. `backend-tests.rpc-font-test` is 16 tests, 172 assertions, 0 failures with this applied. AI-assisted-by: mixed models --- backend/test/backend_tests/rpc_font_test.clj | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index fc771ebbf1..d7b326e5ed 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -623,27 +623,29 @@ (io/read*))] ;; prof1 creates a font variant in team1 with font-id - (let [params {::th/type :create-font-variant + (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof1) :team-id team1 :font-id font-id :font-family "SharedFont" :font-weight 400 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (nil? (:error out)))) ;; prof2 tries to create a variant using the same font-id but - ;; in team2 — must be rejected because font-id belongs to team1 - (let [params {::th/type :create-font-variant + ;; in team2, which must be rejected because font-id belongs to team1 + (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant ::rpc/profile-id (:id prof2) :team-id team2 :font-id font-id :font-family "SharedFont" :font-weight 700 :font-style "normal" - :data {"font/ttf" data}} + :uploads {"font/ttf" session-id}} out (th/command! params)] (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))) From 43b12bc4b91c7e3ed9c3ee88b5bc92f5cdffa8c2 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 7 Aug 2026 09:45:39 +0200 Subject: [PATCH 058/298] :zap: Soft-drain GPU mid-walk on progressive Partials (#11127) Release packs far more cheap Current draws (e.g. fills_none paths) into one Partial than debug; a single end-of-Partial flush_and_submit then stalls the browser. Soft-flush every N walker nodes (and on Partial yield) keeps ops buffers bounded while Full still submits via present_frame. --- render-wasm/src/render.rs | 28 ++++++++++++++++++++-------- render-wasm/src/render/options.rs | 8 ++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5403eb98b6..8aa067dae2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1386,8 +1386,8 @@ impl RenderState { // the layered path without Fills/Strokes blits. // Non-SrcOver blend, frame clip blur, and masked groups stay layered. // Stroke-only (fills_none) can go direct: empty fills are a no-op and - // strokes paint into Current. Requires Partial GPU drain (dc1ab) so - // large SVG-icon files do not backlog commands until Full present. + // strokes paint into Current. Large files need mid-walk GPU drains so + // release builds do not backlog a huge ops buffer in one Partial. let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -2461,12 +2461,9 @@ impl RenderState { panic!("FrameType::None"); } FrameType::Partial => { - // Drain tile GPU work (Current / tile atlas / cache) without - // presenting Target and without re-snapshotting the tile atlas — - // composition stays deferred to Full. A Backbuffer flush alone - // left commands queued until present_frame's flush_and_submit, - // which stalled the browser on large files. - crate::get_gpu_state().context.flush_and_submit(); + // Final soft drain for this yield (mid-walk also drains; see + // `drain_partial_gpu_soft`). Full still submits via present_frame. + Self::drain_partial_gpu_soft(); } FrameType::Full => { // A full-quality frame is now complete. Rebuild the per-shape crop @@ -2669,6 +2666,15 @@ impl RenderState { true } + /// Soft-drain GPU command buffers during progressive tile walks. + /// Release packs far more cheap Current draws (e.g. fills_none paths) into + /// one Partial than debug; flushing only at Partial end then stalls. Call + /// periodically so each flush stays small. Full present still submits. + #[inline] + fn drain_partial_gpu_soft() { + crate::get_gpu_state().context.flush(None); + } + /// Skip all drop/inner shadows in fast mode, or when even a large design-space /// shadow would be subpixel. Otherwise filter per shadow via /// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes). @@ -3775,6 +3781,12 @@ impl RenderState { if allow_stop && self.should_stop_rendering(iteration, timestamp) { return Ok((is_empty, true)); } + // Keep GPU ops buffers bounded when many shapes paint cheaply to + // Current (release packs far more per Partial than debug). + let drain_every = self.options.partial_gpu_drain_every_n; + if allow_stop && drain_every > 0 && iteration > 0 && iteration % drain_every == 0 { + Self::drain_partial_gpu_soft(); + } iteration += 1; } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index fed66505fe..5dfe0ac2fb 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -11,6 +11,10 @@ const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; const MAX_BLOCKING_TIME_MS: i32 = 32; const NODE_BATCH_THRESHOLD: i32 = 3; +/// Soft-drain GPU every N walker nodes on progressive Partials. Keeps ops +/// buffers bounded when many shapes paint cheaply to Current (release packs +/// far more per budget than debug). +const PARTIAL_GPU_DRAIN_EVERY_N: i32 = 64; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] @@ -29,6 +33,9 @@ pub struct RenderOptions { pub dpr_viewport_interest_area_threshold: i32, pub max_blocking_time_ms: i32, pub node_batch_threshold: i32, + /// Soft-flush GPU every N nodes during progressive tile walks (see + /// [`PARTIAL_GPU_DRAIN_EVERY_N`]). + pub partial_gpu_drain_every_n: i32, pub blur_downscale_threshold: f32, pub capture_frames: i32, } @@ -45,6 +52,7 @@ impl Default for RenderOptions { dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, max_blocking_time_ms: MAX_BLOCKING_TIME_MS, node_batch_threshold: NODE_BATCH_THRESHOLD, + partial_gpu_drain_every_n: PARTIAL_GPU_DRAIN_EVERY_N, blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, capture_frames: 0, } From 399b00b86d06013250a2280f2d532da3371d93b6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:24:24 +0200 Subject: [PATCH 059/298] :bug: Add permission checks to WebSocket subscription handlers (#11054) * :bug: Add permission checks to WebSocket subscription handlers Check file and team read permissions before allowing WebSocket subscriptions to prevent resource enumeration via presence notifications. AI-assisted-by: mimo-v2.5-pro * :bug: Fix random backend test failure --- backend/src/app/http/websocket.clj | 10 ++++++-- backend/test/backend_tests/rpc_file_test.clj | 25 ++++++++++++++++--- backend/test/backend_tests/rpc_font_test.clj | 21 ++++++++++++++++ .../test/backend_tests/rpc_project_test.clj | 21 ++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj index 4dd74dfc11..2517b42725 100644 --- a/backend/src/app/http/websocket.clj +++ b/backend/src/app/http/websocket.clj @@ -7,6 +7,7 @@ (ns app.http.websocket "A penpot notification service for file cooperative edition." (:require + [app.binfile.common :as bfc] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.pprint :as pp] @@ -17,6 +18,8 @@ [app.http.session :as session] [app.metrics :as mtx] [app.msgbus :as mbus] + [app.rpc.commands.files :as files] + [app.rpc.commands.teams :as teams] [app.util.websocket :as ws] [integrant.core :as ig] [promesa.exec.csp :as sp] @@ -131,8 +134,9 @@ (mbus/pub! msgbus :topic topic :message msg)))) (defmethod handle-message :subscribe-team - [{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}] + [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}] (l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id) + (teams/check-read-permissions! pool profile-id team-id) (let [prev-subs (get @state ::team-subscription) channel (sp/chan :buf (sp/dropping-buffer 64) :xf (remove #(= (:session-id %) session-id)))] @@ -150,8 +154,10 @@ (defmethod handle-message :subscribe-file - [{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}] + [{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}] (l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id) + (bfc/check-file-exists pool file-id) + (files/check-read-permissions! pool profile-id file-id) (let [psub (::file-subscription @state) fch (sp/chan :buf (sp/dropping-buffer 64) :xf (remove #(= (:session-id %) session-id)))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index cb0997576d..d1ec0eb233 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -733,7 +733,7 @@ (t/is (= 2 (count rows))) (t/is (= 1 (count (remove (comp some? :deleted-at) rows)))) (t/is (= (thc/fmt-object-id file-id page-id frame-id-1 "frame") - (-> rows first :object-id)))) + (->> rows (remove (comp some? :deleted-at)) first :object-id)))) ;; Now that file-gc have marked for deletion the object ;; thumbnail lets execute the objects-gc task which remove @@ -2377,8 +2377,6 @@ (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) -;; --- Security Fix Tests --- - (t/deftest link-file-to-library-circular-reference (let [profile (th/create-profile* 1) file1 (th/create-file* 1 {:profile-id (:id profile) @@ -2448,3 +2446,24 @@ (t/is (th/ex-info? (:error out))) (let [edata (-> out :error ex-data)] (t/is (= :validation (:type edata)))))) + +(t/deftest get-file-libraries-nonexistent-file + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-file-libraries + ::rpc/profile-id (:id prof) + :file-id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-file-libraries-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner)}) + out (th/command! {::th/type :get-file-libraries + ::rpc/profile-id (:id other) + :file-id (:id file)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index d7b326e5ed..0f86a64cb2 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -650,3 +650,24 @@ (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))) (t/is (= :object-not-found (-> out :error ex-data :code))))))) + +(t/deftest get-font-variants-nonexistent-file + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-font-variants + ::rpc/profile-id (:id prof) + :file-id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-font-variants-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner)}) + out (th/command! {::th/type :get-font-variants + ::rpc/profile-id (:id other) + :file-id (:id file)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index 96376a42b1..3f80f06f6b 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -241,3 +241,24 @@ error-data (ex-data error)] (t/is (th/ex-info? error)) (t/is (= (:type error-data) :not-found)))))) + +(t/deftest get-project-nonexistent + (let [prof (th/create-profile* 1 {:is-active true}) + out (th/command! {::th/type :get-project + ::rpc/profile-id (:id prof) + :id (uuid/random)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest get-project-no-permission + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + proj (th/create-project* 1 {:profile-id (:id owner) + :team-id (:default-team-id owner)}) + out (th/command! {::th/type :get-project + ::rpc/profile-id (:id other) + :id (:id proj)}) + err (:error out)] + (t/is (th/ex-info? err)) + (t/is (th/ex-of-type? err :not-found)))) From 6951876c131a3cf72e75cb3de66c1706c32cce4d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:25:15 +0200 Subject: [PATCH 060/298] :bug: Use constant-time comparison for shared key authentication (#11122) Replace standard '=' operator with MessageDigest/isEqual to prevent timing attacks on shared key authentication middleware. Closes #11121 AI-assisted-by: qwen3.7-plus --- backend/src/app/http/middleware.clj | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index fa2faa8a55..31b96927a6 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -24,7 +24,8 @@ (:import io.undertow.server.RequestTooBigException java.io.InputStream - java.io.OutputStream)) + java.io.OutputStream + java.security.MessageDigest)) (set! *warn-on-reflection* true) @@ -329,6 +330,11 @@ {:name ::auth :compile (constantly wrap-auth)}) +(defn- constant-time-eq? + "Compare strings in constant time to prevent timing attacks." + [^String a ^String b] + (MessageDigest/isEqual (.getBytes a "UTF-8") (.getBytes b "UTF-8"))) + (defn- wrap-shared-key-auth [handler keys] (if (seq keys) @@ -338,7 +344,7 @@ (let [key-id (-> key-id str/lower keyword)] (if (and (string? key) (contains? keys key-id) - (= key (get keys key-id))) + (constant-time-eq? key (get keys key-id))) (-> request (assoc ::http/auth-key-id key-id) (handler)) From 5571c53502bc1a4768eb72e279b6b229e6e021ca Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 11:27:12 +0200 Subject: [PATCH 061/298] :bug: Use random UUIDs for share link IDs (#11117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share link IDs function as capability secrets — anyone possessing the ID can read a file without authentication. The previous UUIDv8 scheme is predictable (56 bits fixed per process + 48-bit timestamp). Changed to uuid/random (UUIDv4) for genuine unpredictability. Closes #11116 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/files_share.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index bb925f243e..9a8326d06d 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -43,7 +43,7 @@ [conn {:keys [profile-id file-id pages who-comment who-inspect]}] (let [pages (db/create-array conn "uuid" pages) slink (db/insert! conn :share-link - {:id (uuid/next) + {:id (uuid/random) :file-id file-id :who-comment who-comment :who-inspect who-inspect From a131e40a6dc33da724bd3409d847e9682243e539 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 09:08:32 +0000 Subject: [PATCH 062/298] :sparkles: Add proper rlimit config and propagate limit timestamp Replace the placeholder rlimit.edn with a real per-endpoint configuration covering auth, SSRF, search, email, media and project operations. The previous file only had a commented-out example, so all limits fell back to the 200k/h default window. Also propagate the evaluated `now` timestamp into both bucket and window result maps, so consumers (e.g. soft-mode reports) can know exactly when the limit was checked. AI-assisted-by: minimax-m3 --- backend/resources/rlimit.edn | 309 ++++++++++++++++++++++++++++++++- backend/src/app/rpc/rlimit.clj | 2 + 2 files changed, 305 insertions(+), 6 deletions(-) diff --git a/backend/resources/rlimit.edn b/backend/resources/rlimit.edn index 118f30f70a..68b3153848 100644 --- a/backend/resources/rlimit.edn +++ b/backend/resources/rlimit.edn @@ -1,11 +1,308 @@ -;; Example rlimit.edn file ^{:refresh "30s"} {:default [[:default :window "200000/h"]] - ;; #{:main/get-teams} - ;; [[:burst :bucket "5/5/5s"]] + ;; ═══════════════════════════════════════════════ + ;; Auth & Identity — public, unauthenticated + ;; ═══════════════════════════════════════════════ + #{:main/login-with-password} + [[:auth-password :bucket "100/50/1m"]] - ;; #{:main/get-profile} - ;; [[:burst :bucket "60/60/1m"]] - } + #{:main/login-with-ldap} + [[:auth-ldap :bucket "20/10/5m"]] + + #{:main/register-profile} + [[:auth-register :bucket "20/10/15m"]] + + #{:main/request-profile-recovery + :main/prepare-register-profile} + [[:auth-recovery :bucket "100/50/5m"]] + + #{:main/recover-profile + :main/verify-token} + [[:auth-token :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; SSRF vectors — URL fetch endpoints + ;; ═══════════════════════════════════════════════ + #{:main/create-file-media-object-from-url} + [[:url-fetch :bucket "100/50/5m"]] + + #{:main/create-webhook + :main/update-webhook} + [[:webhook-validation :bucket "20/10/5m"]] + + ;; ═══════════════════════════════════════════════ + ;; Search — full sequential scan risk + ;; ═══════════════════════════════════════════════ + #{:main/search-files} + [[:search :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Feedback & Invitations — email-sending + ;; ═══════════════════════════════════════════════ + #{:main/send-user-feedback + :main/create-team-invitations} + [[:email-send :bucket "30/15/5m"]] + + ;; ═══════════════════════════════════════════════ + ;; Media & File heavy ops + ;; ═══════════════════════════════════════════════ + #{:main/upload-file-media-object} + [[:image-upload :bucket "200/100/1m"]] + + #{:main/create-file-object-thumbnail + :main/delete-file-object-thumbnails + :main/get-file-object-thumbnails} + [[:thumbnail-ops :bucket "5000/3000/1m"]] + + #{:main/get-file-data-for-thumbnail + :main/create-file-thumbnail} + [[:thumbnail-data :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; UI navigation reads — high frequency + ;; ═══════════════════════════════════════════════ + #{:main/get-teams} + [[:get-teams :bucket "5000/2500/30s"]] + + #{:main/get-team-members} + [[:get-team-members :bucket "4000/2000/30s"]] + + #{:main/get-profile} + [[:get-profile :bucket "500/250/30s"]] + + #{:main/get-font-variants} + [[:get-font-variants :bucket "250/125/30s"]] + + #{:main/get-comment-threads} + [[:get-comment-threads :bucket "500/250/30s"]] + + #{:main/get-profiles-for-file-comments} + [[:get-profiles-for-file-comments :bucket "300/150/30s"]] + + #{:main/get-file-libraries} + [[:get-file-libraries :bucket "200/100/30s"]] + + #{:main/get-projects} + [[:get-projects :bucket "120/60/30s"]] + + #{:main/get-team-recent-files + :main/get-unread-comment-threads} + [[:get-team-recent :bucket "120/60/30s"]] + + #{:main/get-page} + [[:get-page :bucket "150/75/30s"]] + + #{:main/get-access-tokens + :main/get-subscription-usage} + [[:get-access-tokens :bucket "150/75/30s"]] + + #{:main/get-enabled-flags} + [[:get-enabled-flags :bucket "250/125/30s"]] + + #{:main/get-builtin-templates} + [[:get-builtin-templates :bucket "200/100/30s"]] + + #{:main/get-project + :main/get-project-files} + [[:get-project-info :bucket "80/40/30s"]] + + #{:main/get-file} + [[:get-file :bucket "180/90/1m"]] + + #{:main/get-team-shared-files + :main/get-team-info + :main/get-team-users + :main/get-team-invitations + :main/get-team-deleted-files + :main/get-sso-provider} + [[:get-team-info :bucket "60/30/30s"]] + + #{:main/get-comments + :main/get-file-snapshots + :main/get-library-usage + :main/has-file-libraries} + [[:get-misc-list :bucket "300/150/30s"]] + + #{:main/get-comment-thread + :main/get-library-file-references} + [[:get-misc-single :bucket "60/30/30s"]] + + #{:main/get-file-info + :main/get-view-only-bundle + :main/get-all-projects + :main/get-owned-teams + :main/get-team-stats + :main/get-file-summary + :main/get-file-stats + :main/get-file-fragment} + [[:get-light :bucket "60/30/30s"]] + + ;; ═══════════════════════════════════════════════ + ;; File mutations — editing active + ;; ═══════════════════════════════════════════════ + #{:main/update-file} + [[:update-file :bucket "1000/500/1m"]] + + #{:main/create-file + :main/rename-file + :main/duplicate-file + :main/move-files} + [[:file-create :bucket "60/30/1m"]] + + #{:main/delete-file} + [[:file-delete :bucket "80/40/1m"]] + + #{:main/set-file-shared + :main/update-file-library-sync-status + :main/ignore-file-library-sync-status + :main/link-file-to-library + :main/unlink-file-from-library + :main/create-file-snapshot + :main/restore-file-snapshot + :main/update-file-snapshot + :main/delete-file-snapshot + :main/lock-file-snapshot + :main/unlock-file-snapshot} + [[:file-mutations :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Project mutations + ;; ═══════════════════════════════════════════════ + #{:main/create-project} + [[:project-create :bucket "100/50/1m"]] + + #{:main/delete-project + :main/rename-project + :main/duplicate-project + :main/move-project + :main/update-project-pin} + [[:project-mutations :bucket "40/20/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Team mutations + ;; ═══════════════════════════════════════════════ + #{:main/create-team + :main/update-team + :main/delete-team + :main/update-team-photo + :main/update-team-member-role + :main/delete-team-member + :main/leave-team + :main/create-team-with-invitations + :main/create-team-access-request + :main/permanently-delete-team-files + :main/restore-deleted-team-files} + [[:team-mutations :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Comment operations + ;; ═══════════════════════════════════════════════ + #{:main/create-comment-thread + :main/create-comment + :main/update-comment + :main/delete-comment + :main/mark-all-threads-as-read} + [[:comment-basic :bucket "30/15/1m"]] + + #{:main/update-comment-thread + :main/update-comment-thread-status + :main/update-comment-thread-position + :main/update-comment-thread-frame + :main/delete-comment-thread} + [[:comment-thread :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Profile operations + ;; ═══════════════════════════════════════════════ + #{:main/update-profile + :main/update-profile-props + :main/update-profile-photo + :main/update-profile-password + :main/update-profile-notifications + :main/delete-profile + :main/delete-profile-photo + :main/request-email-change} + [[:profile-mutations :bucket "30/15/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Font operations + ;; ═══════════════════════════════════════════════ + #{:main/create-font-variant + :main/delete-font + :main/delete-font-variant + :main/update-font + :main/download-font + :main/download-font-family} + [[:font-ops :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Access tokens + ;; ═══════════════════════════════════════════════ + #{:main/create-access-token + :main/delete-access-token} + [[:access-token :bucket "60/30/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Export / Import + ;; ═══════════════════════════════════════════════ + #{:main/export-binfile + :main/import-binfile + :main/clone-template} + [[:export-import :bucket "80/40/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Upload sessions + ;; ═══════════════════════════════════════════════ + #{:main/create-upload-session + :main/upload-chunk + :main/assemble-file-media-object} + [[:upload-session :bucket "100/50/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Webhooks + ;; ═══════════════════════════════════════════════ + #{:main/get-webhooks + :main/delete-webhook} + [[:webhook-read :bucket "20/10/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Share links + ;; ═══════════════════════════════════════════════ + #{:main/create-share-link + :main/delete-share-link} + [[:share-link :bucket "10/5/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Organization operations + ;; ═══════════════════════════════════════════════ + #{:main/add-team-to-organization + :main/remove-team-from-org + :main/all-org-members-in-team + :main/all-team-members-in-orgs + :main/get-owned-organizations-summary + :main/get-leave-org-summary + :main/leave-org + :main/check-org-members + :main/get-team-invitation-token + :main/delete-team-invitation + :main/check-team-external-invitations} + [[:org-ops :bucket "20/10/1m"]] + + ;; ═══════════════════════════════════════════════ + ;; Audit & stats + ;; ═══════════════════════════════════════════════ + #{:main/push-audit-events} + [[:audit-events :bucket "1000/500/1m"]] + + #{:main/logout + :main/get-error-report + :main/get-error-reports + :main/get-current-mcp-token + :main/get-nitrate-connectivity + :main/check-nitrate-sso + :main/redeem-nitrate-activation-code + :main/create-demo-profile + :main/get-subscription-warning} + [[:misc-light :bucket "100/50/1m"]]} diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index 8c28f6a3c6..abc77d81ea 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -190,6 +190,7 @@ :allowed allowed? :remaining remaining) (-> limit + (assoc ::lresult/now now) (assoc ::lresult/allowed allowed?) (assoc ::lresult/reset (ct/plus now reset)) (assoc ::lresult/remaining remaining)))) @@ -212,6 +213,7 @@ :allowed allowed? :remaining remaining) (-> limit + (assoc ::lresult/now now) (assoc ::lresult/allowed allowed?) (assoc ::lresult/timestamp ts) (assoc ::lresult/remaining remaining) From bf9825fcfe579c5966f1f6e61766928faf7d278a Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 7 Aug 2026 12:33:11 +0200 Subject: [PATCH 063/298] :bug: Fix close modal with esc (#11131) --- frontend/src/app/main/ui/comments.cljs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 4a7032b62c..550b4b8e37 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -1063,6 +1063,14 @@ (fn [content] (st/emit! (dcm/add-comment thread content)))) + on-key-down + (mf/use-fn + (fn [event] + (when (kbd/esc? event) + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dcm/close-thread))))) + on-cancel (mf/use-fn #(st/emit! (dcm/close-thread)))] @@ -1086,6 +1094,7 @@ :style {:left (str pos-x "px") :top (str pos-y "px") "--comment-height" (str max-height "px")} + :on-key-down on-key-down :on-click dom/stop-propagation} [:div {:class (stl/css :floating-thread-header)} From 30bc2a4bc30758f179c991ac19aaed42fcc76e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 7 Aug 2026 12:36:52 +0200 Subject: [PATCH 064/298] :wrench: Add FF to enable wasm export at team level (#11130) --- common/src/app/common/features.cljc | 5 ++++- frontend/src/app/main/data/exports/assets.cljs | 9 ++++----- frontend/src/app/main/data/workspace/clipboard.cljs | 2 +- frontend/src/app/plugins/shape.cljs | 4 ++-- render-wasm/docs/rendering_architecture.md | 5 ++++- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index a5097a29a7..216c5fc64f 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -57,6 +57,7 @@ "text-editor/v2" "text-editor-wasm/v1" "render-wasm/v1" + "wasm-export/v1" "variants/v1"}) ;; A set of features enabled by default @@ -82,7 +83,8 @@ "text-editor/v2" "text-editor-wasm/v1" "tokens/numeric-input" - "render-wasm/v1"}) + "render-wasm/v1" + "wasm-export/v1"}) ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it @@ -132,6 +134,7 @@ :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-wasm "text-editor-wasm/v1" :feature-render-wasm "render-wasm/v1" + :feature-wasm-export "wasm-export/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" nil)) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index b1ced71ad6..9a22fe25b9 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -8,7 +8,6 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.event :as ev] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] @@ -183,11 +182,11 @@ (def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) (defn- wasm-export-enabled? - "WASM export is available: the flag is set AND render-wasm is active for the - current file. When render-wasm is inactive its shape tree isn't loaded, so a - client-side WASM render would crash." + "WASM export is available when the `wasm-export/v1` feature is active AND + render-wasm is active for the current file. When render-wasm is inactive its + shape tree isn't loaded, so a client-side WASM render would crash." [state] - (and (contains? cf/flags :wasm-export) + (and (features/active-feature? state "wasm-export/v1") (features/active-feature? state "render-wasm/v1"))) (defn- use-wasm-export? diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 21346c879c..ac803897d7 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1201,7 +1201,7 @@ ;; Call exporter to get image URI, then fetch blob and resolve the deferred. (->> (if (and (features/active-feature? state "render-wasm/v1") - (contains? cf/flags :wasm-export)) + (features/active-feature? state "wasm-export/v1")) (rx/of {:uri (wasm.exports/export-image-uri export)}) (rp/cmd! :export {:exports [export] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 88177b39ab..1b0a9fa965 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -33,7 +33,6 @@ [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] [app.common.uuid :as uuid] - [app.config :as cf] [app.main.data.exports.assets :as de] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] @@ -50,6 +49,7 @@ [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.variants :as dwv] + [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.exports :as exports] @@ -1532,7 +1532,7 @@ (u/not-valid plugin-id :export value) :else - (if (and (contains? cf/flags :wasm-export) + (if (and (features/active-feature? @st/state "wasm-export/v1") (contains? #{:jpeg :webp :png} (:type value :png))) ;; New export with wasm (let [uri (wasm.exports/export-image-uri diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md index 709c882900..12812af022 100644 --- a/render-wasm/docs/rendering_architecture.md +++ b/render-wasm/docs/rendering_architecture.md @@ -80,7 +80,10 @@ flowchart TB The client-side WASM export — rendering in the browser through the vector path (`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and -only when render-wasm is active and the `:wasm-export` flag is set. +only when render-wasm is active and the `wasm-export/v1` feature is enabled +(instance-wide through the `enable-feature-wasm-export` flag, or per team +through the team `features` column). The exporter service keeps its own +`:wasm-export` flag to decide whether it can serve the headless WASM path. **Multiple/batch export** (`request-multiple-export`) always runs **server-side** via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the From e2d429d283184311edc9118296c12fcee3dae526 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 13:37:00 +0200 Subject: [PATCH 065/298] :bug: Add timeout to plugin manifest fetch (#11120) The fetch-manifest function previously had no timeout, causing the plugin installation flow to hang indefinitely if the server accepted the connection but never completed the response. Added a 15-second timeout using rx/timeout to abort the request automatically. Closes #11119 AI-assisted-by: qwen3.7-plus --- frontend/src/app/main/data/plugins.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 6635fc070b..4f7e8b7ceb 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -39,6 +39,7 @@ :uri plugin-url :omit-default-headers true :response-type :json}) + (rx/timeout 15000) (rx/map :body) (rx/map #(preg/parse-manifest plugin-url %)))) From 2f04fcddbf15a56f5a4205a47e1d7d31ba7111b5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 13:44:13 +0200 Subject: [PATCH 066/298] :bug: Invalidate all sessions on profile deletion (#11115) When a profile is deleted, only the current session was being invalidated. Other active sessions on different devices remained functional until the background cleanup task completed. Add session/invalidate-all helper that deletes all sessions for a profile by profile_id, and call it from delete-profile before the response transform. This ensures immediate access revocation across all devices when an account is deleted. Closes #11114 AI-assisted-by: qwen3.7-plus --- backend/src/app/http/session.clj | 8 ++++++ backend/src/app/rpc/commands/profile.clj | 4 +++ .../test/backend_tests/rpc_profile_test.clj | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 614942c072..61140a780c 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -226,6 +226,14 @@ (-> (db/exec-one! cfg [sql (:profile-id session) (:id session)]) (db/get-update-count)))) +(defn invalidate-all + "Delete all sessions for a given profile. Used when a profile is deleted + to ensure immediate access revocation across all devices." + [cfg profile-id] + (let [sql "delete from http_session_v2 where profile_id = ?"] + (-> (db/exec-one! cfg [sql profile-id]) + (db/get-update-count)))) + (def ^:private sql:clear-organization-sso-sessions (str "UPDATE http_session_v2 " "SET props = props #- ARRAY['~:sso', ?]::text[] " diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 26716cc411..77307a1482 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -534,6 +534,10 @@ :deleted-at deleted-at :id profile-id}}) + ;; Invalidate all sessions for this profile to ensure immediate + ;; access revocation across all devices + (session/invalidate-all cfg profile-id) + (-> (rph/wrap nil) (rph/with-transform (session/delete-fn cfg))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index f846cfb343..f9900d44ab 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -388,6 +388,31 @@ (let [result (th/run-task! :objects-gc {:min-age 0})] (t/is (= 10 (:processed result)))))) +(t/deftest profile-deletion-invalidates-all-sessions + (let [prof (th/create-profile* 1) + + ;; Insert 3 sessions for this profile directly into the database + session-ids (doall + (for [i (range 3)] + (let [sid (uuid/random)] + (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) (str "user-agent-" i)]) + sid)))] + + ;; Verify sessions exist + (let [count-before (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))] + (t/is (= 3 count-before))) + + ;; Request profile to be deleted + (let [params {::th/type :delete-profile + ::rpc/profile-id (:id prof)} + out (th/command! params)] + (t/is (nil? (:error out)))) + + ;; Verify ALL sessions were invalidated (not just one) + (let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))] + (t/is (= 0 count-after))))) + (t/deftest email-blacklist-1 (t/is (false? (email.blacklist/enabled? th/*system*))) From b5bec4f983b5540a3ed7969121badf08a14f384e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero-Cantero?= <807608+alvorithm@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:20:24 +0200 Subject: [PATCH 067/298] :bug: Declare new shape attributes in schemas to match stored files (#11125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Declare the shape attributes stored files carry `schema:shape-attrs` is the shape model as *declared*, and it has fallen behind the `Shape` record. Three record fields are absent from it: `rotation`, `flip-x` and `flip-y` are therefore present on every shape that exists and declared nowhere. `rotation` is already named twice in this namespace, in `allowed-shape-attrs`, and once in `app.common.types.shape.attrs/editable-attrs`, so the schema is demonstrably the odd one out rather than the data being unusual. Nothing complains, because the maps are open: an undeclared key validates fine. What breaks is everything that reads the model *from the schema* rather than from a live value, such as the generative tests' shape generator, the generated OpenAPI surface, and any consumer reflecting over `schema:shape-attrs`. Whether an entry is optional, nilable, or both is decided by the record rather than by taste. `app.common.record/defrecord` cannot remove a base field: its `without` assocs nil and its `containsKey` answers true whatever the field holds, on both platforms. So a `Shape` base field is always present, and nil is how that field says "unset". Every other key lives in the `$extmap`, disappears on dissoc, and is dropped by `setup-shape` when a caller passes nil. Base fields are therefore nilable, and the rest are optional. Declared here, measured over a 305-shape corpus: - `rotation`, `flip-x` and `flip-y`, record fields present on every shape, nilable for the reason above: `make-minimal-shape` gives the two flip fields no default, so they are nil on all 305. Optional as well, unlike the geometry below, because `schema:shape-generic-attrs` has a second job: `check-shape-generic-attrs` validates partial update payloads with it, such as the `{:blocked true}` that `app.main.data.workspace/update-shape` passes, and a required key here would reject every such payload. - `hide-in-viewer`, moved out of `schema:frame-attrs`, because circles, rects and texts carry it too, 197 shapes. - `svg-attrs`, `svg-defs`, `svg-transform` and `svg-viewbox`, the SVG provenance an import leaves behind, 101 shapes and 63 for the transform. Typed `:map` rather than more precisely on purpose: legacy files hold `svg-transform` as a plain `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as either a `::grc/rect` record or a plain map, so a tighter schema would reject files that are otherwise valid. - `use-for-thumbnail` on frames. The model has long had it: `app.common.files.migrations` renames `:use-for-thumbnail?` to it and `app.common.logic.libraries` reads it. This schema had not declared it. - `rx` and `ry` on rects and circles, the legacy radii SVG import parses off the element and migration 0003 assocs as `0`. Superseded by `r1` to `r4`, but stored files carry them. - `content` on svg-raw. `shapes-builder/create-raw-svg` sets it and `allowed-svg-attrs` names it. Typed `[:or :map :string]`, because a bare text node arrives as the string itself: `hi` becomes one svg-raw for the element and another for `"hi"`, and `shapes-builder/parse-svg-element` carries a FIXME about exactly that. `schema:nilable-geom-attrs` is new, for bool and path. Those two are the only shape types whose geometry can be nil: `make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every other type and skips those two, whose extent their content and `selrect` imply instead. The four keys stay required, as they already are in the other seven branches, and only the nil is new. **Do not make the analogous change to `ctf/schema:file`.** That map carries `:backend`, `:comment-thread-seqn` and `:ignore-sync-until`, none of which the schema declares, and declaring them breaks saving: `app.binfile.common/update-file!` derives its UPDATE column list from a file map's keys, and the `file` table has no `backend` column, it being synthesized on read. Measured at 185 failures, mostly `rpc-file-test`. Whether a schema serving as both read description and write contract is itself a defect is a real design question, and a separate one. The `check-shape-generic-attrs` case above is a second instance of it. Adding entries changes what `shape-generator` produces, so generative tests begin exercising code paths with these attributes present. That is where a problem would surface. With this applied the common suite is 1142 tests and 24702 assertions on the Clojure side, 992 tests and 24017 assertions on the ClojureScript side, no failures on either. AI-assisted-by: mixed models * :sparkles: Align shape generator with declared schema and add key-presence test shape-generator now selects geometry attrs per-type: nilable-geom-attrs for bool/path, shape-geom-attrs for everything else, and always merges them. This removes the dead attrs2 generation for bool/path and the implicit dependency on create-shape adding nil defaults for missing base record fields. The new shape-generator-key-presence test asserts that generated shapes carry the required keys: rotation, flip-x, flip-y on all shapes and x, y, width, height on bool/path, even when nilable. AI-assisted-by: longcat-2.0-free * :bug: Sample 200 shapes in the key-presence test, not 10 `sg/sample` hands its options to `malli.generator/sample`, which reads `:size`. `:num` is test.check's option. It is correct for the `smt/check!` call directly above, where it came from, but `sg/sample` ignores it and falls back to its default of 10. Ten samples leave the bool and path assertions vacuous about one run in fourteen. Simulated over 200 draws of 10, 14 contained no bool and no path at all, and the median draw held 2. Those four assertions defend exactly the keys this branch made required, so a run that skips them silently is the one case worth not missing. The assertion count shows the arithmetic. The test contributed 42 with `:num`, which is 10 shapes times 3 keys plus 3 bool-or-path shapes times 4 keys, and contributes 756 with `:size`. The common suite goes from 1143 tests and 24744 assertions to 1143 tests and 25458 assertions, no failures either way. AI-assisted-by: mixed models --------- Co-authored-by: Andrey Antukh --- common/src/app/common/types/shape.cljc | 105 ++++++++++++++++-- .../types/shape_decode_encode_test.cljc | 21 ++++ 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 46f608f390..2a3af1ffeb 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -233,7 +233,50 @@ [:grow-type {:optional true} [::sm/one-of grow-types]] [:applied-tokens {:optional true} cto/schema:applied-tokens] - [:plugin-data {:optional true} ctpg/schema:plugin-data]]) + [:plugin-data {:optional true} ctpg/schema:plugin-data] + + ;; `rotation`, `flip-x` and `flip-y` are fields of the `Shape` record (see + ;; `cr/defrecord Shape` above) and this schema did not declare them. + ;; `rotation` was already named in `allowed-shape-attrs` here and in + ;; `app.common.types.shape.attrs/editable-attrs`, so the omission was in this + ;; schema and not in the model. Anything reading the model from the schema + ;; rather than from a live shape missed all three: the graph projection + ;; derives one column per entry (`app.graph.schema.projection`), so shape + ;; nodes carried no rotation at all, and a consumer cannot place a shape + ;; without it. + ;; + ;; Nilable, because `app.common.record/defrecord` cannot remove a base + ;; field: its `without` assocs nil and its `containsKey` answers true + ;; whatever the field holds, so nil is how a record field says "unset". + ;; `flip-x` and `flip-y` are nil on every shape `setup-shape` builds, since + ;; `make-minimal-shape` gives them no default. + ;; + ;; Optional as well, unlike the geometry group below, because this schema + ;; has a second job: `check-shape-generic-attrs` validates partial update + ;; payloads with it, such as the `{:blocked true}` that + ;; `app.main.data.workspace/update-shape` passes. A required key here would + ;; reject every such payload. + [:rotation {:optional true} [:maybe ::sm/safe-number]] + [:flip-x {:optional true} [:maybe :boolean]] + [:flip-y {:optional true} [:maybe :boolean]] + + ;; Carried on circles, rects and texts too, not only on frames, so it + ;; belongs here rather than in `schema:frame-attrs`. Not nilable: the key + ;; lives outside the record, `app.common.logic.shapes` dissocs it to unset + ;; it, and `setup-shape` drops it when a caller passes nil. + [:hide-in-viewer {:optional true} :boolean] + + ;; The SVG provenance an import leaves on a shape. Typed `:map` rather than + ;; more precisely on purpose: legacy files hold `svg-transform` as a plain + ;; `{:a … :f}` map rather than a `::gmt/matrix` record, and `svg-viewbox` as + ;; either a `::grc/rect` record or a plain map, so a tighter schema here + ;; would reject files that are otherwise valid. The graph *column* types are + ;; tightened separately, where a wrong guess costs a column rather than a + ;; rejected file (`app.graph.schema.contract/type-overrides`). + [:svg-attrs {:optional true} :map] + [:svg-defs {:optional true} :map] + [:svg-transform {:optional true} :map] + [:svg-viewbox {:optional true} :map]]) (def schema:group-attrs [:map {:title "GroupAttrs"} @@ -244,7 +287,30 @@ [:shapes [:vector {:gen/max 10 :gen/min 1} ::sm/uuid]] [:hide-fill-on-export {:optional true} :boolean] [:show-content {:optional true} :boolean] - [:hide-in-viewer {:optional true} :boolean]]) + ;; `hide-in-viewer` moved to `schema:shape-generic-attrs`: stored files carry + ;; it on circles, rects and texts too, not only on frames. + ;; `use-for-thumbnail` is a frame attribute the model has long had, since + ;; `app.common.files.migrations` renames `:use-for-thumbnail?` to it and + ;; `app.common.logic.libraries` reads it, and this schema had not declared. + [:use-for-thumbnail {:optional true} :boolean]]) + +(def ^:private schema:nilable-geom-attrs + "`schema:shape-geom-attrs`, but nilable. + + Bools and paths are the only two shape types whose geometry can be nil: + `make-minimal-shape` gives `x`, `y`, `width` and `height` a default for every + other type and skips those two, whose extent their content and `selrect` + imply instead. The four keys stay required, because they are `Shape` record + fields and `app.common.record/defrecord` keeps a base field present whatever + it holds. So these two branches cannot merge `schema:shape-geom-attrs`, which + rejects the nil, and declare the same four keys nilable instead. A + schema-derived reader previously saw a bool or a path as having no position or + size at all." + [:map {:title "NilableGeometryAttrs"} + [:x [:maybe ::sm/safe-number]] + [:y [:maybe ::sm/safe-number]] + [:width [:maybe ::sm/safe-number]] + [:height [:maybe ::sm/safe-number]]]) (def ^:private schema:bool-attrs [:map {:title "BoolAttrs"} @@ -253,10 +319,19 @@ [:content path/schema:content]]) (def ^:private schema:rect-attrs - [:map {:title "RectAttrs"}]) + [:map {:title "RectAttrs"} + ;; Legacy radii, set by SVG import (`app.common.files.shapes-builder` parses + ;; `rx`/`ry` off the element) and by migration 0003, which assocs `0`. + ;; Superseded by `r1` to `r4`, but stored files still carry them. Not + ;; nilable: both keys live outside the `Shape` record, so a dissoc removes + ;; them, and `setup-shape` drops a nil before the merge. + [:rx {:optional true} ::sm/safe-number] + [:ry {:optional true} ::sm/safe-number]]) (def ^:private schema:circle-attrs - [:map {:title "CircleAttrs"}]) + [:map {:title "CircleAttrs"} + [:rx {:optional true} ::sm/safe-number] + [:ry {:optional true} ::sm/safe-number]]) (def ^:private schema:svg-raw-attrs [:map {:title "SvgRawAttrs"} @@ -266,7 +341,15 @@ ;; keeps the child ids typed as uuid, so a JSON round trip (binfile ;; export/import) decodes them back to uuids instead of leaving ;; strings that no longer resolve against the objects map. - [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]]]) + [:shapes {:optional true} [:vector {:gen/max 10} ::sm/uuid]] + ;; The raw SVG node an import kept. + ;; `app.common.files.shapes-builder/create-raw-svg` sets it and + ;; `allowed-svg-attrs` names it. Usually the parsed element, + ;; `{:tag … :attrs … :content …}`, but a bare text node arrives as the + ;; string itself: `hi` becomes one svg-raw for the element + ;; and another for `"hi"`. `app.common.files.shapes-builder/parse-svg-element` + ;; carries a FIXME about exactly that. Both forms are legal and stored. + [:content {:optional true} [:or :map :string]]]) (def schema:image-attrs [:map {:title "ImageAttrs"} @@ -301,7 +384,10 @@ (->> (sg/generator schema:shape-base-attrs) (sg/mcat (fn [{:keys [type] :as shape}] (sg/let [attrs1 (sg/generator schema:shape-generic-attrs) - attrs2 (sg/generator schema:shape-geom-attrs) + attrs2 (if (or (= type :path) + (= type :bool)) + (sg/generator schema:nilable-geom-attrs) + (sg/generator schema:shape-geom-attrs)) attrs3 (case type :text (sg/generator schema:text-attrs) :path (sg/generator schema:path-attrs) @@ -312,10 +398,7 @@ :bool (sg/generator schema:bool-attrs) :group (sg/generator schema:group-attrs) :frame (sg/generator schema:frame-attrs))] - (if (or (= type :path) - (= type :bool)) - (merge attrs1 shape attrs3) - (merge attrs1 shape attrs2 attrs3))))) + (merge attrs1 shape attrs2 attrs3)))) (sg/fmap create-shape))) (def schema:shape-attrs @@ -347,6 +430,7 @@ ctsl/schema:layout-child-attrs schema:bool-attrs schema:shape-generic-attrs + schema:nilable-geom-attrs schema:shape-base-attrs]] [:rect @@ -386,6 +470,7 @@ ctsl/schema:layout-child-attrs schema:path-attrs schema:shape-generic-attrs + schema:nilable-geom-attrs schema:shape-base-attrs]] [:text diff --git a/common/test/common_tests/types/shape_decode_encode_test.cljc b/common/test/common_tests/types/shape_decode_encode_test.cljc index 0068633d32..044c48ede3 100644 --- a/common/test/common_tests/types/shape_decode_encode_test.cljc +++ b/common/test/common_tests/types/shape_decode_encode_test.cljc @@ -146,3 +146,24 @@ ;; (app.common.pprint/pprint shape-3) (= shape shape-3))) {:num 200}))) + +(t/deftest shape-generator-key-presence + "The generator must produce the keys the schema declares required, even when + nilable. This is a targeted check for the attributes added to + `schema:shape-generic-attrs` and `schema:nilable-geom-attrs`." + (let [shapes (sg/sample (sg/generator schema:shape) {:size 200}) + by-type (group-by :type shapes)] + ;; All shapes: rotation, flip-x, flip-y are base record fields, always + ;; present (possibly nil). + (doseq [shape shapes] + (t/is (contains? shape :rotation) "missing :rotation") + (t/is (contains? shape :flip-x) "missing :flip-x") + (t/is (contains? shape :flip-y) "missing :flip-y")) + ;; Bool and path: x/y/width/height are required-but-nilable in the + ;; schema. The generator must produce them (nil is a valid value). + (doseq [shape (concat (get by-type :bool []) + (get by-type :path []))] + (t/is (contains? shape :x) "bool/path missing :x") + (t/is (contains? shape :y) "bool/path missing :y") + (t/is (contains? shape :width) "bool/path missing :width") + (t/is (contains? shape :height) "bool/path missing :height")))) From e01b36b84180c6351410561bed00b178a46ec660 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 7 Aug 2026 19:21:32 +0200 Subject: [PATCH 068/298] :bug: Add project-id guard to use-plugin-register layout effect (#10859) Add `project-id` to the guard condition in `use-plugin-register`'s layout effect so the plugin "Try out" flow waits until projects have loaded. Previously, only `plugin-url` was checked, which allowed the fetch to fire before projects were available, sending a nil `project-id` and causing a 400 validation error from the backend. AI-assisted-by: mimo-v2.5-pro --- frontend/src/app/main/ui/dashboard.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 20daf964a7..622873ac4c 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -211,7 +211,7 @@ (mf/with-layout-effect [plugin-url team-id project-id] - (when plugin-url + (when (and plugin-url project-id) (->> (dp/fetch-manifest plugin-url) (rx/subs! (fn [plugin] From fcd33340b3259a6e9af3b43833455c3b11060bbc Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 10 Aug 2026 10:24:23 +0200 Subject: [PATCH 069/298] :bug: Use a single translation key for the Mixed values label (#11151) The design sidebar named the same "mixed values" concept with two different translation keys. Most sections use settings.multiple, while the blur options and the design system numeric input used labels.mixed-values. Both read "Mixed" in English, so the split is invisible in the default locale, but labels.mixed-values has no translation at all in 16 locales and a different wording in 8 more. Where it is missing the string falls back to the default language, so those controls rendered the English word next to sections showing the localized one; where both exist, a single sidebar named the same concept two ways (fr "Divers" against "Melange", ru "Smeshanyy" against "Smeshat"). Point the two outliers at settings.multiple, the key the rest of the sidebar already uses and the one translated in every locale that ships a translation for it. Fixes #11148. Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 5 --- frontend/src/app/main/ui/ds/controls/numeric_input.cljs | 2 +- .../src/app/main/ui/workspace/sidebar/options/menus/blur.cljs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index ed7d4c4096..44694a708f 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -723,7 +723,7 @@ :id id :class inner-class :placeholder (if is-multiple? - (tr "labels.mixed-values") + (tr "settings.multiple") placeholder) :default-value (fmt/format-number (or (mf/ref-val last-value*) value)) :on-blur handle-blur diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs index 80ee55805a..1e63564a82 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs @@ -296,7 +296,7 @@ (if mixed-state [:div {:class (stl/css :first-row)} [:span {:class (stl/css :mixed-label)} - (tr "labels.mixed-values")] + (tr "settings.multiple")] [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.blur-options.remove-blur") :on-click handle-delete-all From b9c92496f1fdb25f8dd42ff010bfcb1a471f6178 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 10:56:04 +0200 Subject: [PATCH 070/298] :bug: Fix overrides lost after switch (#10619) --- common/src/app/common/logic/libraries.cljc | 7 +- .../logic/variants_switch_test.cljc | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index a89aa633ab..b234263468 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2345,7 +2345,12 @@ updated-sync-groups (into #{} (keep #(ctk/resolve-sync-group (:type previous-shape) %)) updated-attrs) - new-touched (set/union (or (:touched current-shape) #{}) updated-sync-groups) + text-sub-touched #{:text-content-text :text-content-attribute :text-content-structure} + new-touched (set/union (or (:touched current-shape) #{}) + updated-sync-groups + (when (contains? updated-sync-groups :content-group) + (set/intersection (or (:touched previous-shape) #{}) + text-sub-touched))) roperations (into [{:type :set-touched :touched new-touched}] roperations) uoperations (into (list {:type :set-touched :touched (:touched current-shape)}) uoperations)] (cond-> changes diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index ed9eeae783..375b6e6c6e 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -10,6 +10,7 @@ [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] + [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] @@ -3101,3 +3102,82 @@ (t/is (= 150 (:width rect02'))) (t/is (= (+ (:y copy02') 70) (:y rect02'))) (t/is (= (:y rect02') (get-in rect02' [:selrect :y]))))) + +;; ============================================================ +;; PRESERVE TEXT SUB-TOUCHED FLAGS ACROSS VARIANT SWITCH +;; ============================================================ + +(t/deftest test-switch-preserves-text-sub-touched-flags + ;; 1. Creates a component with text "hello world" + font-size "14", variant with font-size "20" + ;; 2. Overrides only text on the copy → verifies :text-content-text in touched + ;; 3. Switches to variant → verifies text override preserved, font-size updated, :text-content-text preserved + ;; 4. Updates main font-size to "30" and syncs → verifies font-size synced but text override preserved + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; c01 has text "hello world" font-size "14" + ;; c02 has text "hello world" font-size "20" (same text, different font-size) + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world") + (update-attr :t02 font-size-path-0 "20") + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-t01])) + + ;; Override only the TEXT on the copy (not font-size) + file (update-attr file :copy-t01 text-path-0 "custom text") + copy-t01 (ths/get-shape file :copy-t01)] + + ;; Verify the copy has the text override and correct touched flags + (t/is (= (get-in copy-t01 text-path-0) "custom text")) + (t/is (= (get-in copy-t01 font-size-path-0) "14")) + (t/is (contains? (:touched copy-t01) :content-group)) + (t/is (contains? (:touched copy-t01) :text-content-text)) + (t/is (not (contains? (:touched copy-t01) :text-content-attribute))) + (t/is (not (contains? (:touched copy-t01) :text-content-structure))) + + ;; ==== Action: Switch copy to c02 variant (same text, different font-size) + (let [file' (tho/swap-component-in-shape file :copy01 :c02 + {:new-shape-label :copy02 + :keep-touched? true}) + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + copy-t02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; After switch: text override preserved (same text between variants), + ;; font-size updated from variant, touched preserves text-content-text + (t/is (= (get-in copy-t02' text-path-0) "custom text")) + (t/is (= (get-in copy-t02' font-size-path-0) "20")) + (t/is (contains? (:touched copy-t02') :content-group)) + (t/is (contains? (:touched copy-t02') :text-content-text)) + (t/is (not (contains? (:touched copy-t02') :text-content-attribute))) + + ;; ==== Now test subsequent component sync + ;; Modify the main component's font-size to "30" (keeping text "hello world") + (let [main-text (ths/get-shape file' :t02) + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page')) + #{(:id main-text)} + (fn [shape] + (assoc-in shape font-size-path-0 "30")) + (:objects page') + {}) + updated-file (thf/apply-changes file' changes1) + + changes2 (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id updated-file) + (thi/id :c02) + (:id updated-file) + {(:id updated-file) updated-file} + (:id updated-file)) + + synced-file (thf/apply-changes updated-file changes2) + synced-copy (ths/get-shape synced-file :copy02) + synced-t (get-in (thf/current-page synced-file) + [:objects (-> synced-copy :shapes first)])] + + ;; The text override is preserved and font-size is synced + (t/is (= (get-in synced-t text-path-0) "custom text")) + (t/is (= (get-in synced-t font-size-path-0) "30")) + (t/is (contains? (:touched synced-t) :content-group)) + (t/is (contains? (:touched synced-t) :text-content-text)))))) From d63d6370c01393203ba7618bc7438a2c0c8eddb5 Mon Sep 17 00:00:00 2001 From: Jules Date: Mon, 10 Aug 2026 03:34:52 -0600 Subject: [PATCH 071/298] :bug: Fix stale DNS caching in frontend nginx MCP proxy (#10947) The generated /etc/nginx/overrides/server.d/mcp-locations.conf used a plain proxy_pass target (e.g. `proxy_pass http://penpot-mcp:4402;`) where $PENPOT_MCP_URI/$PENPOT_MCP_URI_WS are shell variables substituted once by envsubst in nginx-entrypoint.sh at container startup, not nginx variables. nginx resolves a literal proxy_pass hostname once when the config loads and never re-checks it, so the existing `resolver 127.0.0.11 valid=10s;` directive in overrides/http.d/resolvers.conf has no effect on these three locations - it only applies to nginx variables evaluated per-request. In multi-container deployments where the penpot-mcp container restarts or is recreated independently of penpot-frontend (image update, OOM, orchestrator reschedule), it gets a new IP from Docker's/the orchestrator's DNS, and the frontend's nginx keeps forwarding to the old, now-dead address until penpot-frontend itself is restarted. This surfaces to users as `wss:///mcp/ws` failing to connect from the browser after enabling the MCP plugin, with `connect() failed (111: Connection refused)` in the frontend's nginx logs. Route each location through a `set $var ...; proxy_pass $var;` pair so proxy_pass evaluates a real nginx variable, letting the pre-existing resolver directive re-resolve penpot-mcp within its 10s TTL instead of caching the address for the container's lifetime. For /mcp/stream and /mcp/sse, the set value also appends $is_args$args explicitly: when proxy_pass targets a variable AND that variable's value includes a URI/path component, nginx does not automatically forward the original request's query string the way it does for a static proxy_pass target - it must be appended by hand, or the userToken query parameter used for multi-user authentication is silently dropped before reaching the MCP server. /mcp/ws has no path component in its target so it isn't affected by this and needed no such change. Verified locally: force-recreated the penpot-mcp container onto a different IP while leaving penpot-frontend untouched; the /mcp/ws WebSocket upgrade kept returning 101 Switching Protocols throughout, both immediately and after the resolver's TTL window. Separately verified /mcp/stream: a POST with ?userToken=... now shows up server-side as userTokenFp= instead of , and an actual MCP client (Claude Code) using this proxy can now call authenticated tools like execute_code successfully. Signed-off-by: Jules LaPrairie --- docker/images/files/nginx-mcp-locations.conf.template | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docker/images/files/nginx-mcp-locations.conf.template b/docker/images/files/nginx-mcp-locations.conf.template index ab4df0acbb..6ff6fda592 100644 --- a/docker/images/files/nginx-mcp-locations.conf.template +++ b/docker/images/files/nginx-mcp-locations.conf.template @@ -1,16 +1,19 @@ location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_pass $PENPOT_MCP_URI_WS; + set $mcp_ws_backend $PENPOT_MCP_URI_WS; + proxy_pass $mcp_ws_backend; proxy_http_version 1.1; } location /mcp/stream { - proxy_pass $PENPOT_MCP_URI/mcp; + set $mcp_stream_backend $PENPOT_MCP_URI/mcp$is_args$args; + proxy_pass $mcp_stream_backend; proxy_http_version 1.1; } location /mcp/sse { - proxy_pass $PENPOT_MCP_URI/sse; + set $mcp_sse_backend $PENPOT_MCP_URI/sse$is_args$args; + proxy_pass $mcp_sse_backend; proxy_http_version 1.1; } From 86c563f11f40ac01260a8977a0f9069068303aca Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 11:35:51 +0200 Subject: [PATCH 072/298] :bug: Fix font family typography asset persist across files in new created text layers (#11134) --- ...workspace-texts-typography-persist.spec.js | 110 ++++++++++++++++++ frontend/src/app/main/data/workspace.cljs | 5 +- .../src/app/main/data/workspace/texts.cljs | 3 +- .../data/workspace_texts_test.cljs | 57 +++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js diff --git a/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js new file mode 100644 index 0000000000..d9b6c98c02 --- /dev/null +++ b/frontend/playwright/ui/specs/workspace-texts-typography-persist.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +// --------------------------------------------------------------------------- +// BUG 10925 - Font family typography asset must not persist across files in +// newly created text layers. +// +// `save-font` writes the current font (plus the typography refs of the edited +// shape, when it uses one) into the session-global `:workspace-global +// :default-font`. That state is what seeds the content of brand-new text +// shapes via `v2-default-text-content`. Because it is session-global it +// survives a file switch, so a text created in file B could end up referencing +// a typography asset that only exists in file A (see workspace/texts.cljs +// save-font and workspace.cljs initialize/finalize-workspace). +// +// This E2E reproduces the leak faithfully in a single SPA session: +// 1. Open file A (has a text shape linked to a typography asset). +// 2. Change a font attribute on that shape (triggers `emit-update!` -> +// `save-font` with the current text-node attrs, typography refs included). +// 3. Switch to file B (same session, fragment navigation keeps JS state). +// 4. Create a brand-new text layer in file B. +// 5. Assert the new text uses the DEFAULT Penpot font ("Source Sans Pro"), +// not the typography font-family carried over from file A. +// --------------------------------------------------------------------------- + +const FILE_A = { + id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0", + // "Text with typography asset one" carries a ref to in-file typography whose + // font-family is "IM Fell French Canon SC" (multiselection-typography.json). +}; + +const FILE_B = { + id: "434b0541-fa2f-802f-8006-59827d964a9b", + pageId: "434b0541-fa2f-802f-8006-59827d964a9c", + // render-wasm/get-file-text-custom-fonts.json - a mostly empty file whose + // only text uses the default font (no typography asset). +}; + +async function serveTwoFiles(page) { + const fileABody = await readFile( + "playwright/data/workspace/multiselection-typography.json", + "utf-8", + ); + const fileBBody = await readFile( + "playwright/data/render-wasm/get-file-text-custom-fonts.json", + "utf-8", + ); + + // Dispatch on the `id` query param of the `get-file` RPC so each file gets + // its own fixture while keeping a single SPA session alive. + await page.route(/get\-file\?/, (route) => { + const url = new URL(route.request().url()); + const fileId = url.searchParams.get("id"); + const body = fileId === FILE_A.id ? fileABody : fileBBody; + return route.fulfill({ + status: 200, + contentType: "application/transit+json", + body, + }); + }); +} + +test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + // WASM_FLAGS already enables the v2 text editor / render-wasm. Add the WASM + // text editor on top so typography styles are read through the current text + // values path. + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); +}); + +test("BUG 10925 - typography font does not leak into new text in a different file", async ({ page }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + "get-font-variants?team-id=*", + "render-wasm/get-font-variants-custom-fonts.json", + ); + + await serveTwoFiles(page); + + // ---- File A: select the text linked to a typography and change a font ---- + await workspace.goToWorkspace({ fileId: FILE_A.id, pageId: FILE_A.pageId }); + await workspace.waitForFirstRender(); + await workspace.doubleClickLeafLayer("Text with typography asset one"); + await workspace.textEditor.startEditing(); + + // Changing a font attribute triggers save-font with the current text-node + // attrs (including the typography refs) storing them into default-font. + await workspace.textEditor.changeFontSize(24); + await workspace.textEditor.stopEditing(); + + // ---- File B: same SPA session, switch to a file with no typography ---- + await workspace.goToWorkspace({ fileId: FILE_B.id, pageId: FILE_B.pageId }); + await workspace.waitForFirstRender(); + + // Create a brand-new text layer in file B and query its font-family. + await workspace.createTextShape(100, 100, 300, 200, "hello"); + await workspace.textEditor.stopEditing(); + await workspace.clickLeafLayer("hello"); + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("ControlOrMeta+a"); + + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Source Sans Pro"); + // The custom typography family from file A (IM Fell French Canon SC) must NOT + // be carried over. + await expect(fontFamily).not.toContainText("IM Fell"); +}); \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index cedfad1d96..9bf11fb701 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -346,7 +346,8 @@ (assoc :recent-colors (:recent-colors storage/user)) (assoc :recent-fonts (:recent-fonts storage/user)) (assoc :current-file-id file-id) - (assoc :workspace-presence {}))) + (assoc :workspace-presence {}) + (update :workspace-global dissoc :default-font))) ptk/WatchEvent (watch [_ state stream] @@ -544,7 +545,7 @@ :workspace-tokens :workspace-undo :workspace-versions) - (update :workspace-global dissoc :read-only?) + (update :workspace-global dissoc :read-only? :default-font) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 2a905523b6..fb33068adc 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -824,7 +824,8 @@ (let [multiple? (->> data vals (d/seek #(= % :multiple)))] (cond-> state (not multiple?) - (assoc-in [:workspace-global :default-font] data)))))) + (assoc-in [:workspace-global :default-font] + (dissoc data :typography-ref-id :typography-ref-file))))))) (defn apply-text-modifier [shape text-modifier] diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index a52202bc48..5822b571cf 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -12,6 +12,7 @@ [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] + [app.common.uuid :as uuid] [app.main.data.workspace.texts :as dwt] [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] [cljs.test :as t :include-macros true] @@ -377,6 +378,62 @@ (t/is (= "0.1" (:letter-spacing (first typographies))) "float letter-spacing is normalised to 2-decimal string"))))))) +;; --------------------------------------------------------------------------- +;; Tests: save-font must not persist typography refs into the global default font +;; +;; Root cause of #10925: typography assets are file-specific references, but +;; save-font used to write :typography-ref-id / :typography-ref-file into the +;; session-global [:workspace-global :default-font]. That state survives a file +;; switch, and v2-default-text-content bakes it into brand-new text shapes in +;; the other file, so they got a non-existent typography asset instead of the +;; default Penpot font. save-font now strips those two keys. +;; --------------------------------------------------------------------------- + +(t/deftest save-font-strips-typography-refs-from-default-font + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :text1 + :type :text + :x 0 :y 0 + :content (txt/change-text nil "hello"))) + store (ths/setup-store file) + attrs {:font-id "roboto" + :font-family "Roboto" + :font-variant-id "regular" + :font-size "14" + :typography-ref-id (uuid/next) + :typography-ref-file (:id file)}] + (ths/run-store + store done [(dwt/save-font attrs)] + (fn [new-state] + (let [default-font (get-in new-state [:workspace-global :default-font])] + (t/is (some? default-font)) + (t/is (= "roboto" (:font-id default-font))) + (t/is (nil? (:typography-ref-id default-font))) + (t/is (nil? (:typography-ref-file default-font))))))))) + +(t/deftest save-font-preserves-other-font-attrs + (t/async + done + (let [store (ths/setup-store (cthf/sample-file :file1)) + attrs {:font-family "Open Sans" + :font-id "opensans" + :font-variant-id "regular" + :font-size "18" + :line-height "1.5" + :letter-spacing "0" + :typography-ref-id (uuid/next) + :typography-ref-file (uuid/next)}] + (ths/run-store store done [(dwt/save-font attrs)] + (fn [new-state] + (let [default-font (get-in new-state [:workspace-global :default-font])] + (t/is (= "Open Sans" (:font-family default-font))) + (t/is (= "18" (:font-size default-font))) + (t/is (= "1.5" (:line-height default-font))) + (t/is (nil? (:typography-ref-id default-font))) + (t/is (nil? (:typography-ref-file default-font))))))))) + ;; --------------------------------------------------------------------------- ;; Tests: fix-position with degenerate selrect ;; --------------------------------------------------------------------------- From 900a7ef498253f2472ce4dff823f242d5251131a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:42:28 +0200 Subject: [PATCH 073/298] :recycle: Show subscription section to everybody (#11007) --- common/src/app/common/flags.cljc | 1 - .../src/app/main/ui/dashboard/sidebar.cljs | 19 +------------- .../src/app/main/ui/settings/sidebar.cljs | 3 ++- .../src/app/main/ui/workspace/main_menu.cljs | 26 +++---------------- 4 files changed, 7 insertions(+), 42 deletions(-) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 9988c1a9f8..c5129dd5c3 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -147,7 +147,6 @@ :render-switch :hide-release-modal :subscriptions - :subscriptions-old :inspect-styles ;; Enable performance logs in devconsole (disabled by default) :perf-logs diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index b2dfb2f119..ed39edaaf1 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -1392,13 +1392,7 @@ on-sub-menu-pointer-enter (mf/use-fn (fn [_] - (mf/set-ref-val! hovering?* true))) - - on-power-up-click - (mf/use-fn - (fn [] - (st/emit! (ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) - (dom/open-new-window "https://penpot.app/pricing")))] + (mf/set-ref-val! hovering?* true)))] (mf/with-effect [teams] (when (and (contains? cf/flags :admin-console) @@ -1420,17 +1414,6 @@ [:> subscription-sidebar* {:profile profile}]))) - ;; TODO remove this block when subscriptions is full implemented - (when (contains? cf/flags :subscriptions-old) - [:button {:class (stl/css :upgrade-plan-section) - :on-click on-power-up-click} - [:div {:class (stl/css :penpot-free)} - [:span (tr "dashboard.upgrade-plan.penpot-free")] - [:span {:class (stl/css :no-limits)} - (tr "dashboard.upgrade-plan.no-limits")]] - [:div {:class (stl/css :power-up)} - (tr "subscription.dashboard.upgrade-plan.power-up")]]) - (when (and team profile) [:& comments-section {:profile profile diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 3ddff8f862..99313c13b4 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -117,7 +117,8 @@ :data-testid "settings-profile"} [:span {:class (stl/css :element-title)} (tr "labels.settings")]] - (when (contains? cf/flags :subscriptions) + (when (or (contains? cf/flags :subscriptions) + (contains? cf/flags :admin-console)) [:li {:class (stl/css-case :current subscription? :settings-item true) :on-click go-settings-subscription diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index bb2c6a2d88..e08de97b03 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -927,13 +927,6 @@ (keyword))] (reset! selected-sub-menu* menu)))) - on-power-up-click - (mf/use-fn - (fn [] - (st/emit! (ev/event {::ev/name "explore-pricing-click" - ::ev/origin "workspace:menu"})) - (dom/open-new-window "https://penpot.app/pricing"))) - toggle-flag (mf/use-fn (fn [event] @@ -1130,21 +1123,10 @@ [:> icon* {:icon-id i/arrow-right :class (stl/css :item-arrow)}]] - (when (and (contains? cf/flags :subscriptions) - (not= "enterprise" subscription-type)) - [:> main-menu-power-up* {:close-sub-menu close-sub-menu}]) - - ;; TODO remove this block when subscriptions is full implemented - (when (contains? cf/flags :subscriptions-old) - [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) - :on-click on-power-up-click - :on-key-down (fn [event] - (when (kbd/enter? event) - (on-power-up-click))) - :on-pointer-enter close-sub-menu - :id "file-menu-power-up"} - [:span {:class (stl/css :item-name)} - (tr "subscription.workspace.header.menu.option.power-up")]])] + (when (or (and (contains? cf/flags :subscriptions) + (not= "enterprise" subscription-type)) + (contains? cf/flags :admin-console)) + [:> main-menu-power-up* {:close-sub-menu close-sub-menu}])] (case selected-sub-menu :file From 5d2cb22966f1ac84774af75fb8742c0e7cda0aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:43:27 +0200 Subject: [PATCH 074/298] :zap: Fetch team organization in a single batch (#11140) --- backend/src/app/nitrate.clj | 49 ++++++++++++--- backend/src/app/rpc/commands/teams.clj | 10 +-- ...pc_organization_owner_permissions_test.clj | 11 +++- backend/test/backend_tests/rpc_team_test.clj | 61 +++++++++++++++++++ 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 7f4b470ab3..3fc4fefe68 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -259,6 +259,14 @@ (generate-nitrate-uri "api/teams/" team-id) cto/schema:team-with-organization params)) +(defn- get-teams-organizations-api + [cfg {:keys [team-ids] :as params}] + (let [params (assoc params :request-params {:team-ids team-ids})] + (request-to-nitrate cfg :post + (generate-nitrate-uri "api/teams/organizations") + [:vector cto/schema:team-with-organization] + params))) + (defn- get-organization-membership-api [cfg {:keys [profile-id organization-id] :as params}] (request-to-nitrate cfg :get @@ -489,6 +497,7 @@ [_ cfg] (when (contains? cf/flags :admin-console) {:get-team-organization (partial get-team-organization-api cfg) + :get-teams-organizations (partial get-teams-organizations-api cfg) :set-team-organization (partial set-team-organization-api cfg) :get-organization-membership (partial get-organization-membership-api cfg) :get-organization-membership-by-team (partial get-organization-membership-by-team-api cfg) @@ -596,22 +605,25 @@ :cause cause) profile))))) +(defn- apply-organization-info-to-team + [team team-with-organization] + (let [organization (:organization team-with-organization)] + (if (some? organization) + (-> (cto/apply-organization team (assoc organization :custom-photo + (when-let [logo-id (:logo-id organization)] + (generate-public-uri "assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) + team))) + (defn add-organization-info-to-team "Enriches a team map with organization information from Nitrate. - Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. Returns the original team unchanged if the request fails or organization data is nil. Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try - (let [params (assoc (or params {}) :team-id (:id team)) - team-with-organization (call cfg :get-team-organization params) - organization (:organization team-with-organization)] - (if (some? organization) - (-> (cto/apply-organization team (assoc organization :custom-photo - (when-let [logo-id (:logo-id organization)] - (generate-public-uri "assets/by-id/" logo-id)))) - (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-organization))))) - team)) + (let [params (assoc (or params {}) :team-id (:id team)) + team-with-organization (call cfg :get-team-organization params)] + (apply-organization-info-to-team team team-with-organization)) (catch Throwable cause (if (= :nitrate-unavailable (-> cause ex-data :type)) (throw cause) @@ -621,6 +633,23 @@ :cause cause) team))))) +(defn add-organization-info-to-teams + "Enriches teams with organization information using one batched Nitrate request. + Teams absent from the Nitrate response are returned unchanged. + Rejects the request when Nitrate does not return a valid batch response." + [cfg teams params] + (let [request-params (assoc (or params {}) :team-ids (mapv :id teams)) + teams-with-organization (call cfg :get-teams-organizations request-params)] + (when (nil? teams-with-organization) + (ex/raise :type :nitrate-unavailable + :hint "nitrate did not return a valid teams organization response")) + (let [organizations-by-team (into {} (map (juxt :id identity)) teams-with-organization)] + (mapv (fn [{:keys [id] :as team}] + (if-let [team-with-organization (get organizations-by-team id)] + (apply-organization-info-to-team team team-with-organization) + team)) + teams)))) + (defn set-team-organization "Associates a team with an organization in Nitrate. Requires organization-id and is-default in params. diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 196b35c051..0467082b1a 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -196,11 +196,11 @@ ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] - (cond->> (get-teams conn profile-id) - (contains? cf/flags :admin-console) - (map #(nitrate/add-organization-info-to-team cfg % params)) - (contains? cf/flags :admin-console) - (remove #(get-in % [:organization :expired-license]))))) + (let [teams (get-teams conn profile-id)] + (if (contains? cf/flags :admin-console) + (->> (nitrate/add-organization-info-to-teams cfg teams params) + (remove #(get-in % [:organization :expired-license]))) + teams)))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj index 8168242f16..a971c57539 100644 --- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -50,7 +50,16 @@ :organization (organization-data organization-id organization-owner-id)} {:id (:team-id params) :is-your-penpot false - :organization nil})))] + :organization nil}) + + :get-teams-organizations + (->> (:team-ids params) + (keep (fn [candidate-team-id] + (when (= team-id candidate-team-id) + {:id team-id + :is-your-penpot false + :organization (organization-data organization-id organization-owner-id)}))) + vec)))] (f))) (defn- with-captured-messages diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 5c0477ed17..920defb2ba 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -719,6 +719,67 @@ (t/is (not= (:default-team-id profile1) (:id item1)))))) +(t/deftest get-teams-fetches-organizations-in-one-batch + (let [profile (th/create-profile* 1 {:is-active true}) + organization-team (th/create-team* 1 {:profile-id (:id profile)}) + plain-team (th/create-team* 2 {:profile-id (:id profile)}) + expired-team (th/create-team* 3 {:profile-id (:id profile)}) + organization-id (uuid/random) + calls (atom []) + organization {:id organization-id + :name "Acme" + :slug "acme" + :owner-id (:id profile) + :avatar-bg-url "https://example.com/avatar.svg"} + nitrate-call (fn [_cfg method params] + (swap! calls conj [method params]) + [{:id (:id organization-team) + :is-your-penpot false + :organization organization} + {:id (:id expired-team) + :is-your-penpot false + :organization (assoc organization :expired-license true)}]) + params {::th/type :get-teams + ::rpc/profile-id (:id profile)}] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call] + (let [out (th/command! params) + teams (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count @calls))) + (t/is (= :get-teams-organizations (ffirst @calls))) + (t/is (= #{(:default-team-id profile) + (:id organization-team) + (:id plain-team) + (:id expired-team)} + (-> @calls first second :team-ids set))) + (t/is (= #{(:default-team-id profile) + (:id organization-team) + (:id plain-team)} + (into #{} (map :id) teams))) + (t/is (= organization + (->> teams + (filter #(= (:id organization-team) (:id %))) + first + :organization))))))) + + +(t/deftest get-teams-rejects-invalid-organization-batch-response + (let [profile (th/create-profile* 1 {:is-active true}) + calls (atom []) + params {::th/type :get-teams + ::rpc/profile-id (:id profile)}] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method call-params] + (swap! calls conj [method call-params]) + nil)] + (let [out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (= :nitrate-unavailable (th/ex-type (:error out)))) + (t/is (= 1 (count @calls))) + (t/is (= :get-teams-organizations (ffirst @calls))))))) + + (t/deftest team-deletion-1 (let [profile1 (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile1)}) From 0fd2a9d26ff8961dd7155f3707fc29c1438be845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 10 Aug 2026 13:45:15 +0200 Subject: [PATCH 075/298] :bug: Secure organization invitation creation (#11164) --- backend/src/app/nitrate.clj | 3 + backend/src/app/rpc/management/nitrate.clj | 22 ++- .../rpc_management_nitrate_test.clj | 165 ++++++++++++++---- 3 files changed, 152 insertions(+), 38 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 3fc4fefe68..fab5899b74 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -167,6 +167,9 @@ [:id ::sm/uuid] [:name ::sm/text] [:owner-id ::sm/uuid] + [:logo-id {:optional true} [:maybe ::sm/uuid]] + [:avatar-bg-url {:optional true} [:maybe ::sm/uri]] + [:sso-active {:optional true} [:maybe ::sm/boolean]] [:teams [:vector [:map diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index a87dd74ccb..ca334ffb79 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -488,6 +488,21 @@ RETURNING id, deleted_at;") ;; API: invite-to-organization +(defn- get-invitation-organization + [cfg profile-id organization-id] + (let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]} + (nitrate/call cfg :get-organization-summary {:organization-id organization-id})] + (when-not (= profile-id owner-id) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found")) + {:id id + :name name + :initials (if logo-id "" (d/get-initials name)) + :logo (when logo-id (files/resolve-public-uri logo-id)) + :avatar-bg-url (when-not logo-id avatar-bg-url) + :sso-active (true? sso-active)})) + (sv/defmethod ::invite-to-organization "Invite to organization" {::doc/added "2.15" @@ -495,8 +510,11 @@ RETURNING id, deleted_at;") [:email ::sm/email] [:organization cto/schema:organization-with-avatar]] ::nitrate/sso false} - [cfg params] - (db/tx-run! cfg ti/create-organization-invitation params) + [cfg {profile-id ::rpc/profile-id + :keys [organization] + :as params}] + (let [organization (get-invitation-organization cfg profile-id (:id organization))] + (db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization))) nil) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 769b5ec535..4cb32401cf 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -50,45 +50,138 @@ (t/is (= :authentication-required (th/ex-code (:error out)))))) (t/deftest create-and-update-organization-invitations-audit-props + (let [owner-id-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + audit-mock {:target 'app.loggers.audit/submit :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method params] + (when (= method :get-organization-summary) + {:id (:organization-id params) + :name "Acme" + :owner-id @owner-id-ref + :teams []}))}] + (binding [cf/flags (conj cf/flags :email-verification)] + (let [owner (th/create-profile* 101 {:is-active true}) + invitee (th/create-profile* 102 {:is-active true}) + organization {:id (uuid/random) + :name "Acme" + :initials "AC" + :logo nil + :avatar-bg-url nil} + _ (reset! owner-id-ref (:id owner)) + params {::th/type :invite-to-organization + ::rpc/profile-id (:id owner) + :email (:email invitee) + :organization organization} + create-out (th/management-command! params) + update-out (th/management-command! params) + external-out (th/management-command! (assoc params :email "external@example.com")) + events (mapv second (:call-args-list @audit-mock)) + create-event (first (filter #(= "create-organization-invitation" (:name %)) events)) + update-event (first (filter #(= "update-organization-invitation" (:name %)) events)) + external-event + (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))] + (t/is (th/success? create-out)) + (t/is (th/success? update-out)) + (t/is (th/success? external-out)) + + (doseq [event [create-event update-event]] + (t/is (not (contains? (:props event) :event-origin))) + (t/is (= (str (:id owner)) + (get-in event [:props :user-who-send-invitation]))) + (t/is (= (:id organization) + (get-in event [:props :organization-id]))) + (t/is (= (:email invitee) + (get-in event [:props :member-email]))) + (t/is (= (:id invitee) + (get-in event [:props :member-id])))) + + (t/is (not (contains? (:props external-event) :member-id)))))))) + +(t/deftest invite-to-organization-rejects-non-owner + (let [organization-summary-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (when (= method :get-organization-summary) + @organization-summary-ref))}] + (let [owner (th/create-profile* 103 {:is-active true}) + attacker (th/create-profile* 104 {:is-active true}) + organization-id (uuid/random) + organization {:id organization-id + :name "Trusted Organization" + :initials "TO" + :logo nil + :avatar-bg-url nil} + _ (reset! organization-summary-ref + {:id organization-id + :name "Trusted Organization" + :owner-id (:id owner) + :teams []}) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id attacker) + :email "victim@example.com" + :organization organization})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))) + (t/is (= :object-not-found (th/ex-code (:error out)))) + (t/is (not (:called? @email-mock))))))) + +(t/deftest invite-to-organization-rejects-unknown-organization (with-mocks [email-mock {:target 'app.email/send! :return nil} - audit-mock {:target 'app.loggers.audit/submit :return nil} nitrate-mock {:target 'app.nitrate/call :return nil}] - (binding [cf/flags (conj cf/flags :email-verification)] - (let [owner (th/create-profile* 101 {:is-active true}) - invitee (th/create-profile* 102 {:is-active true}) - organization {:id (uuid/random) - :name "Acme" - :initials "AC" - :logo nil - :avatar-bg-url nil} - params {::th/type :invite-to-organization - ::rpc/profile-id (:id owner) - :email (:email invitee) - :organization organization} - create-out (th/management-command! params) - update-out (th/management-command! params) - external-out (th/management-command! (assoc params :email "external@example.com")) - events (mapv second (:call-args-list @audit-mock)) - create-event (first (filter #(= "create-organization-invitation" (:name %)) events)) - update-event (first (filter #(= "update-organization-invitation" (:name %)) events)) - external-event - (first (filter #(= "external@example.com" (get-in % [:props :member-email])) events))] - (t/is (th/success? create-out)) - (t/is (th/success? update-out)) - (t/is (th/success? external-out)) + (let [profile (th/create-profile* 105 {:is-active true}) + organization-id (uuid/random) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id profile) + :email "victim@example.com" + :organization {:id organization-id + :name "Fabricated Organization" + :initials "FO" + :logo "https://evil.example/logo.png" + :avatar-bg-url nil}})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))) + (t/is (= :object-not-found (th/ex-code (:error out)))) + (t/is (not (:called? @email-mock)))))) - (doseq [event [create-event update-event]] - (t/is (not (contains? (:props event) :event-origin))) - (t/is (= (str (:id owner)) - (get-in event [:props :user-who-send-invitation]))) - (t/is (= (:id organization) - (get-in event [:props :organization-id]))) - (t/is (= (:email invitee) - (get-in event [:props :member-email]))) - (t/is (= (:id invitee) - (get-in event [:props :member-id])))) - - (t/is (not (contains? (:props external-event) :member-id))))))) +(t/deftest invite-to-organization-uses-authoritative-branding + (let [organization-summary-ref (atom nil)] + (with-mocks [email-mock {:target 'app.email/send! :return nil} + nitrate-mock {:target 'app.nitrate/call + :return (fn [_cfg method _params] + (when (= method :get-organization-summary) + @organization-summary-ref))}] + (binding [cf/flags (conj cf/flags :email-verification)] + (let [owner (th/create-profile* 106 {:is-active true}) + organization-id (uuid/random) + logo-id (uuid/random) + _ (reset! organization-summary-ref + {:id organization-id + :name "Trusted Organization" + :owner-id (:id owner) + :logo-id logo-id + :avatar-bg-url "https://trusted.example/avatar.svg" + :sso-active true + :teams []}) + out (th/management-command! {::th/type :invite-to-organization + ::rpc/profile-id (:id owner) + :email "victim@example.com" + :organization {:id organization-id + :name "Fabricated Bank" + :initials "FB" + :logo "https://evil.example/logo.png" + :avatar-bg-url "https://evil.example/avatar.svg" + :sso-active false}}) + email-params (first (:call-args @email-mock)) + organization (:organization email-params)] + (t/is (th/success? out)) + (t/is (= "Trusted Organization" (:name organization))) + (t/is (= "" (:initials organization))) + (t/is (str/ends-with? (:logo organization) + (str "/assets/by-id/" logo-id))) + (t/is (nil? (:avatar-bg-url organization))) + (t/is (true? (:sso-active organization)))))))) (t/deftest get-penpot-version (let [out (th/management-command! {::th/type :get-penpot-version}) From 16e52b0494423c42775386d28467bd5b4ab0f58b Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 10 Aug 2026 15:51:31 +0200 Subject: [PATCH 076/298] :bug: Fix error page logo not visible in dark mode (#11167) --- frontend/src/app/main/ui/static.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 9f87bd9d3d..1708e23cd7 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -69,6 +69,7 @@ // SVG inside deco-before — no class available on the raw element .deco-before svg { position: absolute; + fill: var(--color-foreground-secondary); block-size: 1537px; inline-size: px2rem(80); inset-block-end: 0; @@ -76,6 +77,7 @@ // SVG inside deco-after2 — no class available on the raw element .deco-after2 svg { + fill: var(--color-foreground-secondary); block-size: 1537px; inline-size: px2rem(80); } From 83efa28b121faa5f0cc541afc35e6cfb1c9f806c Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 10 Aug 2026 16:18:58 +0200 Subject: [PATCH 077/298] :bug: Keep comment bubbles from painting over the rulers (#11168) The comments layer lives in the viewport overlays, which are absolutely positioned above the canvas, and the container itself carries a high z-index. A comment bubble panned into the ruler bars therefore painted on top of them, covering the ticks and numbers. Clip the comments container to the area outside the ruler bars while the rulers are visible, the same thing the `clip-handlers` clip path already does so the selection handlers stay off the rulers. Clipping only the comments container leaves the text editing overlay, which shares the viewport overlays, untouched. Fixes #11163. Signed-off-by: Filip Sajdak Co-authored-by: Claude Opus 5 --- frontend/src/app/main/ui/workspace/viewport.cljs | 3 ++- .../app/main/ui/workspace/viewport/comments.cljs | 13 +++++++++++-- .../src/app/main/ui/workspace/viewport_wasm.cljs | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 5f71417631..4814be5042 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -363,7 +363,8 @@ :page-id page-id :file-id file-id :vport vport - :zoom zoom}]) + :zoom zoom + :show-rulers show-rulers?}]) (when picking-color? [:> pixel-overlay/pixel-overlay* {:vport vport diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index 288b537f1b..ca0117bae5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -13,6 +13,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.comments :as cmt] + [app.main.ui.workspace.viewport.rulers :as rulers] [rumext.v2 :as mf])) ;; Pin transform for the bubble's frame so it follows the frame during a drag, @@ -70,7 +71,7 @@ (mf/defc comments-layer* {::mf/wrap [mf/memo]} - [{:keys [vbox vport zoom file-id page-id]}] + [{:keys [vbox vport zoom file-id page-id show-rulers]}] (let [vbox-x (dm/get-prop vbox :x) vbox-y (dm/get-prop vbox :y) vport-w (dm/get-prop vport :width) @@ -114,7 +115,15 @@ {:id "comments" :class (stl/css :workspace-comments-container) :style {:width (dm/str vport-w "px") - :height (dm/str vport-h "px")}} + :height (dm/str vport-h "px") + ;; This layer sits above the canvas, so without clipping the + ;; bubbles paint over the rulers as they pan past them. Keep + ;; them out of the ruler bars, like `clip-handlers` does for + ;; the selection handlers. + :clip-path (when show-rulers + (dm/fmt "inset(%px 0 0 %px)" + rulers/ruler-area-size + rulers/ruler-area-size))}} [:div {:class (stl/css :threads) :style {:transform (dm/fmt "translate(%px, %px)" pos-x pos-y)}} diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b55d8c554c..5a9601502d 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -666,7 +666,8 @@ :page-id page-id :file-id file-id :vport vport - :zoom zoom}]) + :zoom zoom + :show-rulers show-rulers?}]) (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref From d4294bbf1eeec4174c677bb4ba5846271151ee89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 11 Aug 2026 09:13:13 +0200 Subject: [PATCH 078/298] :bug: Fix missing membership check in create-team (#11166) --- backend/src/app/nitrate.clj | 14 ++++++ backend/src/app/rpc/commands/nitrate.clj | 25 +++------- backend/src/app/rpc/commands/teams.clj | 3 ++ backend/test/backend_tests/rpc_team_test.clj | 50 ++++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index fab5899b74..2d7c4e22f7 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -669,3 +669,17 @@ :context {:team-id (:id team) :organization-id (:organization-id params)})) team)) + +(defn assert-membership + "Verifies that the user is a member of the organization. + Raises an exception if the organization doesn't exist or the user is not a member." + [cfg profile-id organization-id] + (let [membership (call cfg :get-organization-membership {:profile-id profile-id + :organization-id organization-id})] + (when-not (:organization-id membership) + (ex/raise :type :validation + :code :organization-does-not-exist)) + + (when-not (:is-member membership) + (ex/raise :type :validation + :code :user-doesnt-belong-organization)))) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index b7143ebe85..af993ea179 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -41,17 +41,6 @@ (ex/raise :type :validation :code :cant-move-default-team)))) -(defn assert-membership [cfg profile-id organization-id] - (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id - :organization-id organization-id})] - (when-not (:organization-id membership) - (ex/raise :type :validation - :code :organization-does-not-exist)) - - (when-not (:is-member membership) - (ex/raise :type :validation - :code :user-doesnt-belong-organization)))) - (def schema:connectivity [:map {:title "nitrate-connectivity"} @@ -335,7 +324,7 @@ (when-not skip-validation (assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave)) - (assert-membership cfg profile-id id) + (nitrate/assert-membership cfg profile-id id) ;; delete only eligible teams (non-protected and without files) (doseq [id deletable-team-ids] @@ -421,7 +410,7 @@ (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) ;; Check moveTeams permission on the source organization (when (contains? cf/flags :admin-console) (let [organization-perms (nitrate/call cfg :get-organization-permissions @@ -491,7 +480,7 @@ (assert-is-owner cfg profile-id team-id) (assert-not-default-team cfg team-id) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (when (contains? cf/flags :admin-console) (let [organization-member-ids-before (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) @@ -575,7 +564,7 @@ ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id organization-id emails]}] (or (when (contains? cf/flags :admin-console) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [emails-array (db/create-array conn "text" emails) profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) email->id (into {} (map (fn [p] [(:email p) (:id p)])) profiles) @@ -603,7 +592,7 @@ (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation :code :insufficient-permissions)) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [organization-members (nitrate/call cfg :get-organization-members {:organization-id organization-id}) organization-member-ids (into #{} organization-members) team-members (db/query cfg :team-profile-rel {:team-id team-id}) @@ -631,7 +620,7 @@ (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) team-member-ids (into #{} (map :profile-id team-members))] ;; Validate requester membership in all organizations before fetching members. - (run! #(assert-membership cfg profile-id %) organization-ids) + (run! #(nitrate/assert-membership cfg profile-id %) organization-ids) (into {} (map (fn [organization-id] @@ -664,7 +653,7 @@ (when-not (or (:is-admin perms) (:is-owner perms)) (ex/raise :type :validation :code :insufficient-permissions)) - (assert-membership cfg profile-id organization-id) + (nitrate/assert-membership cfg profile-id organization-id) (let [{:keys [allows-anybody external-emails]} (get-external-invitation-info cfg team-id organization-id)] {:has-external-invitations (boolean (seq external-emails)) :allows-anybody allows-anybody})) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 0467082b1a..ab2a0628a5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -538,6 +538,9 @@ ;; When creating inside an organization, verify the user has permission to do so. ;; Fail closed: if organization permissions cannot be fetched, deny the operation. (when (and organization-id (contains? cf/flags :admin-console)) + ;; Verify caller is a member of the organization + (nitrate/assert-membership cfg profile-id organization-id) + (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})] (if (nil? organization-perms) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 920defb2ba..7f8fb136e1 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1157,3 +1157,53 @@ :name "My Valid Team"} out (th/command! data)] (t/is (th/success? out))))) + +(t/deftest create-team-in-organization-regression + (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}] + (let [owner (th/create-profile* 401 {:is-active true}) + non-member (th/create-profile* 402 {:is-active true}) + organization-id (uuid/random) + params {::th/type :create-team + ::rpc/profile-id (:id owner) + :name "Test Team" + :organization-id organization-id} + + nitrate-call-fn + (fn [_cfg method p] + (case method + :get-organization-membership + (if (= (:profile-id p) (:id non-member)) + {:organization-id organization-id :is-member false} + {:organization-id organization-id :is-member true}) + + :get-organization-permissions + {:owner-id (:id owner) + :permissions {:create-teams "any"}} + + :set-team-organization + (let [team-id (:team-id p)] + {:id team-id + :name "Test Team" + :organization-id organization-id + :default-project-id (uuid/random)}) + + nil))] + + ;; Non-member should be denied with :user-doesnt-belong-organization + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! (assoc params ::rpc/profile-id (:id non-member)))] + (t/is (not (th/success? out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata))) + (t/is (= :user-doesnt-belong-organization (:code edata)))))) + + ;; Authorized member should succeed + (th/reset-mock! audit-mock) + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call nitrate-call-fn] + (let [out (th/command! params)] + (t/is (th/success? out)) + (let [team (:result out)] + (t/is (uuid? (:id team))) + (t/is (= "Test Team" (:name team))))))))) From 1e6d438257c04f094386f1a9f440fce10134491d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 11 Aug 2026 09:14:29 +0200 Subject: [PATCH 079/298] :bug: Fix SSO failure logging user out instead of showing error page (#11129) * :bug: Fix SSO failure logging user out instead of showing error page * :paperclip: Code review --- backend/src/app/auth/oidc.clj | 54 +++++++--- common/src/app/common/uri.cljc | 30 ++++++ frontend/src/app/main/data/nitrate.cljs | 19 ++++ frontend/src/app/main/ui/routes.cljs | 25 ++++- frontend/src/app/main/ui/static.cljs | 131 +++++++++++++++++------- frontend/src/app/util/dom.cljs | 22 +--- frontend/translations/en.po | 9 ++ frontend/translations/es.po | 9 ++ 8 files changed, 229 insertions(+), 70 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index ecf8b658a5..c32f3bbf75 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -650,6 +650,13 @@ (assoc :query (u/map->query-string params)))] (redirect-response uri)))) +(defn- redirect-with-organization-sso-error + [{:keys [dest-url organization-id]}] + (-> (str (or dest-url (cf/get :public-uri))) + (u/append-query-param :sso-error true) + (u/append-query-param :organization-id organization-id) + (redirect-response))) + (defn- redirect-to-register [cfg info provider] (let [info (assoc info @@ -887,6 +894,39 @@ {::yres/status 200 ::yres/body {:redirect-uri uri}})) +(defn- organization-sso-callback-handler + "Handle the organization-SSO branch of the OIDC callback: state carries + :dest-url — exchange the authorization code with the OIDC provider to + verify authentication actually occurred, then redirect back to dest-url." + [cfg request state code] + (let [dest-url (:dest-url state)] + (try + (let [organization-id (:organization-id state) + sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) + provider (prepare-organization-sso-provider cfg sso) + info (get-info cfg provider state code) + session (session/get-session request) + exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))] + (when (and session organization-id) + (let [props (-> (or (:props session) {}) + (update :sso assoc organization-id exp))] + (session/update-session (::session/manager cfg) (assoc session :props props)))) + (redirect-response dest-url)) + (catch Throwable cause + (let [{:keys [code]} (ex-data cause)] + (binding [l/*context* (errors/request->context request)] + (if (some? code) + (l/warn :hint "organization sso callback failed" + :code code + :message (ex-message cause) + :organization-id (:organization-id state)) + (l/err :hint "unexpected error on organization sso callback" + :organization-id (:organization-id state) + :cause cause)))) + (redirect-with-organization-sso-error + {:dest-url dest-url + :organization-id (:organization-id state)}))))) + (defn- callback-handler [cfg {:keys [params] :as request}] (if-let [error (get params :error)] @@ -898,18 +938,8 @@ ;; Organization SSO flow: state carries :dest-url — exchange the authorization ;; code with the OIDC provider to verify authentication actually occurred. - (if-let [dest-url (:dest-url state)] - (let [organization-id (:organization-id state) - sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) - provider (prepare-organization-sso-provider cfg sso) - info (get-info cfg provider state code) - session (session/get-session request) - exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))] - (when (and session organization-id) - (let [props (-> (or (:props session) {}) - (update :sso assoc organization-id exp))] - (session/update-session (::session/manager cfg) (assoc session :props props)))) - (redirect-response dest-url)) + (if (:dest-url state) + (organization-sso-callback-handler cfg request state code) (let [provider (resolve-provider cfg state) info (get-info cfg provider state code) diff --git a/common/src/app/common/uri.cljc b/common/src/app/common/uri.cljc index b82b5c0e74..c61cc69737 100644 --- a/common/src/app/common/uri.cljc +++ b/common/src/app/common/uri.cljc @@ -67,6 +67,36 @@ path (str path "/"))))) +(defn- update-query-params + "Apply `f` to the query-params map of `url`, returning the updated URL string. + Handles both plain query strings and fragment-based (hash) URLs." + [url f] + (let [transform (fn [parsed] + (update parsed :query + (fn [q] + (-> (query-string->map (or q "")) + f + map->query-string)))) + parsed (uri url) + fragment (:fragment parsed)] + (if (str/blank? fragment) + (str (transform parsed)) + (-> parsed + (assoc :fragment (str (transform (parse fragment)))) + str)))) + +(defn append-query-param + "Return a new URL string with the given query parameter added or replaced. + Handles both plain query strings and fragment-based (hash) URLs." + [url key value] + (update-query-params url #(assoc % key value))) + +(defn remove-query-param + "Return a new URL string with the given query parameter removed. + Handles both plain query strings and fragment-based (hash) URLs." + [url key] + (update-query-params url #(dissoc % key))) + #?(:clj (defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer] (.write writer "#") diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index ff0f48c3e2..3470dcc81d 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -1,5 +1,6 @@ (ns app.main.data.nitrate (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.types.organization :as cto] [app.common.uri :as u] @@ -351,6 +352,24 @@ (rx/empty))))))))))) +(defn retry-organization-sso + "Retries the organization SSO login flow after a failed attempt, reusing + the same check-nitrate-sso RPC used elsewhere to move the user through + the organization's identity provider. Passing `team-id` enables the + backend's non-member short-circuit. Falls back to navigating straight + to `dest-url` when no fresh SSO redirect is needed or available." + [{:keys [team-id organization-id dest-url]}] + (ptk/reify ::retry-organization-sso + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id + :organization-id organization-id + :url dest-url})) + (rx/map (fn [{:keys [redirect-uri]}] + (rt/nav-raw :uri (or redirect-uri dest-url)))) + (rx/catch (fn [_] + (rx/of (rt/nav-raw :uri dest-url)))))))) + (defn- fetch-organizations-allowed "Returns an rx observable of an `organizations-allowed` map (organization-id -> boolean). Organizations where :add-anybody-to-team is permitted are pre-approved; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 52d7554e1e..5b5c985043 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -123,6 +123,29 @@ (errors/on-error cause)))) (st/emit! (rt/navigated match send-event-info?))))) +(defn- handle-sso-error-and-navigate + "Check if the current route has an SSO error marker. If so, assign an + exception with type :sso-error and organization-id from query params, + and deliberately do NOT proceed with normal navigation: emitting + `rt/navigated` would clear the exception that was just assigned. + Otherwise, delegate to `check-sso-and-navigate`." + [match send-event-info? url] + (let [route-name (name (get-in match [:data :name])) + sso-error? (some? (get-in match [:query-params :sso-error])) + organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) + team-id-str (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes + team-id (some-> team-id-str uuid/parse*) + is-workspace? (str/starts-with? route-name "workspace") + is-dashboard? (str/starts-with? route-name "dashboard")] + (if sso-error? + (st/emit! (rt/assign-exception {:type :sso-error + :organization-id organization-id + :team-id team-id + :is-workspace is-workspace? + :is-dashboard is-dashboard?})) + (check-sso-and-navigate match send-event-info? url)))) + (defn on-navigate [router path send-event-info?] (let [location (.-location js/document) @@ -138,7 +161,7 @@ (st/emit! (rt/assign-exception {:type :not-found})) (some? match) - (check-sso-and-navigate match send-event-info? (rt/get-current-href)) + (handle-sso-error-and-navigate match send-event-info? (rt/get-current-href)) :else ;; We just recheck with an additional profile request; this diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9e6dfa68f0..9232c6598c 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -13,6 +13,7 @@ [app.common.uuid :as uuid] [app.main.data.auth :refer [is-authenticated?]] [app.main.data.common :as dcm] + [app.main.data.nitrate :as dnt] [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] @@ -433,43 +434,6 @@ (rx/of default) (rx/throw cause))))))) -(mf/defc exception-section* - {::mf/private true} - [{:keys [data] :as props}] - (let [type (get data :type) - cause (get data ::errors/instance) - - report (mf/with-memo [cause] - (when (ex/exception? cause) - (errors/generate-report cause))) - - props (mf/spread-props props {:report report})] - - (mf/with-effect [report type cause] - (when (and (ex/exception? cause) - (not (contains? #{:not-found :authentication} type))) - (errors/submit-report :event-name "exception-page" - :report report - :hint (ex/get-hint cause)))) - - (case type - :not-found - [:> not-found* {}] - - :authentication - [:> not-found* {}] - - :bad-gateway - [:> bad-gateway* props] - - :service-unavailable - [:> service-unavailable*] - - :nitrate-unavailable - [:> nitrate-unavailable*] - - [:> internal-error* props]))) - (mf/defc context-wrapper* [{:keys [is-workspace is-dashboard is-viewer profile children]}] [:* @@ -515,6 +479,99 @@ children]) +(mf/defc sso-error-section* + "Shown in place of the dashboard/workspace (same static skeleton and + `request-dialog*` used by the no-permission dialogs) when the organization + SSO exchange with the identity provider fails." + {::mf/private true} + [{:keys [organization-id team-id profile is-workspace is-dashboard]}] + (let [clean-url + (mf/with-memo [] + (-> (rt/get-current-href) + (dom/remove-query-param :sso-error) + (dom/remove-query-param :organization-id))) + + _ (mf/with-effect [] + ;; Consume the marker once: scrub it from the URL bar so a + ;; browser refresh doesn't keep re-showing this dialog. + (dom/replace-history-state! clean-url)) + + on-close + (mf/use-fn + (mf/deps profile) + (fn [] + ;; Land on the user's own default team + (st/emit! (rt/assign-exception nil) + (dcm/go-to-dashboard-recent :team-id (:default-team-id profile))))) + + on-retry + (mf/use-fn + (mf/deps organization-id team-id clean-url) + (fn [] + (st/emit! (rt/assign-exception nil)) + (if (or team-id organization-id) + ;; Retry with team-id and/or organization-id to trigger SSO check + (st/emit! (dnt/retry-organization-sso {:team-id team-id + :organization-id organization-id + :dest-url clean-url})) + ;; Fallback: just navigate to clean URL + (st/emit! (rt/nav-raw :uri clean-url)))))] + + [:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace)) + :is-workspace is-workspace + :profile profile} + [:> request-dialog* {:title (tr "labels.sso-error.title") + :content [(tr "labels.sso-error.desc-message")] + :button-text (tr "labels.sso-error.retry") + :on-button-click on-retry + :cancel-text (tr "not-found.no-permission.go-dashboard") + :on-close on-close}]])) + +(mf/defc exception-section* + {::mf/private true} + [{:keys [data] :as props}] + (let [type (get data :type) + cause (get data ::errors/instance) + organization-id (get data :organization-id) + + report (mf/with-memo [cause] + (when (ex/exception? cause) + (errors/generate-report cause))) + + props (mf/spread-props props {:report report})] + + (mf/with-effect [report type cause] + (when (and (ex/exception? cause) + (not (contains? #{:not-found :authentication} type))) + (errors/submit-report :event-name "exception-page" + :report report + :hint (ex/get-hint cause)))) + + (case type + :not-found + [:> not-found* {}] + + :authentication + [:> not-found* {}] + + :bad-gateway + [:> bad-gateway* props] + + :service-unavailable + [:> service-unavailable*] + + :nitrate-unavailable + [:> nitrate-unavailable*] + + :sso-error + [:> sso-error-section* {:organization-id organization-id + :team-id (get data :team-id) + :profile (mf/deref refs/profile) + :is-workspace (get data :is-workspace false) + :is-dashboard (get data :is-dashboard true)}] + + [:> internal-error* props]))) + (mf/defc exception-page* [{:keys [data route] :as props}] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 6bdc663f42..6a0f514018 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -875,35 +875,17 @@ [url] (.replaceState (.-history globals/window) nil "" url)) -(defn- update-query-params - "Apply `f` to the query-params map of `url`, returning the updated URL string. - Handles both plain query strings and fragment-based (hash) URLs." - [url f] - (let [transform (fn [parsed] - (update parsed :query - (fn [q] - (-> (u/query-string->map (or q "")) - f - u/map->query-string)))) - parsed (u/uri url) - fragment (:fragment parsed)] - (if (str/blank? fragment) - (str (transform parsed)) - (-> parsed - (assoc :fragment (str (transform (u/parse fragment)))) - str)))) - (defn append-query-param "Return a new URL string with the given query parameter added or replaced. Handles both plain query strings and fragment-based (hash) URLs." [url key value] - (update-query-params url #(assoc % key value))) + (u/append-query-param url key value)) (defn remove-query-param "Return a new URL string with the given query parameter removed. Handles both plain query strings and fragment-based (hash) URLs." [url key] - (update-query-params url #(dissoc % key))) + (u/remove-query-param url key)) (defn reload-current-window ([] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b9621af9e4..c9dcc0f1dd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10339,3 +10339,12 @@ msgstr "Click to close the path" msgid "notifications.invitation-canceled" msgstr "This invitation is no longer available." + +msgid "labels.sso-error.title" +msgstr "We couldn't sign you in to your organization" + +msgid "labels.sso-error.desc-message" +msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected." + +msgid "labels.sso-error.retry" +msgstr "Try again" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 51165035e2..1fb282b4e1 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9989,3 +9989,12 @@ msgstr "Pulsar para cerrar la ruta" msgid "notifications.invitation-canceled" msgstr "Esta invitación ya no está disponible." + +msgid "labels.sso-error.title" +msgstr "No pudimos iniciar sesión en tu organización" + +msgid "labels.sso-error.desc-message" +msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada." + +msgid "labels.sso-error.retry" +msgstr "Intentar de nuevo" From c4d1a1bc94a1bdf0ba4543663fecb52970312fab Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 11 Aug 2026 13:03:29 +0200 Subject: [PATCH 080/298] :bug: Fix node deleting (#11126) * :bug: Fix delete path node * :bug: Fix typography on shortcuts list --- frontend/src/app/main/data/shortcuts.cljs | 35 +++++++++++-------- .../main/data/workspace/path/shortcuts.cljs | 8 ++++- frontend/src/app/main/ui/shortcuts.scss | 3 +- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 19a473ae54..fa5e86e547 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -204,20 +204,27 @@ (defn- bind! [shortcuts] - (->> shortcuts - (remove #(:disabled (second %))) - (run! (fn [[key {:keys [command fn type overwrite]}]] - (let [callback (wrap-cb key fn) - commands (if (vector? command) - (into-array command) - #js [command])] - (if (vector? type) - (do (mousetrap/bind commands callback (nth type 0) overwrite) - (mousetrap/bind commands callback (nth type 1) overwrite)) - (let [undefined (js* "(void 0)")] - (if type - (mousetrap/bind commands callback type overwrite) - (mousetrap/bind commands callback undefined overwrite))))))))) + (let [entries (remove #(:disabled (second %)) shortcuts) + bind-fn (fn [[key {:keys [command fn type overwrite]}]] + (let [callback (wrap-cb key fn) + commands (if (vector? command) + (into-array command) + #js [command])] + (if (vector? type) + (do (mousetrap/bind commands callback (nth type 0) overwrite) + (mousetrap/bind commands callback (nth type 1) overwrite)) + (let [undefined (js* "(void 0)")] + (if type + (mousetrap/bind commands callback type overwrite) + (mousetrap/bind commands callback undefined overwrite))))))] + ;; Bind non-overwrite entries first so that entries flagged with + ;; `:overwrite` are bound last and can reliably splice out the + ;; colliding callbacks bound earlier (mousetrap's overwrite only + ;; removes callbacks that were already registered for the same + ;; combo). Map iteration order is hash-based, so we must force the + ;; order explicitly. + (run! bind-fn (remove (comp :overwrite second) entries)) + (run! bind-fn (filter (comp :overwrite second) entries)))) (defn- reset! ([] diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index fe35b33e40..f4ed3dd8f3 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -37,6 +37,7 @@ :command "p" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/change-edit-mode :draw))} :add-node {:tooltip (ds/shift "+") @@ -49,7 +50,9 @@ :command ["del" "backspace"] :subsections [:path-editor] :section [:workspace] - :fn #(st/emit! (drp/remove-node))} + :overwrite true + :fn #(st/emit! + (drp/remove-node))} :merge-nodes {:tooltip (ds/meta "J") :command (ds/c-mod "j") @@ -67,6 +70,7 @@ :command "k" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/separate-nodes))} :make-corner {:tooltip "X" @@ -79,6 +83,7 @@ :command "c" :subsections [:path-editor] :section [:workspace] + :overwrite true :fn #(st/emit! (drp/make-curve))} :snap-nodes {:tooltip (ds/meta "'") @@ -91,6 +96,7 @@ :escape {:tooltip (ds/esc) :command ["escape" "enter" "v"] :section [:workspace] + :overwrite true :fn #(st/emit! (esc-pressed))} :undo {:tooltip (ds/meta "Z") diff --git a/frontend/src/app/main/ui/shortcuts.scss b/frontend/src/app/main/ui/shortcuts.scss index 7d582e4e71..b92405e5c4 100644 --- a/frontend/src/app/main/ui/shortcuts.scss +++ b/frontend/src/app/main/ui/shortcuts.scss @@ -21,7 +21,7 @@ .section-title, .subsection-title { - @include t.use-typography("title-small"); + @include t.use-typography("headline-small"); display: flex; align-items: center; @@ -43,6 +43,7 @@ } .subsection-title { + block-size: $sz-32; text-transform: none; padding-inline-start: var(--sp-m); } From d7daefafe21f1623413f0c94046e030e6085bbb1 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 11 Aug 2026 13:05:47 +0200 Subject: [PATCH 081/298] :bug: Fix select shape after enter path edition (#11205) --- .../main/data/workspace/path/shortcuts.cljs | 13 +++- .../data/workspace_path_edition_test.cljs | 78 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 frontend/test/frontend_tests/data/workspace_path_edition_test.cljs diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index f4ed3dd8f3..71403d731f 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -94,7 +94,18 @@ :fn #(st/emit! (drp/toggle-snap))} :escape {:tooltip (ds/esc) - :command ["escape" "enter" "v"] + :command ["escape" "v"] + :section [:workspace] + :fn #(st/emit! (esc-pressed))} + + ;; Reuses the `:start-editing` key (instead of adding "enter" to + ;; the `:escape` command above) so that merging this shortcut set + ;; on top of the base workspace shortcuts (see `dsc/push-shortcuts`) + ;; deterministically replaces the workspace's `enter` binding + ;; (which enters path edit mode) instead of both ending up bound + ;; to the same physical key at once. + :start-editing {:tooltip (ds/enter) + :command "enter" :section [:workspace] :overwrite true :fn #(st/emit! (esc-pressed))} diff --git a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs new file mode 100644 index 0000000000..4cfabcdce2 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs @@ -0,0 +1,78 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.workspace-path-edition-test + (:require + [app.common.data :as d] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.main.data.shortcuts :as dsc] + [app.main.data.workspace :as dw] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shortcuts :as wsc] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before thp/reset-idmap!}) + +(defn- enter-command? + [command] + (if (vector? command) + (some #(= % "enter") command) + (= command "enter"))) + +(t/deftest test-enter-key-is-bound-once-while-path-editing + ;; Regression test for the physical "enter" key ending up bound to + ;; two different shortcuts at once while path editing is active: one + ;; that (re)enters edition mode and one that exits it. `push-shortcuts` + ;; merges shortcut groups by map key (see `app.main.data.shortcuts`), + ;; not by physical key/command, so two shortcuts under different keys + ;; that both claim "enter" survive the merge and both would fire on a + ;; single keypress, breaking the toggle. + (let [file (cthf/sample-file :file1) + store (ths/setup-store file)] + (ptk/emit! store (dsc/push-shortcuts ::workspace wsc/shortcuts :workspace)) + (ptk/emit! store (dsc/push-shortcuts ::path psc/shortcuts :workspace :merge-shortcuts :auto)) + (let [effective (get-in @store [:shortcuts ::path]) + matches (->> effective + (filter (fn [[_ sc]] (enter-command? (:command sc)))) + (map first))] + (t/is (= 1 (count matches)) + (str "expected exactly one shortcut bound to \"enter\" while path editing, got " matches))))) + +(defn- run-scenario + [shape-type] + (let [file (-> (cthf/sample-file :file1) + (cths/add-sample-shape :test-shape :type shape-type)) + shape-id (:id (cths/get-shape file :test-shape)) + store (ths/setup-store file)] + ;; Select the shape, then reproduce what a physical Enter keypress + ;; now dispatches at each step: `start-editing-selected` to enter + ;; path edition mode, `esc-pressed` (-> :interrupt) to exit it, and + ;; `start-editing-selected` again to re-enter. + (ptk/emit! store (dws/select-shapes (d/ordered-set shape-id))) + + (ptk/emit! store (dw/start-editing-selected)) + (t/is (= shape-id (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to enter path edition mode")) + + (ptk/emit! store (psc/esc-pressed)) + (t/is (nil? (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to exit path edition mode")) + (t/is (= #{shape-id} (get-in @store [:workspace-local :selected])) + (str "expected " (name shape-type) " to remain selected after exiting path edition mode")) + + (ptk/emit! store (dw/start-editing-selected)) + (t/is (= shape-id (get-in @store [:workspace-local :edition])) + (str "expected " (name shape-type) " to enter path edition mode again")))) + +(t/deftest test-enter-toggles-path-editing-mode + (doseq [shape-type [:rect :circle :path :image]] + (run-scenario shape-type))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e0c06b4a08..7c0c5f0889 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -21,6 +21,7 @@ [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-pages-test] + [frontend-tests.data.workspace-path-edition-test] [frontend-tests.data.workspace-reflow-test] [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] @@ -113,6 +114,7 @@ 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test 'frontend-tests.data.workspace-pages-test + 'frontend-tests.data.workspace-path-edition-test 'frontend-tests.data.workspace-reflow-test 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-texts-test From 69ef7e86cd23d2a93062f9d8dc8b1ee4ab075e71 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 11 Aug 2026 13:06:30 +0200 Subject: [PATCH 082/298] :bug: Fix colorpicker z-index (#11207) --- .../src/app/main/ui/workspace/colorpicker.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index b9fe67d6f5..d69a25f651 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -12,16 +12,20 @@ @use "refactor/basic-rules.scss" as *; .colorpicker-tooltip { - @extend %modal-background; - --colorpicker-width: #{$sz-284}; - left: calc(10 * px2rem(140)); - padding: var(--sp-m); - width: var(--colorpicker-width); - overflow: auto; + position: absolute; display: flex; flex-direction: column; + inset-inline-start: calc(10 * px2rem(140)); + inline-size: var(--colorpicker-width); + padding: var(--sp-m); + border-radius: $br-8; + box-shadow: 0 0 12px 0 var(--color-shadow-dark); + color: var(--color-foreground-primary); + background-color: var(--color-background-primary); + z-index: var(--z-index-set); + overflow: auto; } .colorpicker { From 4a1d6e6d573a10e06f694efcc4258f17da2cf196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 11 Aug 2026 13:10:44 +0200 Subject: [PATCH 083/298] :bug: Fix creating minimal path shapes (#11210) --- common/src/app/common/types/shape.cljc | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 2a3af1ffeb..76f1360ea1 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -657,10 +657,15 @@ [type] (let [type (if (= type :curve) :path type) attrs (get-minimal-shape type) - attrs (cond-> attrs - (and (not= :path type) - (not= :bool type)) - (-> (assoc :x 0) + attrs (if (or (= :path type) + (= :bool type)) + (-> attrs + (assoc :x nil) + (assoc :y nil) + (assoc :width nil) + (assoc :height nil)) + (-> attrs + (assoc :x 0) (assoc :y 0) (assoc :width 0.01) (assoc :height 0.01))) From 02c31e734892aadc3b4165230ab7325798778b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Tue, 11 Aug 2026 13:30:04 +0200 Subject: [PATCH 084/298] :bug: Cache Nitrate SSO checks during navigation (#11209) --- frontend/src/app/main/ui/routes.cljs | 52 +++++++++--- frontend/test/frontend_tests/runner.cljs | 2 + .../test/frontend_tests/ui/routes_test.cljs | 85 +++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 frontend/test/frontend_tests/ui/routes_test.cljs diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 5b5c985043..cc0d07537d 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.routes (:require [app.common.data.macros :as dm] + [app.common.time :as ct] [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] @@ -21,6 +22,12 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) +(def ^:private sso-authorization-max-age-ms + (* 5 60 1000)) + +(defonce ^:private sso-authorization-cache + (atom {})) + (def routes [["/auth" ["/login" :auth-login] @@ -102,26 +109,43 @@ "Authorization filter for dashboard and workspace routes. Checks if the team being navigated to has an organization with SSO active. If so, calls :check-nitrate-sso and either proceeds with navigation - or redirects to the SSO provider URL." + or redirects to the SSO provider URL. Successful checks are cached for five + minutes per profile and team; redirect results are never cached." [match send-event-info? url] - (let [route-name (name (get-in match [:data :name])) - relevant? (and (contains? cf/flags :admin-console) - (or (str/starts-with? route-name "dashboard") - (str/starts-with? route-name "workspace"))) - team-id-str (when relevant? - (or (get-in match [:query-params :team-id]) - (get-in match [:params :path :team-id]))) - team-id (some-> team-id-str uuid/parse*)] - (if (some? team-id) + (let [route-name (name (get-in match [:data :name])) + relevant? (and (contains? cf/flags :admin-console) + (or (str/starts-with? route-name "dashboard") + (str/starts-with? route-name "workspace"))) + team-id-str (when relevant? + (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id]))) + team-id (some-> team-id-str uuid/parse*) + profile-id (get-in @st/state [:profile :id]) + cache-key [profile-id team-id] + authorized-at (get @sso-authorization-cache cache-key) + cache-valid? (and (some? authorized-at) + (< (ct/diff-ms authorized-at (ct/now)) + sso-authorization-max-age-ms)) + navigate #(st/emit! (rt/navigated match send-event-info?))] + (cond + (nil? team-id) + (navigate) + + cache-valid? + (navigate) + + :else (->> (rp/cmd! :check-nitrate-sso {:team-id team-id :url url}) (rx/subs! (fn [{:keys [authorized redirect-uri]}] (if authorized - (st/emit! (rt/navigated match send-event-info?)) - (when redirect-uri (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) + (do + (swap! sso-authorization-cache assoc cache-key (ct/now)) + (navigate)) + (when redirect-uri + (st/emit! (rt/nav-raw :uri (str redirect-uri)))))) (fn [cause] - (errors/on-error cause)))) - (st/emit! (rt/navigated match send-event-info?))))) + (errors/on-error cause))))))) (defn- handle-sso-error-and-navigate "Check if the current route has an SSO error marker. If so, assign an diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 7c0c5f0889..a29132a22e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -72,6 +72,7 @@ [frontend-tests.ui.gradient-handlers-test] [frontend-tests.ui.layout-container-multiple-test] [frontend-tests.ui.measures-menu-props-test] + [frontend-tests.ui.routes-test] [frontend-tests.ui.settings-password-schema-test] [frontend-tests.ui.settings-shortcuts-test] [frontend-tests.util-clipboard-test] @@ -163,6 +164,7 @@ 'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.measures-menu-props-test + 'frontend-tests.ui.routes-test 'frontend-tests.render-dimensions-test 'frontend-tests.text-editor-paste-guard-test 'frontend-tests.ui.settings-password-schema-test diff --git a/frontend/test/frontend_tests/ui/routes_test.cljs b/frontend/test/frontend_tests/ui/routes_test.cljs new file mode 100644 index 0000000000..3ebb7edbbe --- /dev/null +++ b/frontend/test/frontend_tests/ui/routes_test.cljs @@ -0,0 +1,85 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.ui.routes-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.routes :as routes] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(defn- workspace-match + [team-id] + {:data {:name :workspace} + :params {:path {}} + :query-params {:team-id (str team-id)}}) + +(t/deftest sso-check-is-cached-for-five-minutes + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0) + events (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [command params] + (t/is (= :check-nitrate-sso command)) + (t/is (= team-id (:team-id params))) + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 4 :seconds 59})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 1 @rpc-calls)) + (t/is (= 2 (count @events)))))) + +(t/deftest sso-check-is-refreshed-after-five-minutes + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0)] + (with-redefs [cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! mock/noop] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 5})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 2 @rpc-calls))))) + +(t/deftest sso-redirect-result-is-not-cached + (let [team-id (uuid/next) + match (workspace-match team-id) + rpc-calls (atom 0) + events (atom [])] + (with-redefs [cf/flags (conj cf/flags :admin-console) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + + (t/is (= 2 @rpc-calls)) + (t/is (= 2 (count @events)))))) From 53985dc630b0c39ea2d20f9d807af1b2d44c1d95 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 11 Aug 2026 13:49:48 +0200 Subject: [PATCH 085/298] :bug: Fix setting dark theme on onboarding (#11212) * :bug: Fix setting dark theme on onboarding * :tada: Add test --- backend/src/app/rpc/commands/auth.clj | 2 +- .../register/verify-token-email-verified.json | 28 ++++++++++++ frontend/playwright/ui/pages/RegisterPage.js | 36 +++++++++++++++ .../ui/specs/email-verification.spec.js | 45 +++++++++++++++++++ frontend/src/app/main/data/profile.cljs | 40 +++++++++-------- 5 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 frontend/playwright/data/register/verify-token-email-verified.json create mode 100644 frontend/playwright/ui/specs/email-verification.spec.js diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 07460633c4..78d2ac45c4 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -367,7 +367,7 @@ email (str/lower email) fullname (d/normalize-string (:fullname params)) locale (d/normalize-string locale) - theme (d/normalize-string theme) + theme (some-> theme d/normalize-string not-empty) photo-id (some->> (or (:oidc/picture props) (:google/picture props) diff --git a/frontend/playwright/data/register/verify-token-email-verified.json b/frontend/playwright/data/register/verify-token-email-verified.json new file mode 100644 index 0000000000..347bc4057c --- /dev/null +++ b/frontend/playwright/data/register/verify-token-email-verified.json @@ -0,0 +1,28 @@ +{ + "~:iss": "~:verify-email", + "~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:profile": { + "~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b", + "~:email": "foo@example.com", + "~:fullname": "Princesa Leia", + "~:auth-backend": "penpot", + "~:is-active": true, + "~:is-demo": false, + "~:is-muted": false, + "~:is-blocked": false, + "~:theme": "", + "~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d", + "~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b", + "~:created-at": "~m1713533116365", + "~:modified-at": "~m1713533116365", + "~:props": { + "~:nudge": { + "~:big": 10, + "~:small": 1 + }, + "~:v2-info-shown": true, + "~:viewed-tutorial?": false, + "~:viewed-walkthrough?": false + } + } +} diff --git a/frontend/playwright/ui/pages/RegisterPage.js b/frontend/playwright/ui/pages/RegisterPage.js index 8d3633e678..ef43f56469 100644 --- a/frontend/playwright/ui/pages/RegisterPage.js +++ b/frontend/playwright/ui/pages/RegisterPage.js @@ -29,6 +29,42 @@ export class RegisterPage extends BasePage { ); } + /** + * Mocks a successful email-verification token exchange (the link the + * user clicks from the verification email) and every RPC the dashboard + * needs to render right after landing on it, so the flow can be + * exercised end-to-end without a real backend. + */ + async setupEmailVerificationSuccess() { + await this.mockConfigFlags(["disable-onboarding"]); + await this.mockRPC( + "verify-token", + "register/verify-token-email-verified.json", + ); + await this.mockRPCs({ + "get-teams": "logged-in-user/get-teams-default.json", + "get-font-variants?team-id=*": + "logged-in-user/get-font-variants-empty.json", + "get-projects?team-id=*": "logged-in-user/get-projects-default.json", + "get-team-members?team-id=*": + "logged-in-user/get-team-members-your-penpot.json", + "get-team-users?team-id=*": + "logged-in-user/get-team-users-single-user.json", + "get-unread-comment-threads?team-id=*": + "logged-in-user/get-team-users-single-user.json", + "get-team-recent-files?team-id=*": + "logged-in-user/get-team-recent-files-empty.json", + "get-profiles-for-file-comments": + "logged-in-user/get-profiles-for-file-comments-empty.json", + "get-builtin-templates": + "logged-in-user/get-built-in-templates-empty.json", + }); + } + + async goToVerifyToken(token = "verify-email-token") { + await this.page.goto(`/#/auth/verify-token?token=${token}`); + } + static async init(page) { await BasePage.init(page); } diff --git a/frontend/playwright/ui/specs/email-verification.spec.js b/frontend/playwright/ui/specs/email-verification.spec.js new file mode 100644 index 0000000000..6be76653ce --- /dev/null +++ b/frontend/playwright/ui/specs/email-verification.spec.js @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { RegisterPage } from "../pages/RegisterPage"; + +// Regression test for the bug where a freshly verified account (whose +// profile never had a theme persisted) ended up with an empty string as +// its theme instead of falling back to the dark default: the workspace +// switched to light mode and Settings > UI Theme showed a blank field. + +test.beforeEach(async ({ page }) => { + await RegisterPage.initWithLoggedOutUser(page); +}); + +test.describe("Email verification", () => { + test("Newly verified account defaults to the dark theme", async ({ + page, + }) => { + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); + + await registerPage.goToVerifyToken(); + await page.waitForURL("**/dashboard/**"); + + // `default` is the body class applied for dark theme, `light` for + // light theme (see app.util.theme/set-color-scheme). + await expect(page.locator("body")).toHaveClass(/default/); + await expect(page.locator("body")).not.toHaveClass(/light/); + }); + + test("Settings > UI Theme shows Penpot Dark (default) selected, not blank", async ({ + page, + }) => { + const registerPage = new RegisterPage(page); + await registerPage.setupEmailVerificationSuccess(); + + await registerPage.goToVerifyToken(); + await page.waitForURL("**/dashboard/**"); + + await page.goto("/#/settings/options"); + + // The language select is the first combobox on the page, the theme + // select is the second one. + const themeSelect = page.getByRole("combobox").nth(1); + await expect(themeSelect).toHaveText("Penpot Dark (default)"); + }); +}); diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 93a948c119..3f1237c2b7 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -43,28 +43,30 @@ (defn set-profile "Initialize profile state, only logged-in profile data should be passed to this event" - [{:keys [id] :as profile}] - (ptk/reify ::set-profile - IDeref - (-deref [_] profile) + [profile] + (let [profile (update profile :theme not-empty) + id (:id profile)] + (ptk/reify ::set-profile + IDeref + (-deref [_] profile) - ptk/UpdateEvent - (update [_ state] - (-> state - (assoc :profile-id id) - (assoc :profile profile))) + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc :profile-id id) + (assoc :profile profile))) - ptk/WatchEvent - (watch [_ state _] - (let [profile (:profile state)] - (->> (rx/from (i18n/set-locale (:lang profile))) - (rx/ignore)))) + ptk/WatchEvent + (watch [_ state _] + (let [profile (:profile state)] + (->> (rx/from (i18n/set-locale (:lang profile))) + (rx/ignore)))) - ptk/EffectEvent - (effect [_ state _] - (let [profile (:profile state)] - (swap! storage/user assoc :profile profile) - (plugins.register/init))))) + ptk/EffectEvent + (effect [_ state _] + (let [profile (:profile state)] + (swap! storage/user assoc :profile profile) + (plugins.register/init)))))) (def profile-fetched? (ptk/type? ::profile-fetched)) From 044d7ac15f91b23cef0800367dad4376485a62d3 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Tue, 11 Aug 2026 14:29:29 +0200 Subject: [PATCH 086/298] :recycle: Update colorpicker scss file (#11208) --- .../app/main/ui/workspace/colorpicker.cljs | 33 ++- .../app/main/ui/workspace/colorpicker.scss | 274 +++++++++--------- 2 files changed, 152 insertions(+), 155 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 0498e2d656..b468b71038 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -28,6 +28,8 @@ [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]] [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] @@ -433,10 +435,12 @@ (when (and (not= selected-mode :image) (= color-style :direct-color)) - [:button {:class (stl/css-case :picker-btn true - :selected picking-color?) - :on-click handle-click-picker} - deprecated-icon/picker]) + [:> icon-button* {:icon i/picker + :variant "ghost" + :aria-label (tr "workspace.colorpicker.color-picker") + :aria-pressed picking-color? + :class (stl/css :picker-btn) + :on-click handle-click-picker}]) (when (= color-style :token-color) [:div {:class (stl/css :token-color-title)} @@ -467,7 +471,8 @@ [:div {:class (stl/css :select-image)} [:div {:class (stl/css :content)} (when (:image current-color) - [:img {:src uri}])] + [:img {:src uri + :class (stl/css :content-image)}])] (when (some? (:image current-color)) [:div {:class (stl/css :checkbox-option)} @@ -481,11 +486,10 @@ :id "keep-aspect-ratio" :checked keep-aspect-ratio? :on-change handle-change-keep-aspect-ratio}]]]) - [:button - {:class (stl/css :choose-image) - :title (tr "media.choose-image") - :aria-label (tr "media.choose-image") - :on-click on-fill-image-click} + + [:> button* {:class (stl/css :choose-image) + :variant "secondary" + :on-click on-fill-image-click} (tr "media.choose-image") [:& file-uploader {:input-id "fill-image-upload" @@ -554,11 +558,10 @@ :color-origin color-origin}])] (when (fn? on-accept) [:div {:class (stl/css :actions)} - [:button {:class (stl/css-case - :accept-color true - :btn-disabled disabled-color-accept?) - :on-click on-color-accept - :disabled disabled-color-accept?} + [:> button* {:class (stl/css :accept-color) + :variant "primary" + :on-click on-color-accept + :disabled disabled-color-accept?} (tr "workspace.libraries.colors.save-color")]])])) (defn calculate-position diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index b9fe67d6f5..366eecbdd7 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -5,23 +5,25 @@ // Copyright (c) KALEIDOS INC Sucursal en España SL @use "ds/typography.scss" as t; -@use "ds/spacing"; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; -@use "refactor/basic-rules.scss" as *; .colorpicker-tooltip { - @extend %modal-background; - --colorpicker-width: #{$sz-284}; - left: calc(10 * px2rem(140)); - padding: var(--sp-m); - width: var(--colorpicker-width); - overflow: auto; + position: absolute; display: flex; flex-direction: column; + inset-inline-start: calc(10 * px2rem(140)); + inline-size: var(--colorpicker-width); + padding: var(--sp-m); + border-radius: $br-8; + box-shadow: 0 0 12px 0 var(--color-shadow-dark); + color: var(--color-foreground-primary); + background-color: var(--color-background-primary); + z-index: var(--z-index-set); + overflow: auto; } .colorpicker { @@ -34,7 +36,7 @@ align-items: flex-start; flex-direction: row-reverse; justify-content: space-between; - height: $sz-40; + block-size: $sz-40; } .top-actions-right { @@ -42,114 +44,14 @@ gap: var(--sp-s); } -.opacity-input-wrapper { - @extend %input-element; - @include t.use-typography("body-small"); - - width: px2rem(68); -} - -// TODO: change to DS button component -.picker-btn { - display: flex; - justify-content: center; - align-items: center; - background: none; - cursor: pointer; - background-color: transparent; - border: $b-1 solid transparent; - height: var(--sp-xl); - width: var(--sp-xl); - border-radius: $br-4; - padding: 0; - margin-top: var(--sp-xs); - - svg { - @extend %button-icon; - - stroke: var(--button-tertiary-foreground-color-rest); - } - - &:hover { - svg { - stroke: var(--button-tertiary-foreground-color-focus); - } - } - - &:focus, - &:focus-visible { - outline: none; - - svg { - stroke: var(--button-secondary-foreground-color-hover); - } - } - - &:active { - outline: none; - border: $b-1 solid transparent; - - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } - - &.selected { - svg { - stroke: var(--button-tertiary-foreground-color-active); - } - } -} - -.gradient-buttons { - display: flex; - align-items: center; - gap: var(--sp-s); -} - -.gradient-btn { - @extend %button-tertiary; - - height: var(--sp-xl); - width: var(--sp-xl); - border-radius: $br-4; - border: $b-2 solid transparent; - - &:hover { - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - -.linear-gradient-btn { - background: linear-gradient(180deg, var(--color-foreground-secondary), transparent); - - &.selected { - background: linear-gradient(to bottom, rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - -.radial-gradient-btn { - background: radial-gradient(transparent, var(--color-foreground-secondary)); - - &.selected { - background: radial-gradient(rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); - border: $b-2 solid var(--colorpicker-details-color-selected); - } -} - .actions { display: flex; gap: var(--sp-xs); } .accept-color { - @include t.use-typography("headline-small"); - @extend %button-primary; - - width: 100%; - height: var(--sp-xxxl); - margin-top: var(--sp-s); + justify-content: center; + inline-size: 100%; } .picker-detail-wrapper { @@ -157,17 +59,18 @@ justify-content: center; align-items: center; position: relative; - margin: var(--sp-m) 0 var(--sp-s) 0; + margin-block: var(--sp-m) var(--sp-s); + margin-inline: 0; } .center-circle { - width: var(--sp-xxl); - height: var(--sp-xxl); - border: $b-2 solid var(--colorpicker-details-color); - border-radius: $br-circle; position: absolute; - left: 50%; - top: 50%; + inset-inline-start: 50%; + inset-block-start: 50%; + inline-size: var(--sp-xxl); + block-size: var(--sp-xxl); + border: $b-2 solid var(--color-background-quaternary); + border-radius: $br-circle; transform: translate(calc(-1 * var(--sp-m)), calc(-1 * var(--sp-m))); } @@ -177,46 +80,137 @@ } .select { - width: px2rem(116); + inline-size: px2rem(116); } .select-image { - margin-top: var(--sp-xs); + margin-block-start: var(--sp-xs); } .content { - border-radius: $br-8; display: flex; justify-content: center; + border-radius: $br-8; + block-size: px2rem(140); + margin-block-end: px2rem(6); + margin-inline-end: px2rem(1); background-image: url("/images/colorpicker-no-image.png"); background-position: center; background-size: auto px2rem(140); - height: px2rem(140); - margin-bottom: $sz-6; - margin-right: $sz-1; +} - img { - height: fit-content; - width: fit-content; - max-height: 100%; - max-width: 100%; - margin: auto; - } +.content-image { + max-inline-size: 100%; + max-block-size: 100%; + inline-size: fit-content; + block-size: fit-content; + margin: auto; } .choose-image { - @extend %button-secondary; - @include t.use-typography("headline-small"); - - width: 100%; - margin-top: var(--sp-m); - height: var(--sp-xxxl); + justify-content: center; + inline-size: 100%; } +// TODO: Use a DS checkbox component .checkbox-option { - @extend %input-checkbox; + display: flex; + align-items: center; + margin-block: var(--sp-l) 0; + margin-inline: 0; - margin: var(--sp-l) 0 0 0; + // The native checkbox markup (label, span, input, svg) has no dedicated + // classes; styling these elements directly is unavoidable. + label { + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + gap: px2rem(6); + cursor: pointer; + color: var(--color-foreground-secondary); + + span { + --checkbox-icon-background-color: var(--color-background-quaternary); + --checkbox-icon-border-color: var(--color-foreground-secondary); + --checkbox-icon-foreground-color: var(--color-background-primary); + + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-16; + block-size: $sz-16; + min-inline-size: $sz-16; + min-block-size: $sz-16; + border-radius: $br-4; + background-color: var(--checkbox-icon-background-color); + border: $b-1 solid var(--checkbox-icon-border-color); + + svg { + display: none; + inline-size: $sz-16; + block-size: $sz-16; + stroke: var(--checkbox-icon-foreground-color); + } + + &:hover { + --checkbox-icon-border-color: var(--color-accent-primary-muted); + } + + &:focus { + --checkbox-icon-border-color: var(--color-accent-primary); + } + + &:global(.checked) { + --checkbox-icon-background-color: var(--color-accent-primary); + --checkbox-icon-border-color: var(--color-background-quaternary); + + svg { + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-12; + block-size: $sz-12; + stroke-width: 1.33px; + } + } + + &:global(.intermediate) { + --checkbox-icon-background-color: var(--color-foreground-secondary); + --checkbox-icon-foreground-color: var(--color-background-secondary); + + svg { + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-12; + block-size: $sz-12; + stroke-width: 1.33px; + } + } + + &:global(.unchecked) { + --checkbox-icon-border-color: var(--color-background-quaternary); + } + } + + input { + margin: 0; + } + + &:hover { + span { + --checkbox-icon-border-color: var(--color-accent-primary-muted); + } + } + + &:focus, + &:focus-within { + span { + --checkbox-icon-border-color: var(--color-accent-primary); + } + } + } } .token-color-title { @@ -225,5 +219,5 @@ color: var(--color-foreground-secondary); display: flex; align-items: center; - height: var(--sp-xxxl); + block-size: var(--sp-xxxl); } From 985d219810a1740f14297aeee212869e3087b06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 11 Aug 2026 14:36:07 +0200 Subject: [PATCH 087/298] :bug: Fix confusing copy for feams in organizations (#11213) --- frontend/translations/en.po | 4 ++-- frontend/translations/es.po | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c9dcc0f1dd..e5e8967f42 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1243,7 +1243,7 @@ msgstr "Your project has been moved successfully" #, unused msgid "dashboard.team-belong-organization" -msgstr "This team now belongs to %s" +msgstr "This team is now part of the organization %s" #: src/app/main/ui/dashboard/team.cljs:1602 msgid "dashboard.team-info" @@ -1255,7 +1255,7 @@ msgstr "Team members" #, unused msgid "dashboard.team-no-longer-belong-organization" -msgstr "This team no longer belongs to the organization %s" +msgstr "This team is no longer part of the organization %s" #: src/app/main/ui/dashboard/team.cljs:1609 msgid "dashboard.team-organization" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1fb282b4e1..84790f1d86 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1254,7 +1254,7 @@ msgstr "Tu proyecto ha sido movido con éxito" #, unused msgid "dashboard.team-belong-organization" -msgstr "Este equipo ahora pertenece a la organización %s" +msgstr "Este equipo ahora es parte de la organización %s" #: src/app/main/ui/dashboard/team.cljs:1602 msgid "dashboard.team-info" @@ -1266,7 +1266,7 @@ msgstr "Integrantes del equipo" #, unused msgid "dashboard.team-no-longer-belong-organization" -msgstr "Este equipo ya no pertenece a la organización %s" +msgstr "Este equipo ya no es parte de la organización %s" #: src/app/main/ui/dashboard/team.cljs:1609 msgid "dashboard.team-organization" From 290b14167a89a033fb547864da76dd5d560f3ab2 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 11 Aug 2026 17:11:06 +0200 Subject: [PATCH 088/298] :wrench: Allow forcing render-wasm DPR via ?dpr= query param (#11211) Makes HiDPI repro possible without hardcoding get-dpr or relying on the real devicePixelRatio (e.g. ?dpr=2). --- frontend/src/app/render_wasm/api.cljs | 35 +++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e7b0b03c0b..36772bd49f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -67,6 +67,15 @@ (def use-dpr? (contains? cf/flags :render-wasm-dpr)) +(defn- wasm-get-numeric-value + "Read a positive numeric query param (e.g. `?dpr=2`)." + [name] + (when-let [raw (let [p (rt/get-params @st/state)] + (get p name))] + (let [n (if (string? raw) (js/parseFloat raw) raw)] + (when (and (number? n) (not (js/isNaN n)) (pos? n)) + n)))) + ;; --- Page transition state (WASM viewport) ;; ;; Goal: avoid showing tile-by-tile rendering during page switches (and initial load), @@ -300,14 +309,18 @@ (defn get-dpr "Returns the current device pixel ratio. Use instead of `dpr` wherever - the value must reflect browser-zoom changes that happen after load." + the value must reflect browser-zoom changes that happen after load. + + Override with query param `?dpr=2` (or any positive number) for HiDPI repro + without relying on the real `devicePixelRatio`." [] - (if use-dpr? - (let [d (.-devicePixelRatio ^js ug/window)] - ;; In workers `ug/window` is a mock without `devicePixelRatio`, - ;; so guard against nil/NaN/non-positive values. - (if (and (number? d) (pos? d)) d 1.0)) - 1.0)) + (or (wasm-get-numeric-value :dpr) + (if use-dpr? + (let [d (.-devicePixelRatio ^js ug/window)] + ;; In workers `ug/window` is a mock without `devicePixelRatio`, + ;; so guard against nil/NaN/non-positive values. + (if (and (number? d) (pos? d)) d 1.0)) + 1.0))) (def noop-fn (constantly nil)) @@ -2165,14 +2178,6 @@ (set-render-options! dpr) (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) -(defn- wasm-get-numeric-value - [name] - (when-let [raw (let [p (rt/get-params @st/state)] - (get p name))] - (let [n (if (string? raw) (js/parseFloat raw) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) - (defn- wasm-set-param-from-route-params-if-present [param-name] (when-let [value (wasm-get-numeric-value param-name)] From 9f17aa6216f6cddfc4b1bb90f9fefdba3b848f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 11 Aug 2026 19:02:13 +0200 Subject: [PATCH 089/298] :wrench: Report flaky e2e tests in integration workflow Enable Playwright's JSON reporter alongside `list` and publish a summary of flaky tests to the job step summary. The JSON report is kept as an artifact for 30 days so flakiness rates can be aggregated over time. CI already runs with `retries: 2`, so unstable tests have been passing silently on retry. This only surfaces what the suite already absorbs; no test behaviour changes. The reporter in `frontend/scripts/test-e2e` becomes overridable via `PLAYWRIGHT_REPORTER` so the local developer default stays untouched. --- .github/workflows/tests-integration.yml | 31 +++++++++++++++++++++++++ frontend/scripts/test-e2e | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index b028676aa7..61e9c6932e 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -80,9 +80,40 @@ jobs: - name: Run Tests working-directory: ./frontend + env: + PLAYWRIGHT_REPORTER: list,json + PLAYWRIGHT_JSON_OUTPUT_NAME: report.json run: | ./scripts/test-e2e + - name: Flaky summary + if: always() + working-directory: ./frontend + run: | + if [ ! -f report.json ]; then + echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + jq -r ' + [ .. | objects + | select(has("tests") and has("file")) + | select(any(.tests[]; .status == "flaky")) + | "- `\(.file):\(.line)` — \(.title)" + ] as $f + | "## Flaky tests: \($f | length)\n" + + (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end) + ' report.json >> "$GITHUB_STEP_SUMMARY" + + - name: Upload JSON report + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-json-report + path: frontend/report.json + overwrite: true + retention-days: 30 + - name: Upload test result uses: actions/upload-artifact@v7 if: always() diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index fca7cf941e..f4511b53e7 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -1,8 +1,9 @@ #!/usr/bin/env bash SCRIPT_DIR=$(dirname $0); +REPORTER=${PLAYWRIGHT_REPORTER:-list}; set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=1 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter="$REPORTER" "$@"; From 868340dfba9365bee86ae33ec572ceeae4762425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 12 Aug 2026 07:10:34 +0200 Subject: [PATCH 090/298] :bug: Fix text layer bounds clipping glyph (#11141) --- render-wasm/src/shapes.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index dbdeb74be2..8944e814fc 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1071,10 +1071,15 @@ impl Shape { } fn own_extrect_bounds(&self) -> Bounds { + self.expand_own_bounds(self.own_base_bounds()) + } + + /// The shape's own geometry bounds, before stroke/shadow/blur margins. + fn own_base_bounds(&self) -> Bounds { let shape = self; let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); - let mut bounds = match &shape.shape_type { + match &shape.shape_type { Type::Path(_) | Type::Bool(_) => { if let Some(path) = shape.get_skia_path() { let cap_margin = shape.cap_bounds_margin(); @@ -1091,9 +1096,12 @@ impl Shape { text_content.calculate_bounds(shape, false) } _ => shape.calculate_bounds(false), - }; + } + } - bounds = self.apply_stroke_bounds(bounds, max_stroke); + fn expand_own_bounds(&self, bounds: Bounds) -> Bounds { + let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open()); + let mut bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_blur_bounds(bounds); bounds @@ -1104,7 +1112,15 @@ impl Shape { /// first). Includes shadow/blur margins, so it is also a valid input bound /// for a layer whose paint carries an image filter. pub fn layer_bounds(&self) -> math::Rect { - self.own_extrect_bounds().to_rect() + let mut bounds = self.own_base_bounds(); + + if matches!(self.shape_type, Type::Text(_)) { + let mut rect = bounds.to_rect(); + rect.join(self.selrect); + bounds = Bounds::from_rect(&rect); + } + + self.expand_own_bounds(bounds).to_rect() } fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { From 732162e720ef0aba4672da4fd656cdc2864444ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 11 Aug 2026 19:02:13 +0200 Subject: [PATCH 091/298] :wrench: Report flaky e2e tests in integration workflow Enable Playwright's JSON reporter alongside `list` and publish a summary of flaky tests to the job step summary. The JSON report is kept as an artifact for 30 days so flakiness rates can be aggregated over time. CI already runs with `retries: 2`, so unstable tests have been passing silently on retry. This only surfaces what the suite already absorbs; no test behaviour changes. The reporter in `frontend/scripts/test-e2e` becomes overridable via `PLAYWRIGHT_REPORTER` so the local developer default stays untouched. --- .github/workflows/tests-integration.yml | 31 +++++++++++++++++++++++++ frontend/scripts/test-e2e | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index b028676aa7..61e9c6932e 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -80,9 +80,40 @@ jobs: - name: Run Tests working-directory: ./frontend + env: + PLAYWRIGHT_REPORTER: list,json + PLAYWRIGHT_JSON_OUTPUT_NAME: report.json run: | ./scripts/test-e2e + - name: Flaky summary + if: always() + working-directory: ./frontend + run: | + if [ ! -f report.json ]; then + echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + jq -r ' + [ .. | objects + | select(has("tests") and has("file")) + | select(any(.tests[]; .status == "flaky")) + | "- `\(.file):\(.line)` — \(.title)" + ] as $f + | "## Flaky tests: \($f | length)\n" + + (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end) + ' report.json >> "$GITHUB_STEP_SUMMARY" + + - name: Upload JSON report + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-json-report + path: frontend/report.json + overwrite: true + retention-days: 30 + - name: Upload test result uses: actions/upload-artifact@v7 if: always() diff --git a/frontend/scripts/test-e2e b/frontend/scripts/test-e2e index fca7cf941e..f4511b53e7 100755 --- a/frontend/scripts/test-e2e +++ b/frontend/scripts/test-e2e @@ -1,8 +1,9 @@ #!/usr/bin/env bash SCRIPT_DIR=$(dirname $0); +REPORTER=${PLAYWRIGHT_REPORTER:-list}; set -ex $SCRIPT_DIR/setup; -pnpm run test:e2e -x --workers=1 --reporter=list "$@"; +pnpm run test:e2e -x --workers=1 --reporter="$REPORTER" "$@"; From e5c80edbf301ea580028f4142dc7247ed2820690 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 12 Aug 2026 13:20:07 +0200 Subject: [PATCH 092/298] :bug: Fix libraries grid layout (#11226) --- frontend/src/app/main/ui/dashboard/grid.cljs | 3 ++- frontend/src/app/main/ui/dashboard/grid.scss | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 0b19614858..59442337f8 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -480,7 +480,8 @@ [:li {:class (stl/css-case :grid-item true :project-thumbnail true :library-item library-view?)} - [:div {:class (stl/css-case :is-selected selected?) + [:div {:class (stl/css-case :is-selected selected? + :grid-item-button true) :ref node-ref :role "button" :title (:name file) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 448600c835..97291f6002 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -180,6 +180,12 @@ $thumbnail-default-height: px2rem(168); } } +.grid-item-button { + inline-size: 100%; + block-size: 100%; + padding: 0 px2rem(6); +} + .project-thumbnail-actions { align-items: center; display: flex; @@ -328,7 +334,7 @@ $thumbnail-default-height: px2rem(168); // ─── LIBRARY ─────────────────────────────────── .library-thumbnail { - border-radius: $br-4; + border-radius: $br-8; position: relative; overflow: hidden; background-color: var(--color-background-tertiary); From 986ee60cad3703ec549a2e2139c7635d019beba2 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 12 Aug 2026 13:20:21 +0200 Subject: [PATCH 093/298] :bug: Fix invitation loop (#11223) --- frontend/src/app/main/ui/dashboard/team.cljs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 2baaf90d12..d9f8071981 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -19,6 +19,7 @@ [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.repo :as rp] + [app.main.router :as rt] [app.main.store :as st] [app.main.ui.alert] [app.main.ui.components.dropdown :refer [dropdown]] @@ -87,6 +88,7 @@ route (mf/deref refs/route) invite-email (-> route :query-params :invite-email) + team-id (:id team) members-section? (= section :dashboard-team-members) settings-section? (= section :dashboard-team-settings) @@ -101,15 +103,20 @@ on-invite-member (mf/use-fn - (mf/deps team invite-email) + (mf/deps team-id invite-email) (fn [] - (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + (st/emit! (dtm/check-and-invite-members {:team-id team-id :origin :team :invite-email invite-email}))))] - (mf/with-effect [team invite-email] - (when invite-email - (on-invite-member))) + ;; Depend on `team-id` (stable) rather than `team` (a map whose + ;; reference changes on every teams/members fetch) and clear + ;; `invite-email` from the URL once consumed, so this can't + ;; keep re-triggering `check-and-invite-members` in a loop. + (mf/with-effect [team-id invite-email] + (when (and team-id invite-email) + (on-invite-member) + (st/emit! (rt/nav (get-in route [:data :name]) {:team-id team-id} {::rt/replace true})))) [:header {:class (stl/css :dashboard-header :team) :data-testid "dashboard-header"} [:div {:class (stl/css :dashboard-title)} From fee416d27526d78015e8a8a2d3590449e28e694d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 12 Aug 2026 15:10:23 +0200 Subject: [PATCH 094/298] :bug: Fix crash after changing typography options (v2 and v3) (#11221) --- frontend/src/app/render_wasm/text_editor.cljs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 9686889dab..b577861f62 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -345,10 +345,15 @@ :text-direction (sr/untranslate-text-direction (text-editor-get-style-property text-direction-state text-direction-value)) :text-decoration (sr/untranslate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value)) :text-transform (sr/untranslate-text-transform (text-editor-get-style-property text-transform-state text-transform-value)) - :line-height (text-editor-get-style-property line-height-state line-height-value) - :letter-spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value) - :font-size (text-editor-get-style-property font-size-state font-size-value) - :font-weight (text-editor-get-style-property font-weight-state font-weight-value) + ;; WASM reports size/weight as numbers, but the rest of Penpot (and the backend schema) expects strings. + :line-height (let [height (text-editor-get-style-property line-height-state line-height-value)] + (if (= height :multiple) height (str height))) + :letter-spacing (let [spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value)] + (if (= spacing :multiple) spacing (str spacing))) + :font-size (let [size (text-editor-get-style-property font-size-state font-size-value)] + (if (= size :multiple) size (str size))) + :font-weight (let [weight (text-editor-get-style-property font-weight-state font-weight-value)] + (if (= weight :multiple) weight (str weight))) :font-style font-style-value :font-family (text-editor-get-style-property font-family-id-state font-id) :font-id (text-editor-get-style-property font-family-id-state font-id) From 6d49fb2be0f0ccd14e36cac4a1a63d79906c4f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 12 Aug 2026 16:52:27 +0200 Subject: [PATCH 095/298] :bug: Fix organization dropdown alignment (#11216) --- frontend/src/app/main/ui/dashboard/sidebar.scss | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 836e6b6e93..3dcc3b8ba8 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -699,12 +699,13 @@ grid-template-columns: 1fr auto auto; gap: var(--sp-s); height: 100%; - padding: 0 px2rem(10); + padding: 0 0 0 px2rem(10); width: 100%; } .current-organization-no-options { gap: 0; + padding: 0 px2rem(8) 0 px2rem(10); } .current-organization .arrow-icon { @@ -734,11 +735,15 @@ display: flex; justify-content: center; align-items: center; - width: $sz-48; - height: $sz-48; + width: $sz-28; + height: 100%; + padding: 0; + border-radius: 0 $br-8 $br-8 0; &:hover { --icon-stroke: var(--color-accent-primary); + + cursor: pointer; } } From 93f02ea0b4e1701d42829556bfa022b4eeb54fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 12 Aug 2026 16:55:20 +0200 Subject: [PATCH 096/298] :bug: Fix send-invitations policy not enforced in backend RPC (#11206) --- .../src/app/rpc/commands/teams_invitations.clj | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 0ba235a444..93b15df5c2 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -460,6 +460,10 @@ [cfg {:keys [::rpc/profile-id team-id role emails] :as params}] (let [perms (teams/get-permissions cfg profile-id team-id) profile (db/get-by-id cfg :profile profile-id) + team (db/get-by-id cfg :team team-id) + team-with-org (when (contains? cf/flags :admin-console) + (nitrate/add-organization-info-to-team cfg team {})) + organization (:organization team-with-org) ;; Determine which format is being used using-emails-format? (and emails role) ;; Handle both parameter formats @@ -475,6 +479,17 @@ (ex/raise :type :validation :code :insufficient-permissions)) + (when (and (contains? cf/flags :admin-console) + organization + (not (cto/allowed? :send-invitations + {:organization-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms perms}))) + (ex/raise :type :validation + :code :insufficient-permissions + :hint "Organization policy does not allow you to send invitations")) + (when (> invitation-count max-invitations-by-request-threshold) (ex/raise :type :validation :code :max-invitations-by-request From 3b9e0782e4184af5c32e07b4613c039af8737782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 12 Aug 2026 17:06:02 +0200 Subject: [PATCH 097/298] :bug: Fix sso error message (#11225) --- backend/src/app/auth/oidc.clj | 13 +++++++++---- frontend/src/app/main/ui/routes.cljs | 20 +++++++++++--------- frontend/src/app/main/ui/static.cljs | 8 ++++---- frontend/translations/en.po | 4 ++-- frontend/translations/es.po | 4 ++-- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index c32f3bbf75..d92f430422 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -651,10 +651,12 @@ (redirect-response uri)))) (defn- redirect-with-organization-sso-error - [{:keys [dest-url organization-id]}] + [{:keys [dest-url organization-id organization-name]}] (-> (str (or dest-url (cf/get :public-uri))) (u/append-query-param :sso-error true) (u/append-query-param :organization-id organization-id) + (cond-> organization-name + (u/append-query-param :organization-name organization-name)) (redirect-response))) (defn- redirect-to-register @@ -923,9 +925,12 @@ (l/err :hint "unexpected error on organization sso callback" :organization-id (:organization-id state) :cause cause)))) - (redirect-with-organization-sso-error - {:dest-url dest-url - :organization-id (:organization-id state)}))))) + (let [organization-id (:organization-id state) + organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))] + (redirect-with-organization-sso-error + {:dest-url dest-url + :organization-id organization-id + :organization-name organization-name})))))) (defn- callback-handler [cfg {:keys [params] :as request}] diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index cc0d07537d..0a898d7992 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -149,22 +149,24 @@ (defn- handle-sso-error-and-navigate "Check if the current route has an SSO error marker. If so, assign an - exception with type :sso-error and organization-id from query params, + exception with type :sso-error and organization-id/name from query params, and deliberately do NOT proceed with normal navigation: emitting `rt/navigated` would clear the exception that was just assigned. Otherwise, delegate to `check-sso-and-navigate`." [match send-event-info? url] - (let [route-name (name (get-in match [:data :name])) - sso-error? (some? (get-in match [:query-params :sso-error])) - organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) - team-id-str (or (get-in match [:query-params :team-id]) - (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes - team-id (some-> team-id-str uuid/parse*) - is-workspace? (str/starts-with? route-name "workspace") - is-dashboard? (str/starts-with? route-name "dashboard")] + (let [route-name (name (get-in match [:data :name])) + sso-error? (some? (get-in match [:query-params :sso-error])) + organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) + organization-name (some-> (get-in match [:query-params :organization-name]) str/trim) + team-id-str (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes + team-id (some-> team-id-str uuid/parse*) + is-workspace? (str/starts-with? route-name "workspace") + is-dashboard? (str/starts-with? route-name "dashboard")] (if sso-error? (st/emit! (rt/assign-exception {:type :sso-error :organization-id organization-id + :organization-name organization-name :team-id team-id :is-workspace is-workspace? :is-dashboard is-dashboard?})) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9232c6598c..95b73d0191 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -484,7 +484,7 @@ `request-dialog*` used by the no-permission dialogs) when the organization SSO exchange with the identity provider fails." {::mf/private true} - [{:keys [organization-id team-id profile is-workspace is-dashboard]}] + [{:keys [organization-id team-id profile is-workspace is-dashboard organization-name]}] (let [clean-url (mf/with-memo [] (-> (rt/get-current-href) @@ -520,7 +520,7 @@ [:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace)) :is-workspace is-workspace :profile profile} - [:> request-dialog* {:title (tr "labels.sso-error.title") + [:> request-dialog* {:title (tr "labels.sso-error.title", organization-name) :content [(tr "labels.sso-error.desc-message")] :button-text (tr "labels.sso-error.retry") :on-button-click on-retry @@ -532,7 +532,6 @@ [{:keys [data] :as props}] (let [type (get data :type) cause (get data ::errors/instance) - organization-id (get data :organization-id) report (mf/with-memo [cause] (when (ex/exception? cause) @@ -564,7 +563,8 @@ [:> nitrate-unavailable*] :sso-error - [:> sso-error-section* {:organization-id organization-id + [:> sso-error-section* {:organization-id (get data :organization-id) + :organization-name (get data :organization-name) :team-id (get data :team-id) :profile (mf/deref refs/profile) :is-workspace (get data :is-workspace false) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e5e8967f42..a153d8975e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10341,10 +10341,10 @@ msgid "notifications.invitation-canceled" msgstr "This invitation is no longer available." msgid "labels.sso-error.title" -msgstr "We couldn't sign you in to your organization" +msgstr "We couldn't sign you in to %s" msgid "labels.sso-error.desc-message" -msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected." +msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet." msgid "labels.sso-error.retry" msgstr "Try again" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 84790f1d86..41a7eaa180 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9991,10 +9991,10 @@ msgid "notifications.invitation-canceled" msgstr "Esta invitación ya no está disponible." msgid "labels.sso-error.title" -msgstr "No pudimos iniciar sesión en tu organización" +msgstr "No pudimos iniciar sesión en %s" msgid "labels.sso-error.desc-message" -msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada." +msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio." msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" From ef26231b8f73a5659a2f590907409e85695a17a6 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 12 Aug 2026 17:53:02 +0200 Subject: [PATCH 098/298] :bug: Fix nitrate organization sso expiration (#11227) --- backend/src/app/auth/oidc.clj | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index c32f3bbf75..715661b234 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -620,9 +620,6 @@ (some? (:external-session-id state)) (assoc :external-session-id (:external-session-id state)) - (some? (:token/expires-in tdata)) - (assoc :sso-token-exp (ct/in-future {:seconds (:token/expires-in tdata)})) - ;; If state token comes with props, merge them. The state token ;; props can contain pm_ and utm_ prefixed query params. (map? (:props state)) @@ -904,9 +901,9 @@ (let [organization-id (:organization-id state) sso (nitrate/call cfg :get-organization-sso {:organization-id organization-id}) provider (prepare-organization-sso-provider cfg sso) - info (get-info cfg provider state code) + _info (get-info cfg provider state code) session (session/get-session request) - exp (or (:sso-token-exp info) (ct/in-future {:hours 48}))] + exp (ct/in-future {:minutes 15})] (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] From af1537d071d776d8ef4c512acfcab4bf1ee01f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 11 Aug 2026 19:51:57 +0200 Subject: [PATCH 099/298] :zap: Shard integration e2e tests across four parallel jobs Split the integration suite into four shards running two Playwright workers each. Median wall time for the job drops from ~40 min to an expected ~15 min; the build job is unchanged at ~4 min. Shard reports are merged into a single HTML report, and the merged run is summarised in the job step summary: totals, failed specs and flaky specs ranked by retry count. Chromium is installed into a shared volume so shards do not re-download it. `workflow_dispatch` allows running the suite manually against an arbitrary ref, with configurable shard layout and workers. PRs targeting `staging` keep running serially while the current release stabilizes. The exception is marked TEMPORARY and removed in a follow-up. --- .github/scripts/playwright-summary.jq | 41 +++++ .github/workflows/tests-integration.yml | 199 +++++++++++++++++++----- 2 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/playwright-summary.jq diff --git a/.github/scripts/playwright-summary.jq b/.github/scripts/playwright-summary.jq new file mode 100644 index 0000000000..034b582267 --- /dev/null +++ b/.github/scripts/playwright-summary.jq @@ -0,0 +1,41 @@ +def specs: [.. | objects | select(has("tests") and has("file"))]; +def dur: [.tests[].results[]?.duration // 0] | add; + +specs as $s +| ($s | map(select(any(.tests[]; .status == "unexpected")))) as $failed +| ($s | map(select(any(.tests[]; .status == "flaky")))) as $flaky +| ($s | map(select(any(.tests[]; .status == "skipped")))) as $skipped +| ($s | length) as $total +| ($s | map(dur) | add // 0 | . / 1000 | floor) as $cpu +| (if ($failed | length) > 0 then "❌" + elif ($flaky | length) > 0 then "⚠️" + else "✅" end) as $icon + +| "## \($icon) Integration tests\n\n" ++ "| Total | Passed | Flaky | Failed | Skipped | Test time |\n" ++ "|---|---|---|---|---|---|\n" ++ "| \($total) | \($total - ($failed|length) - ($flaky|length) - ($skipped|length)) " ++ "| \($flaky|length) | \($failed|length) | \($skipped|length) | \($cpu / 60 | floor)m |\n" + ++ (if ($failed | length) > 0 then + "\n### Failed\n\n" + + ($failed | map("- `\(.file):\(.line)` — \(.title)") | join("\n")) + "\n" + else "" end) + ++ (if ($flaky | length) > 0 then + "\n### Flaky (passed on retry)\n\n" + + ($flaky + | map({ t: "`\(.file):\(.line)` — \(.title)", + r: ([.tests[].results[]? | select(.status == "failed")] | length) }) + | sort_by(-.r) + | map("- \(.t) _(\(.r) \(if .r == 1 then "retry" else "retries" end))_") + | join("\n")) + "\n" + else "" end) + ++ (if $total > 0 then + "\n
    Slowest specs\n\n" + + ($s | map({ t: "`\(.file)` — \(.title)", d: (dur / 1000 | floor) }) + | sort_by(-.d) | .[0:5] + | map("- \(.t) — \(.d)s") | join("\n")) + + "\n\n
    \n" + else "" end) diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index 61e9c6932e..1a3e210427 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -5,11 +5,37 @@ defaults: shell: bash on: + workflow_dispatch: + inputs: + gh_ref: + description: 'Name of the branch or ref' + type: string + required: true + default: 'develop' + + shards: + description: 'Shard layout (JSON array)' + type: choice + required: true + default: '[1, 2, 3, 4]' + options: + - '[1, 2, 3, 4]' + - '[1, 2, 3, 4, 5, 6]' + - '[1, 2]' + - '[1]' + + workers: + description: 'Playwright workers per shard' + type: string + required: true + default: '2' + pull_request: paths: - 'frontend/**' - 'common/**' - 'render-wasm/**' + - '.github/workflows/tests-integration.yml' types: - opened @@ -25,9 +51,10 @@ on: - 'frontend/**' - 'common/**' - 'render-wasm/**' + - '.github/workflows/tests-integration.yml' concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }} cancel-in-progress: true jobs: @@ -35,15 +62,30 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 + timeout-minutes: 30 container: image: penpotapp/devenv:latest volumes: - /var/cache/github-runner/m2:/root/.m2 - /var/cache/github-runner/gitlib:/root/.gitlibs + outputs: + bundle_key: ${{ steps.vars.outputs.bundle_key }} + steps: + # An empty `ref` makes checkout fall back to its default (the PR merge + # ref on pull_request, the pushed ref on push). - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.gh_ref }} + + # The cache key must come from the SHA actually checked out: on a manual + # run `github.sha` points at the dispatching ref, not at `gh_ref`. + - name: Extract cache key + id: vars + run: | + echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - name: Build Bundle working-directory: ./frontend @@ -53,72 +95,151 @@ jobs: - name: Store Bundle Cache uses: actions/cache@v5 with: - key: "integration-bundle-${{ github.sha }}" + key: ${{ steps.vars.outputs.bundle_key }} path: frontend/resources/public test-integration: if: ${{ !github.event.pull_request.draft }} - name: "Integration Tests" + name: "Integration Tests (${{ matrix.shard }})" runs-on: penpot-runner-02 + timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }} + + needs: build-integration + + # TEMPORARY (release stabilization): PRs targeting `staging` run on a + # single serial shard, so new flakes cannot block the release work. + # Remove the `github.base_ref` branch below to restore full parallelism. + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }} + + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + - /var/cache/github-runner/ms-playwright:/ms-playwright + env: + PLAYWRIGHT_BROWSERS_PATH: /ms-playwright + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.gh_ref }} + + - name: Restore Cache + uses: actions/cache/restore@v5 + with: + key: ${{ needs.build-integration.outputs.bundle_key }} + path: frontend/resources/public + + - name: Install deps + working-directory: ./frontend + run: | + corepack enable; + corepack install; + pnpm install --frozen-lockfile; + + # No-op once the shared volume is warm; keeps the first run working. + - name: Install Playwright Chromium + working-directory: ./frontend + run: pnpm exec playwright install chromium + + # `strategy.job-total` is the matrix size, so the shard denominator + # follows the `shards` input without being hardcoded. + - name: Run Tests + working-directory: ./frontend + env: + WORKERS: ${{ inputs.workers }} + BASE_REF: ${{ github.base_ref }} + run: | + # TEMPORARY (release stabilization): see the note on the matrix above. + if [ -z "$WORKERS" ]; then + if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi + fi + echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers" + pnpm exec playwright test --project default \ + --workers="$WORKERS" \ + --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ + --reporter=blob + + - name: Upload blob report + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-blob-report-${{ matrix.shard }} + path: frontend/blob-report/ + overwrite: true + retention-days: 3 + + - name: Upload test result + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-tests-result-${{ matrix.shard }} + path: frontend/test-results/ + overwrite: true + if-no-files-found: ignore + retention-days: 3 + + merge-reports: + if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }} + name: "Merge Integration Reports" + runs-on: penpot-runner-02 + timeout-minutes: 15 + + needs: test-integration + container: image: penpotapp/devenv:latest volumes: - /var/cache/github-runner/m2:/root/.m2 - /var/cache/github-runner/gitlib:/root/.gitlibs - needs: build-integration - steps: - name: Checkout Repository uses: actions/checkout@v6 - - - name: Restore Cache - uses: actions/cache/restore@v5 with: - key: "integration-bundle-${{ github.sha }}" - path: frontend/resources/public + ref: ${{ inputs.gh_ref }} - - name: Run Tests + - name: Install deps + working-directory: ./frontend + run: | + corepack enable; + corepack install; + pnpm install --frozen-lockfile; + + - name: Download blob reports + uses: actions/download-artifact@v7 + with: + path: frontend/all-blob-reports + pattern: integration-blob-report-* + merge-multiple: true + + - name: Merge into HTML report working-directory: ./frontend env: - PLAYWRIGHT_REPORTER: list,json PLAYWRIGHT_JSON_OUTPUT_NAME: report.json run: | - ./scripts/test-e2e + pnpm exec playwright merge-reports \ + --reporter=html,json,list ./all-blob-reports - - name: Flaky summary + - name: Test summary if: always() working-directory: ./frontend run: | if [ ! -f report.json ]; then - echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY" + echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY" exit 0 fi + jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY" - jq -r ' - [ .. | objects - | select(has("tests") and has("file")) - | select(any(.tests[]; .status == "flaky")) - | "- `\(.file):\(.line)` — \(.title)" - ] as $f - | "## Flaky tests: \($f | length)\n" - + (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end) - ' report.json >> "$GITHUB_STEP_SUMMARY" - - - name: Upload JSON report + - name: Upload HTML report uses: actions/upload-artifact@v7 - if: always() with: - name: integration-json-report - path: frontend/report.json + name: integration-html-report + path: frontend/playwright-report/ overwrite: true - retention-days: 30 - - - name: Upload test result - uses: actions/upload-artifact@v7 - if: always() - with: - name: integration-tests-result - path: frontend/test-results/ - overwrite: true - retention-days: 3 + retention-days: 7 From be83656d557e719a67bb90edf463005561b63721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 13 Aug 2026 07:23:15 +0200 Subject: [PATCH 100/298] :tada: Add caret style changes (text editor v3) (#11171) * :bug: Fix editor v3 quitting when changing typography options * :tada: Apply text styles to collapsed caret * :bug: Fix not persisting the new selrect * :bug: Fix selrect not being recomputed on caret style changes * :bug: Fix quitting the editor when changing typography on empty texts --- .../ui/specs/text-editor-v3.spec.js | 106 ++++++++++++++++ .../src/app/main/data/workspace/texts.cljs | 36 ++++-- .../shapes/text/text_edition_outline.cljs | 13 +- .../ui/workspace/shapes/text/v3_editor.cljs | 85 ++++++++++++- .../workspace/sidebar/options/menus/text.cljs | 2 + frontend/src/app/render_wasm/api.cljs | 11 ++ frontend/src/app/render_wasm/text_editor.cljs | 120 ++++++++++++------ frontend/src/app/util/text/ui.cljs | 12 +- 8 files changed, 319 insertions(+), 66 deletions(-) diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 45f0103d19..21d7d97909 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -23,6 +23,49 @@ async function openEditorAndSelectAll(workspace) { await workspace.page.keyboard.press("ControlOrMeta+a"); } + +test("Typography at a collapsed caret only styles newly typed text", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const fontSize = workspace.textEditor.fontSize; + const editorInput = page.locator("#text-editor-wasm-input"); + + // Draw a text box, focus it, and type some text; the caret ends up collapsed + // after it. + await workspace.createTextShape(200, 150, 460, 260); + await workspace.clickAt(210, 160); + await expect(editorInput).toBeFocused(); + await page.keyboard.type("ab"); + + const originalSize = await fontSize.inputValue(); + const newSize = String(Number(originalSize) + 20); + + // Change the font size with a collapsed caret. This must not restyle the + // existing text; it is stashed as a pending style for the next input. Focus + // returns to the editor once the sidebar input blurs. + await workspace.textEditor.changeFontSize(newSize); + await expect(editorInput).toBeFocused(); + + // Typing now adopts the pending size as its own span. + await page.keyboard.type("X"); + + // The just-typed "X" carries the new size... + await page.keyboard.press("Shift+ArrowLeft"); + await expect(fontSize).toHaveValue(newSize); + + // ...while the pre-existing "ab" keeps the original size (the bug applied the + // change to the whole shape instead). + await page.keyboard.press("Home"); + await page.keyboard.press("Shift+ArrowRight"); + await page.keyboard.press("Shift+ArrowRight"); + await expect(fontSize).toHaveValue(originalSize); +}); + test.describe("BUG 10502 - Mixed families and variants", () => { test("Multiple variants of the same font family", async ({ page }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); @@ -114,6 +157,69 @@ test.describe("BUG 10530 - Empty text box left behind when leaving the editor", }); }); +test.describe("BUG 11083 - Changing typography must not quit the editor", () => { + test("Changing a numeric input must not quit the editor", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // Draw an empty text box and, without typing anything, change the font size. + await workspace.createTextShape(200, 150, 320, 210); + await expect(layerRows).toHaveCount(1); + + await workspace.textEditor.changeFontSize(24); + + // The shape is not deleted and the editor is still mounted. + await expect(layerRows).toHaveCount(1); + await expect(page.getByTestId("text-editor")).toBeVisible(); + + // The edition survives, so we can click back into the box and keep typing. + await workspace.clickAt(210, 160); + await page.keyboard.type("hello"); + await workspace.textEditor.stopEditing(); + + await layerRows.first().click(); + await workspace.waitForSelectedShapeName("hello"); + }); + + test("Opening the font family selector must not quit the editor", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + await workspace.setupEmptyFile(); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + const layerRows = workspace.layers.getByTestId("layer-row"); + + // Draw an empty text box and, without typing anything, open the font family + // selector + await workspace.createTextShape(200, 150, 320, 210); + await expect(layerRows).toHaveCount(1); + + await workspace.rightSidebar.getByTitle("Font Family").click(); + + // The shape is not deleted and the editor is still mounted. + await expect(layerRows).toHaveCount(1); + await expect(page.getByTestId("text-editor")).toBeVisible(); + + // The edition survives, so we can click back into the box and keep typing. + await workspace.clickAt(210, 160); + await page.keyboard.type("hello"); + await workspace.textEditor.stopEditing(); + + await layerRows.first().click(); + await workspace.waitForSelectedShapeName("hello"); + }); +}); + + + test("BUG 10467 - Auto-width text captures every typed character", async ({ page, }) => { diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 862a40ceb6..7499aae52e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1014,8 +1014,18 @@ (rx/of (update-paragraph-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs txt/text-node-attrs)] - (if (or (empty? attrs) wasm-editing-selection?) + (cond + (or (empty? attrs) wasm-editing-selection?) (rx/empty) + + ;; Collapsed caret: stash a pending caret style for the next typed + ;; character instead of restyling the whole shape. + wasm-editing? + (do + (wasm.text-editor/merge-pending-caret-styles! id attrs) + (rx/of (dwt-v3/v3-update-text-editor-styles id attrs))) + + :else (rx/of (update-text-attrs {:id id :attrs attrs})))) (when (and (features/active-feature? state "text-editor/v2") @@ -1238,7 +1248,7 @@ 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]}] + update-name? name resize-geom]}] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape* (get objects id) @@ -1250,7 +1260,8 @@ (cond-> new-shape? (-> (pcb/set-undo-group id) (pcb/set-stack-undo? true)))) - final-geom (select-keys shape* [:selrect :points :width :height]) + ;; `resize-geom` is the post-resize geometry; `shape*` still holds the pre-resize selrect. + final-geom (or resize-geom (select-keys shape* [:selrect :points :width :height])) geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height]) old-geom (when (and content-has-text? (not= :fixed (:grow-type shape*))) (or (get-in state [:workspace-text-session-geom id]) @@ -1302,6 +1313,13 @@ ;; modifier machinery, made auto-width typing very laggy. new-size (when (and finalize? (not= :fixed (:grow-type shape))) (dwwt/get-wasm-text-new-size shape content)) + ;; Also compute the resized geometry for the finalize commit; the + ;; async `apply-wasm-modifiers` below never updates this `state`. + resize-modifiers (when (some? new-size) + (dwwt/resize-wasm-text-modifiers shape content)) + resize-geom (when resize-modifiers + (-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers])) + (select-keys [:selrect :points :width :height]))) ;; 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?) @@ -1341,12 +1359,9 @@ :stack-undo? effective-stack-undo? :undo-group (when new-shape? id)}) - ;; `new-size` is only computed on finalize (see above), so this commits - ;; the final auto-width/auto-height geometry via `apply-wasm-modifiers` - ;; like other transform flows (flex parents, sidebar width, etc.). - (when (some? new-size) - (when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)] - (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)})))) + ;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`. + (when (some? resize-modifiers) + (dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)}))) (when finalize? (rx/concat @@ -1379,7 +1394,8 @@ ;; behavior (their create is bundled in the undo group). :original-content (if new-shape? original-content prev-content) :update-name? update-name? - :name name}))) + :name name + :resize-geom resize-geom}))) (rx/empty)) (rx/of (dwt/finish-transform) (fn [state] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 35529a64a0..977ec783c1 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.workspace.shapes.text.text-edition-outline (:require [app.common.geom.shapes :as gsh] - [app.common.math :as mth] [app.main.data.helpers :as dsh] [app.main.data.workspace.texts :as dwt] [app.main.features :as features] @@ -22,11 +21,13 @@ (let [selrect-transform (mf/deref refs/workspace-selrect) [selrect transform] (dsh/get-selrect selrect-transform shape) - [sr-width sr-height] - (if (or (mth/close? (:width selrect) 0.01) (mth/close? (:height selrect) 0.01)) - (let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))] - [width height]) - [(:width selrect) (:height selrect)])] + ;; While editing, the committed selrect lags the text (geometry is + ;; finalize-only), so measure the live WASM text for the growing axes: + ;; width grows on auto-width, height on auto-width/auto-height. + grow-type (:grow-type shape) + {live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape)) + sr-width (if (= grow-type :auto-width) live-width (:width selrect)) + sr-height (if (= grow-type :fixed) (:height selrect) live-height)] [:rect.main.viewport-selrect {:x (:x selrect) :y (:y selrect) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index d872d684a8..86cce0327d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -23,6 +23,20 @@ (def caret-blink-interval-ms 250) +;; Elements carrying this attr keep the edit alive when focus moves onto them (see `keep-editing-on-blur?`). +(def ^:private keep-editing-selector "[data-keep-editing-on-blur]") + +(defn- keep-editing-on-blur? + "True when a surface `blur` must NOT exit the editor: + - Firefox triggering a blur when MacOS Character Viewer is open + - Focus switched to a data-keep-editing-on-blur region (e.g. typography options), + ancestors or descendants" + [^js event ^js surface] + (or (= (.-activeElement js/document) surface) + (when-let [related (dom/get-related-target event)] + (or (some? (.closest related keep-editing-selector)) + (some? (.querySelector related keep-editing-selector)))))) + (defn- sync-wasm-text-editor-content! "Sync WASM text editor content back to the shape via the standard commit pipeline. Called after every text-modifying input." @@ -39,6 +53,48 @@ :name name :finalize? finalize?))))) +;; Keys that move/reset the caret (or delete): pressing any abandons the pending +;; caret style. Plain character keys instead reach `on-input`, which consumes it. +(def ^:private caret-abandon-keys + #{"ArrowLeft" "ArrowRight" "ArrowUp" "ArrowDown" + "Home" "End" "PageUp" "PageDown" + "Enter" "Backspace" "Delete" "Escape" "Tab"}) + +(defn- caret-position + "Collapsed caret as {:para :offset} from the WASM selection, or nil." + [] + (when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)] + {:para focus-para :offset focus-offset})) + +(defn- typed-range + "Normalized range covering the text inserted between `before` and `after`, or nil." + [before after] + (when (and before after) + (if (or (< (:para before) (:para after)) + (and (= (:para before) (:para after)) + (<= (:offset before) (:offset after)))) + {:start-para (:para before) :start-offset (:offset before) + :end-para (:para after) :end-offset (:offset after)} + {:start-para (:para after) :start-offset (:offset after) + :end-para (:para before) :end-offset (:offset before)}))) + +(defn- sync-with-pending-caret-styles! + "Commit an insertion that consumed a pending caret style: sync the new text, + then restyle the just-typed `range` into its own span. `before` is the + pre-insert caret." + [shape-id before] + (let [range (typed-range before (caret-position))] + ;; Sync first so the cached content stays index-aligned with WASM. + (text-editor/text-editor-sync-content) + (if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)] + (let [text (txt/content->text content) + name (when (not= text "") (txt/generate-shape-name text))] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :name name))) + (sync-wasm-text-editor-content!)))) + (defn- reset-input-node "Empties the contenteditable capture surface and restores a collapsed caret inside it. @@ -163,6 +219,8 @@ on-composition-start (mf/use-fn (fn [_event] + ;; IME composition supplies its own text; drop any pending caret style. + (text-editor/clear-pending-caret-styles!) (text-editor/text-editor-composition-start))) on-composition-update @@ -190,6 +248,8 @@ (mf/use-fn (fn [^js event] (dom/prevent-default event) + ;; Pasted text keeps the surrounding style; drop any pending caret style. + (text-editor/clear-pending-caret-styles!) (let [clipboard-data (.-clipboardData event) text (.getData clipboard-data "text/plain")] (when (and text (seq text)) @@ -229,6 +289,10 @@ (let [key (.-key event) ctrl? (or (.-ctrlKey event) (.-metaKey event)) shift? (.-shiftKey event)] + ;; Ctrl+A adds select-all to the caret-abandon-keys set. + (when (or (contains? caret-abandon-keys key) + (and ctrl? (= (str/lower key) "a"))) + (text-editor/clear-pending-caret-styles!)) (cond ;; Escape: finalize and stop (= key "Escape") @@ -365,8 +429,14 @@ (let [pending (mf/ref-val pending-replace-ref)] (dotimes [_ pending] (text-editor/text-editor-delete-backward))) - (text-editor/text-editor-insert-text data) - (sync-wasm-text-editor-content!) + (let [shape-id (text-editor/text-editor-get-active-shape-id) + ;; The inserted character adopts a pending caret style, if any. + pending-styles? (some? (text-editor/get-pending-caret-styles shape-id)) + before (when pending-styles? (caret-position))] + (text-editor/text-editor-insert-text data) + (if pending-styles? + (sync-with-pending-caret-styles! shape-id before) + (sync-wasm-text-editor-content!))) (wasm.api/request-render-preserving-target "text-input")) (mf/set-ref-val! pending-replace-ref 0) ;; IMPORTANT: do NOT clear the surface here (see keep-input-alive): @@ -379,6 +449,9 @@ (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] + ;; Repositioning the caret abandons the pending caret style (also + ;; covers click and double-click, which fire pointer-down first). + (text-editor/clear-pending-caret-styles!) (mf/set-ref-val! dragging-ref true) (if (.-shiftKey event) (wasm.api/text-editor-pointer-down-extend off-pt) @@ -431,12 +504,10 @@ on-blur (mf/use-fn (fn [^js event] - ;; MacOS Character Viewer on Firefox fires a `blur` when it opens. - ;; To avoid losing the selected character, we need guard against - ;; `activeElement` being the surface itself. + ;; A blur exits the editor unless keep-editing-on-blur? is true (when-not (and (some? event) - (= (.-activeElement js/document) - (mf/ref-val contenteditable-ref))) + (keep-editing-on-blur? event (mf/ref-val contenteditable-ref))) + (text-editor/clear-pending-caret-styles!) (sync-wasm-text-editor-content! {:finalize? true}) (wasm.api/text-editor-blur)))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60b0c15c02..60832b642c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -498,6 +498,8 @@ (ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!)))) [:section {:class (stl/css :element-set) + ;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`). + :data-keep-editing-on-blur true :aria-label (tr "workspace.options.text-options.text-section")} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable true diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 36772bd49f..9e4e4c1010 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -735,6 +735,17 @@ (request-render "apply-paragraph-attrs-to-selection") result)) +(defn apply-pending-caret-styles! + "Apply the shape's pending caret style over `range` (the just-typed text) and + clear it; returns {:shape-id :content} or nil when there is none." + [shape-id range] + (when-let [styles (text-editor/get-pending-caret-styles shape-id)] + (let [result (text-editor/apply-styles-to-range + shape-id range styles use-shape set-shape-text-content)] + (text-editor/clear-pending-caret-styles!) + (request-render "apply-pending-caret-styles") + result))) + (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 767a0619cb..241d8960d6 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -547,6 +547,24 @@ [shape-id content] (swap! shape-text-contents assoc shape-id content)) +;; Typography chosen at a collapsed caret: not applied to existing text, but +;; picked up (as a new span) by the next inserted text. Keyed by shape-id. +(def ^:private pending-caret-styles (atom {})) + +(defn merge-pending-caret-styles! + "Stack `styles` onto the shape's pending caret style." + [shape-id styles] + (swap! pending-caret-styles update shape-id merge styles)) + +(defn get-pending-caret-styles + [shape-id] + (get @pending-caret-styles shape-id)) + +(defn clear-pending-caret-styles! + "Drop every pending caret style; only the active shape can hold one." + [] + (reset! pending-caret-styles {})) + (defn- merge-exported-texts-into-content "Merge exported span texts back into the existing content tree. @@ -704,18 +722,49 @@ (= 1 (count fills-set)) (first fills-set) :else :multiple))) +(defn- apply-styles-over-range + "Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans." + [content {:keys [start-para start-offset end-para end-offset]} styles] + (let [paragraph-set (first (:children content)) + paragraphs (:children paragraph-set) + new-paragraphs (mapv (fn [idx para] + (cond + ;; paragraph outside the range of paragraphs. + (or (< idx start-para) (> idx end-para)) + para + + ;; same paragraph. + (= start-para end-para) + (apply-attrs-to-paragraph para start-offset end-offset styles) + + ;; first paragraph + (= idx start-para) + (apply-attrs-to-paragraph para start-offset (para-char-count para) styles) + + ;; final paragraph + (= idx end-para) + (apply-attrs-to-paragraph para 0 end-offset styles) + + ;; any other paragraph + :else + (apply-attrs-to-paragraph para 0 (para-char-count para) styles))) + (range (count paragraphs)) + paragraphs)] + (assoc content :children [(assoc paragraph-set :children new-paragraphs)]))) + +(defn- clean-styles + "Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail + the backend schema); a per-span fn is passed through untouched." + [styles] + (if (fn? styles) + styles + (into {} (remove (comp nil? val)) styles))) + (defn apply-styles-to-selection "Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`." [styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]] (when (wasm/ready?) - (let [;; Drop nil-valued attrs so they are never merged onto text spans. - ;; The DOM editor path strips these in `attrs->styles`; the WASM merge - ;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr - ;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded - ;; font) would corrupt the span and fail the backend schema. - styles (if (fn? styles) - styles - (into {} (remove (comp nil? val)) styles)) + (let [styles (clean-styles styles) shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] @@ -725,41 +774,10 @@ (let [normalized-selection (normalize-selection selection) {:keys [start-para start-offset end-para end-offset]} normalized-selection - collapsed? (and (= start-para end-para) (= start-offset end-offset)) + collapsed? (and (= start-para end-para) (= start-offset end-offset)) - paragraph-set (first (:children content)) - paragraphs (:children paragraph-set) - - new-paragraphs - (when (not collapsed?) - (mapv (fn [idx para] - (cond - ;; paragraph outside the range of paragraphs. - (or (< idx start-para) (> idx end-para)) - para - - ;; same paragraph. - (= start-para end-para) - (apply-attrs-to-paragraph para start-offset end-offset styles) - - ;; first paragraph - (= idx start-para) - (apply-attrs-to-paragraph para start-offset (para-char-count para) styles) - - ;; final paragraph - (= idx end-para) - (apply-attrs-to-paragraph para 0 end-offset styles) - - ;; any other paragraph - :else - (apply-attrs-to-paragraph para 0 (para-char-count para) styles))) - - (range (count paragraphs)) - paragraphs)) - - new-content (when new-paragraphs - (assoc content :children - [(assoc paragraph-set :children new-paragraphs)]))] + new-content (when (not collapsed?) + (apply-styles-over-range content normalized-selection styles))] (when new-content (update-cached-content! shape-id new-content) @@ -770,6 +788,24 @@ with-fills? (assoc :fills (selection-fills new-content normalized-selection))))))))))) +(defn apply-styles-to-range + "Like `apply-styles-to-selection` but over an explicit range (used to restyle + just-inserted text); returns `{:shape-id :content}` or nil." + [shape-id {:keys [start-para start-offset end-para end-offset] :as range} styles + use-shape-fn set-shape-text-content-fn] + (when (wasm/ready?) + (let [styles (clean-styles styles) + content (get-cached-content shape-id)] + (when (and content + (seq styles) + (not (and (= start-para end-para) (= start-offset end-offset)))) + (let [new-content (apply-styles-over-range content range styles)] + (update-cached-content! shape-id new-content) + (use-shape-fn shape-id) + (set-shape-text-content-fn shape-id new-content) + {:shape-id shape-id + :content new-content}))))) + (defn apply-paragraph-attrs-to-selection "Apply paragraph level attrs (text-align, text-direction) to the whole paragraphs the editor selection touches; a collapsed caret means just the one diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index a6242cb7e3..9f8576fcce 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -46,8 +46,18 @@ [] (dom/query "[data-itype=\"editor\"]")) +(defn v3-get-text-editor-content + [] + (dom/get-element "text-editor-wasm-input")) + (defn get-text-editor-content [] - (if (features/active-feature? @st/state "text-editor/v2") + (cond + (features/active-feature? @st/state "text-editor-wasm/v1") + (v3-get-text-editor-content) + + (features/active-feature? @st/state "text-editor/v2") (v2-get-text-editor-content) + + :else (v1-get-text-editor-content))) From f7fc869e52526372e948018ce176be2a2c702fba Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 13 Aug 2026 11:37:06 +0200 Subject: [PATCH 101/298] :books: Unify doc added for nitrate apis (#11231) --- backend/src/app/rpc/commands/nitrate.clj | 24 +++++------ backend/src/app/rpc/management/nitrate.clj | 46 +++++++++++----------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index af993ea179..c7ea974a66 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -48,7 +48,7 @@ (sv/defmethod ::get-nitrate-connectivity {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params [:map] ::sm/result schema:connectivity} [cfg _params] @@ -64,7 +64,7 @@ (sv/defmethod ::get-subscription-warning {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params [:map] ::sm/result schema:subscription-warning} [cfg {:keys [::rpc/profile-id]}] @@ -80,7 +80,7 @@ (sv/defmethod ::redeem-nitrate-activation-code {::rpc/auth true - ::doc/added "2.14" + ::doc/added "2.18" ::sm/params schema:redeem-activation-code-params ::sm/result schema:redeem-activation-code-result} [cfg {:keys [::rpc/profile-id activation-code]}] @@ -112,7 +112,7 @@ "Returns a Base64-encoded JSON file requesting a Nitrate activation code. Payload includes nitrateId, publicKey, email and iat." {::rpc/auth true - ::doc/added "2.20" + ::doc/added "2.18" ::sm/params [:map] ::sm/result ::sm/text} [cfg {:keys [::rpc/profile-id]}] @@ -360,7 +360,7 @@ (sv/defmethod ::leave-organization {::rpc/auth true - ::doc/added "2.15" + ::doc/added "2.18" ::sm/params schema:leave-organization ::db/transaction true} [cfg {:keys [::rpc/profile-id] :as params}] @@ -404,7 +404,7 @@ [:organization-name ::sm/text]]) (sv/defmethod ::remove-team-from-organization - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:remove-team-from-organization} [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] @@ -473,7 +473,7 @@ (sv/defmethod ::add-team-to-organization {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:add-team-to-organization ::db/transaction true} [cfg {:keys [::rpc/profile-id team-id organization-id]}] @@ -558,7 +558,7 @@ (sv/defmethod ::check-organization-members {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:check-organization-members-params ::sm/result [:map-of :string :boolean] ::db/transaction true} @@ -583,7 +583,7 @@ (sv/defmethod ::all-organization-members-in-team {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:all-organization-members-in-team-params ::sm/result ::sm/boolean} [cfg {:keys [::rpc/profile-id team-id organization-id]}] @@ -607,7 +607,7 @@ (sv/defmethod ::all-team-members-in-organizations {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:all-team-members-in-organizations-params ::sm/result [:map-of ::sm/uuid ::sm/boolean]} [cfg {:keys [::rpc/profile-id team-id organization-ids]}] @@ -643,7 +643,7 @@ (sv/defmethod ::check-team-external-invitations {::rpc/auth true - ::doc/added "2.17" + ::doc/added "2.18" ::sm/params schema:check-team-external-invitations-params ::sm/result schema:check-team-external-invitations-result ::db/transaction true} @@ -677,7 +677,7 @@ the client must redirect there. The OIDC provider itself handles re-authentication transparently if the user already has an active SSO session." {::rpc/auth true - ::doc/added "2.19" + ::doc/added "2.18" ::sm/params schema:check-nitrate-sso ::nitrate/sso false} [cfg {:keys [::rpc/profile-id team-id organization-id url] :as params}] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index ca334ffb79..d888308dde 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -54,7 +54,7 @@ (sv/defmethod ::authenticate "Authenticate the current user" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:profile ::nitrate/sso false} @@ -94,7 +94,7 @@ (sv/defmethod ::get-penpot-version "Get the current Penpot version" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:get-penpot-version-result ::rpc/auth false} @@ -106,7 +106,7 @@ (sv/defmethod ::get-teams "List teams for which current user is owner" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:get-teams-result ::nitrate/sso false} @@ -130,7 +130,7 @@ "Store an organization logo in penpot storage and return its ID. Accepts an optional previous-id to mark the old logo for garbage collection when replacing an existing one." - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:upload-organization-logo ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} @@ -151,7 +151,7 @@ (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params cto/schema:team-with-organization ::rpc/auth false} [cfg team] @@ -168,7 +168,7 @@ (sv/defmethod ::notify-user-added-to-organization "Notify to Penpot that an user has joined an organization from nitrate" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params schema:notify-user-added-to-organization ::rpc/auth false} [cfg {:keys [profile-id organization-id]}] @@ -199,7 +199,7 @@ (sv/defmethod ::get-managed-profiles "List profiles that belong to teams for which current user is owner" - {::doc/added "2.14" + {::doc/added "2.18" ::sm/params [:map] ::sm/result schema:managed-profile-result ::nitrate/sso false} @@ -239,7 +239,7 @@ (sv/defmethod ::get-teams-summary "Get summary information for a list of teams" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params schema:get-teams-summary-params ::sm/result schema:get-teams-summary-result ::nitrate/sso false} @@ -360,7 +360,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-organization-deletion "For a deleted organization, preserve organization teams and only prefix or delete imported Your Penpot teams before notifying connected users." - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params schema:notify-organization-deletion ::rpc/auth false} [cfg {:keys [organization-id]}] @@ -406,7 +406,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-email "Get profile by email" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:email ::sm/email]] ::sm/result schema:profile ::nitrate/sso false} @@ -430,7 +430,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-profile-by-id "Get profile by email" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:id ::sm/uuid]] ::sm/result schema:profile ::nitrate/sso false} @@ -465,7 +465,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-member-team-counts "Get the number of non-default teams each profile belongs to within a set of teams." - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params schema:get-organization-member-team-counts-params ::sm/result schema:get-organization-member-team-counts-result ::rpc/auth false} @@ -505,7 +505,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::invite-to-organization "Invite to organization" - {::doc/added "2.15" + {::doc/added "2.18" ::sm/params [:map [:email ::sm/email] [:organization cto/schema:organization-with-avatar]] @@ -537,7 +537,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-organization-invitations "Get valid invitations for an organization, returning at most one invitation per email." - {::doc/added "2.16" + {::doc/added "2.18" ::sm/params schema:get-organization-invitations-params ::sm/result schema:get-organization-invitations-result ::nitrate/sso false} @@ -565,7 +565,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::delete-organization-invitations "Delete all invitations for one email in an organization scope (organization + organization teams)." - {::doc/added "2.16" + {::doc/added "2.18" ::sm/params schema:delete-organization-invitations-params ::nitrate/sso false} [cfg {:keys [organization-id email]}] @@ -630,7 +630,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::remove-from-organization "Remove an user from an organization" - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -675,7 +675,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-remove-from-organization-summary "Get a summary of the teams that would be deleted, transferred, or exited if the user were removed from the organization" - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params [:map [:profile-id ::sm/uuid] [:organization-id ::sm/uuid] @@ -710,7 +710,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::send-renewal-email "Send an Enterprise subscription renewal notice email to a user." - {::doc/added "2.17" + {::doc/added "2.18" ::sm/params schema:send-renewal-email-params ::rpc/auth false} [cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}] @@ -823,7 +823,7 @@ RETURNING id, deleted_at;") "Push audit events from nitrate (strictly for nitrate backend events)" - {::doc/added "2.19" + {::doc/added "2.18" ::audit/skip true ::sm/params schema:push-audit-events-params ::rpc/auth false} @@ -930,7 +930,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::get-teams-detail "Get detailed information for all non-deleted teams in an organization, including owner info and project/file/member counts." - {::doc/added "2.20" + {::doc/added "2.18" ::sm/params schema:get-teams-detail-params ::sm/result schema:get-teams-detail-result ::nitrate/sso false} @@ -958,7 +958,7 @@ RETURNING id, deleted_at;") "Validate an organization SSO configuration by generating a login redirect URL. Nitrate calls this while configuring SSO to verify client credentials and OIDC discovery before saving the settings." - {::doc/added "2.20" + {::doc/added "2.18" ::sm/params cto/schema:nitrate-sso ::sm/result schema:check-organization-sso-result ::rpc/auth false} @@ -968,7 +968,7 @@ RETURNING id, deleted_at;") ;; ---- API: notify-organization-sso-change (sv/defmethod ::notify-organization-sso-change "Nitrate notifies that an organization sso values have changed" - {::doc/added "2.19" + {::doc/added "2.18" ::sm/params [:map [:organization-id ::sm/uuid] [:updated-props ::sm/boolean] @@ -1017,7 +1017,7 @@ RETURNING id, deleted_at;") bulk-creation screen; access is gated by the shared key and, in Nitrate, an email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled by default so it is only available on test environments." - {::doc/added "2.19" + {::doc/added "2.18" ::sm/params schema:bulk-create-profiles-params ::sm/result schema:bulk-create-profiles-result ::rpc/auth false} From 9528400c6e86ea9f3c88e8de5483a828ba45aa6b Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Thu, 13 Aug 2026 12:03:27 +0200 Subject: [PATCH 102/298] :bug: Forward internal Host in nginx proxy_pass to backend/exporter (#11233) The global `proxy_set_header Host $http_host;` forwarded the client-facing Host to internal proxy_pass calls (backend/exporter), breaking mTLS routing in service-mesh setups (e.g. Istio STRICT mode), which match outbound requests to a cluster based on Host/:authority. Explicitly set `Host $proxy_host` on /api, /assets, /api/export, /readyz and /ws/notifications so these calls always target the correct internal service host, independent of the client's original Host header. Fixes #10835 Signed-off-by: Sebastien MALOT Co-authored-by: Sebastien MALOT --- docker/images/files/nginx.conf.template | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 3e91a91714..5ca929c124 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -111,6 +111,7 @@ http { } location /assets { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/assets; recursive_error_pages on; proxy_intercept_errors on; @@ -127,10 +128,12 @@ http { } location /api/export { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_EXPORTER_URI; } location /api { + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI/api; proxy_buffering off; } @@ -142,10 +145,12 @@ http { location /readyz { access_log off; + proxy_set_header Host $proxy_host; proxy_pass $PENPOT_BACKEND_URI$request_uri; } location /ws/notifications { + proxy_set_header Host $proxy_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_pass $PENPOT_BACKEND_URI/ws/notifications; From cb57fd9dfa817f575fa550b5dd233d532142e559 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 13 Aug 2026 12:12:43 +0200 Subject: [PATCH 103/298] :zap: Skip save_layer for plain image fills (#11230) Avoid an offscreen buffer per Fill::Image during tile walks: only use save_layer when a shape image filter is present; axis-aligned rects and frames without corner radii also skip the redundant container clip. --- .../render-wasm/ffi-rendering-subtleties.md | 2 + render-wasm/src/render/fills.rs | 57 +++++++++++++++---- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index f57856a17d..d739cd54e7 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -17,6 +17,8 @@ ## Tile/render behavior +- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain + Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index d7010cc2e5..bebd20f7c6 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -56,6 +56,15 @@ fn clip_to_shape( } } +/// Axis-aligned rect/frame with no corner radii: `dest` fills `selrect`, so a +/// clip to the container is a no-op before `draw_image_rect`. +fn is_axis_aligned_image_rect(shape: &Shape) -> bool { + matches!( + &shape.shape_type, + Type::Rect(Rect { corners: None }) | Type::Frame(Frame { corners: None, .. }) + ) +} + fn draw_image_fill( render_state: &mut RenderState, shape: &Shape, @@ -85,29 +94,53 @@ fn draw_image_fill( let src_rect = get_source_rect(size, container, image_fill); let dest_rect = container; + let sampling = get_resources().sampling_options; - let mut image_paint = skia::Paint::default(); - image_paint.set_anti_alias(antialias); + // `save_layer` is only required when a shape-level image filter (blur) must + // run over the clipped image. Otherwise a plain save/clip (or no clip for + // axis-aligned rects) avoids an offscreen buffer per fill — the hot path + // for photo-heavy boards during tile walks. if let Some(filter) = shape.image_filter(1.) { - image_paint.set_image_filter(filter.clone()); + let mut layer_paint = skia::Paint::default(); + layer_paint.set_anti_alias(antialias); + layer_paint.set_image_filter(filter); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + canvas.save_layer(&layer_rec); + clip_to_shape(canvas, shape, container, antialias); + canvas.draw_image_rect_with_sampling_options( + image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), + dest_rect, + sampling, + paint, + ); + canvas.restore(); + return; } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&image_paint); - // Save the current canvas state - canvas.save_layer(&layer_rec); + let mut draw_paint = paint.clone(); + draw_paint.set_anti_alias(antialias); + if is_axis_aligned_image_rect(shape) { + canvas.draw_image_rect_with_sampling_options( + image, + Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), + dest_rect, + sampling, + &draw_paint, + ); + return; + } + + canvas.save(); clip_to_shape(canvas, shape, container, antialias); - - // Draw the image with the calculated destination rectangle canvas.draw_image_rect_with_sampling_options( image, Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)), dest_rect, - get_resources().sampling_options, - paint, + sampling, + &draw_paint, ); - - // Restore the canvas to remove the clipping canvas.restore(); } From 3db7548c19e1e9d9b26b0401c5c65db91d895fce Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Thu, 13 Aug 2026 13:06:26 +0200 Subject: [PATCH 104/298] :lipstick: Change nitrate error message (#11232) --- frontend/src/app/main/ui/settings/subscription.cljs | 2 +- frontend/translations/en.po | 6 ------ frontend/translations/es.po | 7 ------- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 98626cda7d..a8a7f3fed9 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -425,7 +425,7 @@ nitrate-toast-message (condp = params-subscription - dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-finish-failed") + dnt/nitrate-checkout-finish-error-token (tr "subscription.error.nitrate.checkout-failed") dnt/nitrate-checkout-cancelled-token (tr "subscription.error.nitrate.checkout-cancelled") nil) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e5e8967f42..f3f01fcecf 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -6073,12 +6073,6 @@ msgstr "" "The payment was not completed. Please try again. " "If the problem persists, contact us: support@penpot.app." -#: src/app/main/ui/settings/subscription.cljs:407 -msgid "subscription.error.nitrate.checkout-finish-failed" -msgstr "" -"We couldn’t confirm your subscription. Please check your subscription " -"status on the Subscription page. You may try again if needed." - #: src/app/main/ui/settings/sidebar.cljs:114, src/app/main/ui/settings/subscription.cljs:505, src/app/main/ui/settings/subscription.cljs:565 msgid "subscription.labels" msgstr "Subscription" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 84790f1d86..1b1c8b4c24 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5937,13 +5937,6 @@ msgstr "" "No hemos podido iniciar el proceso de pago. Inténtalo de nuevo. Si el " "problema persiste, contáctanos: support@penpot.app." -#: src/app/main/ui/settings/subscription.cljs:407 -msgid "subscription.error.nitrate.checkout-finish-failed" -msgstr "" -"No hemos podido confirmar tu suscripción. Revisa el estado de tu " -"suscripción en la página de Suscripciones. Puedes volver a intentarlo si lo " -"necesitas." - #: src/app/main/ui/settings/sidebar.cljs:114, src/app/main/ui/settings/subscription.cljs:505, src/app/main/ui/settings/subscription.cljs:565 msgid "subscription.labels" msgstr "Suscripción" From c7f036bed00eae8aa43d2154ef55557e7990f874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 13 Aug 2026 13:15:59 +0200 Subject: [PATCH 105/298] :bug: Fix organization invitation schema validation for logo URI (#11238) --- backend/src/app/rpc/management/nitrate.clj | 3 ++- backend/test/backend_tests/rpc_management_nitrate_test.clj | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index d888308dde..8f07c90612 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -17,6 +17,7 @@ [app.common.types.organization :as cto] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] + [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -499,7 +500,7 @@ RETURNING id, deleted_at;") {:id id :name name :initials (if logo-id "" (d/get-initials name)) - :logo (when logo-id (files/resolve-public-uri logo-id)) + :logo (when logo-id (u/uri (files/resolve-public-uri logo-id))) :avatar-bg-url (when-not logo-id avatar-bg-url) :sso-active (true? sso-active)})) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 4cb32401cf..455f875fd3 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -178,7 +178,7 @@ (t/is (th/success? out)) (t/is (= "Trusted Organization" (:name organization))) (t/is (= "" (:initials organization))) - (t/is (str/ends-with? (:logo organization) + (t/is (str/ends-with? (str (:logo organization)) (str "/assets/by-id/" logo-id))) (t/is (nil? (:avatar-bg-url organization))) (t/is (true? (:sso-active organization)))))))) From 350dc1463253a77b2af27a1eadc98fdf53cd25f2 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 14 Aug 2026 09:33:58 +0200 Subject: [PATCH 106/298] :bug: Show a specific error on nitrate reused activation code (#11236) --- backend/src/app/rpc/commands/nitrate.clj | 1 + .../test/backend_tests/rpc_nitrate_test.clj | 43 +++++++++++++++++++ .../nitrate_code_activation_modal.cljs | 2 +- frontend/translations/en.po | 3 ++ frontend/translations/es.po | 3 ++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index c7ea974a66..76b34e8be8 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -101,6 +101,7 @@ (ex/raise :type :validation :code (case status 410 :expired-activation-code + 409 :used-activation-code :invalid-activation-code) :cause cause) (throw cause))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 5d973d94bd..640e68f2fc 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.rpc-nitrate-test (:require [app.auth.oidc :as oidc] + [app.common.exceptions :as ex] [app.common.json :as json] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -1123,3 +1124,45 @@ ::rpc/profile-id (:id profile)})] (t/is (not (th/success? out))) (t/is (th/ex-of-code? (:error out) :nitrate-identity-unavailable)))))) + +(t/deftest redeem-nitrate-activation-code-used + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 409 + :hint "activation code already used"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "already-used-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :used-activation-code)))))) + +(t/deftest redeem-nitrate-activation-code-expired + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 410 + :hint "activation code expired"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "expired-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :expired-activation-code)))))) + +(t/deftest redeem-nitrate-activation-code-invalid + (let [profile (th/create-profile* 1 {:is-active true})] + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (fn [_cfg method _params] + (t/is (= :redeem-activation-code method)) + (ex/raise :type :nitrate-http-error + :status 422 + :hint "invalid activation code"))] + (let [out (th/command! {::th/type :redeem-nitrate-activation-code + ::rpc/profile-id (:id profile) + :activation-code "invalid-code"})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-code? (:error out) :invalid-activation-code)))))) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 7ad802a2b8..0fc1b86b03 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -53,10 +53,10 @@ (modal/show {:type :nitrate-activation-success}) (dprof/refresh-profile)))) (fn [error] - ;; TODO: "Already used" is not yet detectable (CC upserts on reuse). (let [code (-> error ex-data :code)] (reset! error* (case code :expired-activation-code (tr "nitrate.activation-code.expired-error") + :used-activation-code (tr "nitrate.activation-code.used-error") (tr "nitrate.activation-code.invalid-error"))))))))))) on-key-down diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f3f01fcecf..ff36a11deb 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4377,6 +4377,9 @@ msgstr "This code has expired." msgid "nitrate.activation-code.invalid-error" msgstr "Invalid code." +msgid "nitrate.activation-code.used-error" +msgstr "This code has already been used." + #: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:57 msgid "nitrate.activation-success.active-until" msgstr "Your plan is active until %s." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 1b1c8b4c24..28c0689ba4 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4252,6 +4252,9 @@ msgstr "Este código ha caducado." msgid "nitrate.activation-code.invalid-error" msgstr "Código inválido." +msgid "nitrate.activation-code.used-error" +msgstr "Este código ya ha sido utilizado." + #: src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs:57 msgid "nitrate.activation-success.active-until" msgstr "Tu plan está activo hasta el %s." From a3bc4b0e3acf1028bd80114615f6b4aea219b2e5 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Fri, 14 Aug 2026 10:56:48 +0200 Subject: [PATCH 107/298] :bug: Fix text alignment on libraries (#11243) --- frontend/src/app/main/ui/dashboard/grid.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index 97291f6002..a64e898019 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -353,6 +353,7 @@ $thumbnail-default-height: px2rem(168); .library-name-block { color: var(--color-foreground-secondary); inline-size: calc(100% - var(--sp-xxl) - var(--sp-s)); + text-align: left; } .library-item-name { From e56c8018206053b5f4b5bbdefa82ac09cea2f19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 14 Aug 2026 13:36:40 +0200 Subject: [PATCH 108/298] :bug: Fix selrect collapsing after undo (v3) (#11239) --- frontend/src/app/main/data/workspace/texts.cljs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 7499aae52e..361221bcaa 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1329,7 +1329,16 @@ finalize-save-undo-first? (if (and finalize? (or (not new-shape?) (not content-has-text?))) false - effective-save-undo?)] + effective-save-undo?) + + ;; Whether any content-changing edit happened this editing session. + session-touched? (some? (get-in state [:workspace-text-session-geom id])) + ;; A finalize on an existing shape that wasn't edited must not create any undo entry + ;; (exception being newly created shapes) + finalize-no-op? (and finalize? + (not new-shape?) + content-has-text? + (not session-touched?))] (rx/concat (rx/of @@ -1360,7 +1369,8 @@ :undo-group (when new-shape? id)}) ;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`. - (when (some? resize-modifiers) + ;; Skipped for a no-op finalize: applying it would record an undo transaction. + (when (and (some? resize-modifiers) (not finalize-no-op?)) (dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)}))) (when finalize? @@ -1378,7 +1388,7 @@ (dwsh/delete-shapes #{id}))) (rx/empty)) (rx/concat - (if content-has-text? + (if (and content-has-text? (not finalize-no-op?)) (rx/of (dch/commit-changes (build-finalize-commit-changes it state id From 59ef07633aae46450c7e8738ee8b1fd1bbd2ea86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 14 Aug 2026 20:14:29 +0200 Subject: [PATCH 109/298] :wrench: Align MCP workflow name with the rest of CI workflows The MCP workflow was named "MCP CI" while every other tests-*.yml workflow uses the "CI: " pattern. Rename it to "CI: MCP" for consistency in the GitHub Actions listing. --- .github/workflows/tests-mcp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 6f8eadcadd..317d82dca2 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -1,4 +1,4 @@ -name: "MCP CI" +name: "CI: MCP" on: pull_request: From aecfee0f02893ad0d1c212fc6f0b91b89237dff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 14 Aug 2026 20:14:29 +0200 Subject: [PATCH 110/298] :wrench: Align MCP workflow name with the rest of CI workflows The MCP workflow was named "MCP CI" while every other tests-*.yml workflow uses the "CI: " pattern. Rename it to "CI: MCP" for consistency in the GitHub Actions listing. --- .github/workflows/tests-mcp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 6f8eadcadd..317d82dca2 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -1,4 +1,4 @@ -name: "MCP CI" +name: "CI: MCP" on: pull_request: From 3033da4409e32e906fc8130ef27064ed6ea84fa7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:08:07 +0200 Subject: [PATCH 111/298] :bug: Add concurrency limit to import-binfile RPC handler (#11024) Apply climit with 4 global permits and 1 per-profile permit (queue 2) to prevent connection pool exhaustion from concurrent imports. Each import holds a DB connection for its entire duration with idle transaction timeout disabled, so unbounded concurrency could exhaust the pool (default 60 connections). AI-assisted-by: mimo-v2.5-pro --- backend/resources/climit.edn | 8 +++++++- backend/src/app/rpc/commands/binfile.clj | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/resources/climit.edn b/backend/resources/climit.edn index 66ac82b174..ded9b5c9b8 100644 --- a/backend/resources/climit.edn +++ b/backend/resources/climit.edn @@ -45,4 +45,10 @@ {:permits 4} :send-user-feedback/by-profile - {:permits 1 :queue 3}} + {:permits 1 :queue 3} + + :import-binfile/global + {:permits 4} + + :import-binfile/by-profile + {:permits 1 :queue 2}} diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index e68210aae3..685b450ecb 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -21,6 +21,7 @@ [app.loggers.webhooks :as-alias webhooks] [app.media.validation :as media.v] [app.rpc :as-alias rpc] + [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] [app.rpc.commands.media :as media-cmd] [app.rpc.commands.projects :as projects] @@ -142,7 +143,9 @@ ::webhooks/event? true ::sse/stream? true - ::sm/params schema:import-binfile} + ::sm/params schema:import-binfile + ::climit/id [[:import-binfile/by-profile ::rpc/profile-id] + [:import-binfile/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) (let [version (or version 3) From 68e1db984d71aa4fbddb8e497561fa7f4d22ebdf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:20:04 +0200 Subject: [PATCH 112/298] :sparkles: Add resolve-git-conflicts opencode command --- .opencode/commands/resolve-git-conflicts.md | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .opencode/commands/resolve-git-conflicts.md diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md new file mode 100644 index 0000000000..1b17ca0001 --- /dev/null +++ b/.opencode/commands/resolve-git-conflicts.md @@ -0,0 +1,40 @@ +--- +description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase +agent: build +--- + +# Fix Git Conflicts + +Resolve conflicts in the local repository. The user handles finishing the +rebase themselves — you must **never** run `git rebase --continue`, +`git rebase --skip`, `git merge --continue`, or anything similar. + +## Phase 1 — Understand the problem (read-only) + +1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. +2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: + - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). + - Inspect both sides — `git show :` and `git show :` — plus `git log`/`git show` on the commits involved to understand intent. + - Identify what each side changed and why, and how they should be combined. + +## Phase 2 — Present the resolution plan + +3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: + - What each side changed and why. + - Your proposed resolution and the reasoning behind it. + - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). +4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. +5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. + +## Phase 3 — Execute + +6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. + +## Phase 4 — Stage and verify + +7. **Stage every resolved file** with `git add `. Do not stage unrelated untracked files unless clearly part of the resolution. +8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. + +## Phase 5 — Report + +9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. From c688cba8d8eb2bbd2eb81cd2e220caccc03994f2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:21:25 +0200 Subject: [PATCH 113/298] :bug: Mock DNS resolution in SSRF tests for environments without public DNS (#11040) The validate-url-allows-public-{https,http} tests relied on real DNS resolution of example.com, which fails in containers without public DNS access. Mock resolve-host to return a known public IP, consistent with the pattern used by other tests in the same file. AI-assisted-by: mimo-v2.5-pro --- backend/test/backend_tests/util_ssrf_test.clj | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj index 2dadb8282a..c3b5b435bb 100644 --- a/backend/test/backend_tests/util_ssrf_test.clj +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -13,11 +13,25 @@ [clojure.test :as t])) (t/deftest validate-url-allows-public-https - (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) - (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))) + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "example.com") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")]) + (original hostname)))] + (t/is (true? (ssrf/safe-url? "https://example.com/foo"))) + (t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1")))))) (t/deftest validate-url-allows-public-http - (t/is (true? (ssrf/safe-url? "http://example.com/foo")))) + (let [original ssrf/resolve-host] + (with-redefs [ssrf/resolve-host + (fn [hostname] + (if (= hostname "example.com") + (into-array java.net.InetAddress + [(java.net.InetAddress/getByName "93.184.216.34")]) + (original hostname)))] + (t/is (true? (ssrf/safe-url? "http://example.com/foo")))))) (t/deftest validate-url-blocks-disallowed-schemes (t/is (false? (ssrf/safe-url? "file:///etc/passwd"))) From 9e97477a984a9e550efe126b110b7ceb2bf949ed Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:05:27 +0200 Subject: [PATCH 114/298] :arrow_up: Update to latest nodejs lts --- .nvmrc | 2 +- docker/devenv/Dockerfile | 2 +- docker/images/Dockerfile.exporter | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.nvmrc b/.nvmrc index 87d8620cc6..3648bfc346 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24.18.1 +v24.19.0 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 4aff930e9e..989fb39f63 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.18.1 \ +ENV NODE_VERSION=v24.19.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 7c0b1a14ff..97189bc5be 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.18.1-debian13-dev +FROM dhi.io/node:24.19.0-debian13-dev LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ From e219ce20eba7597a8c4924f2d7c49405ab142d3d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:05:42 +0200 Subject: [PATCH 115/298] :arrow_up: Update opencode version on devenv --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 989fb39f63..8a3d99216d 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.11 +ENV OPENCODE_VERSION=1.18.18 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From 5efd9cc3c5689485322f57b644b83a3bd2e33cee Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:37:51 +0200 Subject: [PATCH 116/298] :bug: Prevent admins from granting owner role in team invitations (#11099) Add role-ceiling check to create-team-invitations and update-team-invitation-role methods. These RPC methods allowed team admins to grant or elevate invitations to :owner role, bypassing the protection that exists in update-team-member-role. The fix replicates the existing check from update-team-member-role: reject promotion to :owner when the caller is not an owner. Closes #11098 AI-assisted-by: qwen3.7-plus --- .../app/rpc/commands/teams_invitations.clj | 12 +++ backend/test/backend_tests/rpc_team_test.clj | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 93b15df5c2..b96cb0a8ce 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -490,6 +490,13 @@ :code :insufficient-permissions :hint "Organization policy does not allow you to send invitations")) + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) + (or (= role :owner) + (some #(= :owner (:role %)) (:invitations params)))) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (when (> invitation-count max-invitations-by-request-threshold) (ex/raise :type :validation :code :max-invitations-by-request @@ -633,6 +640,11 @@ (ex/raise :type :validation :code :insufficient-permissions)) + ;; Don't allow promote to owner to admin users. + (when (and (not (:is-owner perms)) (= role :owner)) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + (db/update! conn :team-invitation {:role (name role) :updated-at (ct/now)} {:team-id team-id :email-to (profile/clean-email email)}) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 7f8fb136e1..4bca0fbaa2 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1207,3 +1207,102 @@ (let [team (:result out)] (t/is (uuid? (:id team))) (t/is (= "Test Team" (:name team))))))))) + +;; --- T7-F-01: Role ceiling in team invitations --- + +(t/deftest admin-cannot-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (emails+role format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-create-invitation-with-owner-role-invitations-format + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Admin tries to create invitation with :owner role (invitations format) + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id admin) + :team-id (:id team) + :invitations [{:email "invitee@example.com" :role :owner}]} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)) + (t/is (= 0 (:call-count @mock))))))) + +(t/deftest admin-cannot-update-invitation-role-to-owner + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Add admin as team member with :admin role + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + ;; Owner creates an invitation with :editor role + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out))) + + (th/reset-mock! mock) + + ;; Admin tries to update invitation role to :owner + ;; This should FAIL with :cant-promote-to-owner + (let [data {::th/type :update-team-invitation-role + ::rpc/profile-id (:id admin) + :team-id (:id team) + :email "invitee@example.com" + :role :owner} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-promote-to-owner)))))) + +(t/deftest owner-can-create-invitation-with-owner-role + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + ;; Owner creates invitation with :owner role + ;; This should SUCCEED (owner has full privileges) + (let [data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :owner + :emails ["invitee@example.com"]} + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))))))) From 0797d7235a608a105af2ac8317e6678bba802915 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:51:24 +0200 Subject: [PATCH 117/298] :bug: Fix workspace crash on rapid sidebar measures input changes (#10793) (#10794) The sidebar measures panel numeric inputs (X, Y, width, height, rotation) emitted one full apply-modifiers commit per DOM event with no throttle: every arrow key-repeat, wheel tick and scrub pointermove became update-positions / update-dimensions / increase-rotation. A sustained gesture starved the React renderer and crashed the workspace with error #185 (Maximum update depth exceeded). Coalesce those bursts at the data layer (potok), following the update-position-data debounce pattern in texts.cljs: - update-positions is now burst-coalesced in place (its only caller is the measures panel); new update-dimensions-coalesced and increase-rotation-coalesced variants are used by the measures panel, while the immediate events keep serving plugins, variants and token application (including the delta? rotation path). - The first event of a burst commits immediately (leading edge, so single edits stay synchronous); further ticks commit at most once per 50 ms (throttle); a trailing debounced flush guarantees the exact final value lands. All payloads are absolute values, so keeping the latest queued value per shape/attribute is lossless. - Pending payloads are drained atomically and stale shape ids (deleted mid-burst) are skipped. The drain stream lives until the workspace is finalized, so bursts reuse a single subscription. - Fewer commits per burst also means fewer undo entries; scrub drags still produce a single entry via the input's outer transaction. Tests: new frontend-tests.logic.sidebar-transform-coalescing-test (8 tests, legacy SVG and WASM renderer branches) guards the invariant that a 20-event burst commits the exact final value in a handful of commits. The previously unregistered update-position-test is wired into the runner with WASM mock fixtures (it fails in full-suite context without them due to a pre-existing global mock-state issue). AI-assisted-by: kimi-k3 --- frontend/src/app/main/constants.cljs | 6 + frontend/src/app/main/data/workspace.cljs | 2 + .../app/main/data/workspace/transforms.cljs | 164 ++++++++++++- .../sidebar/options/menus/measures.cljs | 4 +- .../sidebar_transform_coalescing_test.cljs | 226 ++++++++++++++++++ .../logic/update_position_test.cljs | 7 +- frontend/test/frontend_tests/runner.cljs | 4 + 7 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs index d473e621c6..40390e2ee4 100644 --- a/frontend/src/app/main/constants.cljs +++ b/frontend/src/app/main/constants.cljs @@ -341,3 +341,9 @@ (def ^:const resize-sample-time default-sample-time) (def ^:const rotation-sample-time default-sample-time) (def ^:const move-sample-time default-sample-time) + +(def ^:const sidebar-transform-sample-time + "Time in ms for coalescing sidebar measures-panel transform commits: at + most one full commit per window during a burst, plus a trailing flush + with the exact final value." + 50) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 9bf11fb701..72e5f93f3e 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1552,9 +1552,11 @@ (dm/export dwt/trigger-bounding-box-cloaking) (dm/export dwt/start-resize) (dm/export dwt/update-dimensions) +(dm/export dwt/update-dimensions-coalesced) (dm/export dwt/change-orientation) (dm/export dwt/start-rotate) (dm/export dwt/increase-rotation) +(dm/export dwt/increase-rotation-coalesced) (dm/export dwt/start-move-selected) (dm/export dwt/move-selected) (dm/export dwt/update-position) diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index d0e612b493..f53cf8b2ee 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -1125,19 +1125,169 @@ :ignore-touched (:ignore-touched options) :ignore-snap-pixel true})))))))) +;; -- Sidebar measures transform coalescing ---------------------------- + +;; The sidebar measures panel numeric inputs emit one event per DOM +;; gesture tick (held arrow keys, mouse wheel, scrub drags). Committing +;; each tick would run a full `apply-modifiers` per DOM event and starve +;; the renderer (React error #185). The events in this section coalesce +;; those bursts at the data layer: the first event of a burst commits +;; immediately (leading edge, so single edits stay synchronous), further +;; ticks commit at most once per `mconst/sidebar-transform-sample-time` +;; (throttle), and a trailing debounced flush guarantees the exact final +;; value lands. All payloads are absolute values, so keeping only the +;; latest queued value per shape/attribute is lossless. + +(defn- sidebar-commit-events + "Build the real commit events for a drained pending entry of `kind`, + skipping shapes that no longer exist on the queued page." + [state kind entry] + (let [options (:options entry) + page-id (or (:page-id options) (:current-page-id state)) + objects (dsh/lookup-page-objects state page-id) + options (assoc options :page-id page-id) + live-ids (fn [ids] (into [] (filter #(contains? objects %)) ids))] + (case kind + ::positions + (keep (fn [[id position]] + (when (contains? objects id) + (update-position id position options))) + (:positions entry)) + + ::dimensions + (let [ids (live-ids (:ids entry))] + (when (seq ids) + (map (fn [[attr value]] + (update-dimensions ids attr value options)) + (:values entry)))) + + ::rotation + (let [ids (live-ids (:ids entry))] + (when (seq ids) + [(increase-rotation ids (:value entry) nil :page-id page-id)]))))) + +(defn- flush-sidebar-transforms + "Internal: atomically drain the pending sidebar transform payloads and + emit their commit events. No-op when nothing is pending." + [] + (ptk/reify ::flush-sidebar-transforms + ptk/UpdateEvent + (update [_ state] + (let [pending (::pending-sidebar-transforms state)] + (-> state + (dissoc ::pending-sidebar-transforms) + (assoc ::flushing-sidebar-transforms pending)))) + + ptk/WatchEvent + (watch [_ state _] + (let [pending (::flushing-sidebar-transforms state)] + (rx/concat + (if (empty? pending) + (rx/empty) + (->> pending + (mapcat (fn [[kind entry]] (sidebar-commit-events state kind entry))) + (rx/from))) + (rx/of (fn [state] (dissoc state ::flushing-sidebar-transforms)))))))) + +(defn- queue-sidebar-transform + "Internal: accumulate the latest payload of `kind` with `update-entry` + (a fn from the previous pending entry to the new one). + + The very first queued event of the workspace session also installs the + drain stream that commits pending payloads: a leading flush for the + first event, at most one flush per + `mconst/sidebar-transform-sample-time` while a burst is ongoing + (throttle), and a trailing flush (debounce) that guarantees the exact + final value lands. The drain stream lives until the workspace is + finalized, so subsequent bursts reuse it." + [kind update-entry] + (let [cur-event (js/Symbol)] + (ptk/reify ::queue-sidebar-transform + ptk/UpdateEvent + (update [_ state] + (let [state (update-in state [::pending-sidebar-transforms kind] + (fn [entry] (update-entry (or entry {}))))] + (if (nil? (::sidebar-transform-drain state)) + (assoc state ::sidebar-transform-drain cur-event) + state))) + + ptk/WatchEvent + (watch [_ state stream] + (if (= cur-event (::sidebar-transform-drain state)) + (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (rx/merge + ;; Leading edge: commit the payload this first event queued. + (rx/of (flush-sidebar-transforms)) + ;; At most one commit per window while a burst is ongoing. + (->> stream + (rx/filter (ptk/type? ::queue-sidebar-transform)) + (rx/throttle mconst/sidebar-transform-sample-time) + (rx/map (fn [_] (flush-sidebar-transforms))) + (rx/take-until stopper)) + ;; Trailing edge: guarantee the exact final value lands. + (->> stream + (rx/filter (ptk/type? ::queue-sidebar-transform)) + (rx/debounce mconst/sidebar-transform-sample-time) + (rx/map (fn [_] (flush-sidebar-transforms))) + (rx/take-until stopper)))) + (rx/empty)))))) + (defn update-positions - "Move multiple shapes to a new position." + "Move multiple shapes to a new position, from the sidebar options form. + + Burst-coalesced (see `queue-sidebar-transform`): rapid successive calls + from the sidebar numeric inputs commit at most once per + `mconst/sidebar-transform-sample-time`, and the trailing flush commits + the exact final position. A single call still commits synchronously." ([ids position] (update-positions ids position nil)) ([ids position options] (assert (every? uuid? ids) "expected valid coll of uuids") (assert (map? position) "expected a valid map for `position`") - (ptk/reify ::update-positions - ptk/WatchEvent - (watch [_ _ _] - (->> ids - (map (fn [id] (update-position id position options))) - (rx/from)))))) + (queue-sidebar-transform + ::positions + (fn [entry] + (-> entry + (update :positions + (fn [positions] + (reduce (fn [positions id] + (update positions id merge position)) + (or positions {}) + ids))) + (assoc :options options)))))) + +(defn update-dimensions-coalesced + "Like `update-dimensions`, but burst-coalesced (see + `queue-sidebar-transform`); used by the sidebar measures panel numeric + inputs. The latest queued value per attribute wins." + ([ids attr value] (update-dimensions-coalesced ids attr value nil)) + ([ids attr value options] + (assert (number? value)) + (assert (every? uuid? ids) + "expected valid coll of uuids") + (assert (contains? #{:width :height} attr) + "expected valid attr") + (queue-sidebar-transform + ::dimensions + (fn [entry] + (-> entry + (assoc-in [:values attr] value) + (assoc :ids ids :options options)))))) + +(defn increase-rotation-coalesced + "Like `increase-rotation` with an absolute rotation value, but + burst-coalesced (see `queue-sidebar-transform`); used by the sidebar + measures panel rotation input. The latest queued absolute value wins; + the delta is recomputed from the current rotation when the burst + commits." + [ids rotation] + (assert (every? uuid? ids) + "expected valid coll of uuids") + (assert (number? rotation)) + (queue-sidebar-transform + ::rotation + (fn [entry] + (assoc entry :value rotation :ids ids :options nil)))) (defn position-shapes [shapes] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index f4a454af54..c9b3190574 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -374,7 +374,7 @@ (fn [value attr] (if (or (string? value) (number? value)) (st/emit! (udw/trigger-bounding-box-cloaking ids) - (udw/update-dimensions ids attr value)) + (udw/update-dimensions-coalesced ids attr value)) (st/emit! (udw/trigger-bounding-box-cloaking ids) (dwta/apply-token-from-input {:token (first value) :attrs #{attr} @@ -408,7 +408,7 @@ (if (or (string? value) (number? value)) (let [value (fixed-decimal-value value)] (st/emit! (udw/trigger-bounding-box-cloaking ids)) - (st/emit! (udw/increase-rotation ids value))) + (st/emit! (udw/increase-rotation-coalesced ids value))) (st/emit! (udw/trigger-bounding-box-cloaking ids) (dwta/apply-token-from-input {:token (first value) :attrs #{:rotation} diff --git a/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs new file mode 100644 index 0000000000..40c174d9cf --- /dev/null +++ b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs @@ -0,0 +1,226 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.sidebar-transform-coalescing-test + "Regression tests for the sidebar measures panel transform coalescing + (React error #185): a burst of numeric-input gestures (held arrow key, + wheel, scrub) must collapse to a handful of commits, and the trailing + flush must land the exact final value." + (:require + [app.common.geom.rect :as grc] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.main.data.workspace :as dw] + [app.main.data.workspace.transforms :as-alias dwt] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before (fn [] (thp/reset-idmap!) (thw/setup-wasm-mocks!)) + :after (fn [] (thw/teardown-wasm-mocks!))}) + +(def ^:private flush-wait-ms + "How long to keep the store running after a burst so the 50 ms + trailing flush fires before checking the final state." + 150) + +(defn- count-events + "Return an atom counting how many events of `type` get emitted on the + store input stream (i.e. the real commits triggered by the coalescer)." + [store type] + (let [counter (atom 0)] + (->> (ptk/input-stream store) + (rx/filter (ptk/type? type)) + (rx/tap (fn [_] (swap! counter inc))) + (rx/subs! (fn [_] nil))) + counter)) + +(defn- run-store-timed + "Like `ths/run-store`, but emits `:the/end` `wait-ms` after `events` + so the timer-based coalescing (throttle/debounce) gets to fire." + [store done events wait-ms completed-cb] + (->> (ptk/input-stream store) + (rx/filter #(= :the/end %)) + (rx/take 1) + (rx/tap (fn [_] (completed-cb @store))) + (rx/subs! (fn [_] nil) + (fn [cause] + (done) + (t/do-report {:type :error :message "Stream error" :actual cause})) + (fn [_] (done)))) + (doseq [event events] + (ptk/emit! store event)) + (js/setTimeout (fn [] (ptk/emit! store :the/end)) wait-ms)) + +(defn- burst + "A burst of `n` events built with `make-event`, like the stream of + calls a held arrow key or a scrub gesture produces." + [n make-event] + (mapv make-event (range 1 (inc n)))) + +;; --- Positions (update-positions, coalesced in place) ----------------- + +(t/deftest update-positions-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (burst 20 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)})))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + x (-> frame1' :points grc/points->rect :x)] + ;; The trailing flush lands the exact final value... + (t/is (= 120 x)) + ;; ...and the 20-event burst collapsed to a handful of commits. + (t/is (<= @commits 3)))))))) + +(t/deftest update-positions-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (burst 20 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)})))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + x (-> frame1' :points grc/points->rect :x)] + (t/is (= 120 x)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-positions-burst-merges-x-and-y + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + frame1 (cths/get-shape file :frame1) + commits (count-events store ::dwt/update-position) + events (into (burst 10 (fn [i] (dw/update-positions [(:id frame1)] {:x (+ 100 i)}))) + (burst 10 (fn [i] (dw/update-positions [(:id frame1)] {:y (+ 200 i)}))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + frame1' (cths/get-shape file' :frame1) + rect (-> frame1' :points grc/points->rect)] + ;; Partial position maps of the same shape merge, so the last + ;; value of each attribute lands. + (t/is (= 110 (:x rect))) + (t/is (= 210 (:y rect))) + (t/is (<= @commits 3)))))))) + +;; --- Dimensions (update-dimensions-coalesced) -------------------------- + +(t/deftest update-dimensions-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (burst 20 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + width (-> rect1' :points grc/points->rect :width)] + (t/is (= 120 width)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-dimensions-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (burst 20 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + width (-> rect1' :points grc/points->rect :width)] + (t/is (= 120 width)) + (t/is (<= @commits 3)))))))) + +(t/deftest update-dimensions-burst-merges-width-and-height + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/update-dimensions) + events (into (burst 10 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :width (+ 100 i)))) + (burst 10 (fn [i] (dw/update-dimensions-coalesced [(:id rect1)] :height (+ 200 i)))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1) + rect (-> rect1' :points grc/points->rect)] + ;; Each attribute keeps its own latest queued value. + (t/is (= 110 (:width rect))) + (t/is (= 210 (:height rect))) + ;; At most 3 flushes; the trailing one commits both pending + ;; attributes, hence 4 commit events. + (t/is (<= @commits 4)))))))) + +;; --- Rotation (increase-rotation-coalesced) ---------------------------- + +(t/deftest increase-rotation-burst-commits-exact-final-value-wasm + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/increase-rotation) + events (burst 20 (fn [i] (dw/increase-rotation-coalesced [(:id rect1)] (* i 3))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1)] + (t/is (= 60 (:rotation rect1'))) + (t/is (<= @commits 3)))))))) + +(t/deftest increase-rotation-burst-commits-exact-final-value-svg + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 0 :y 0 :width 100 :height 100)) + store (ths/setup-store file {:renderer :svg}) + rect1 (cths/get-shape file :rect1) + commits (count-events store ::dwt/increase-rotation) + events (burst 20 (fn [i] (dw/increase-rotation-coalesced [(:id rect1)] (* i 3))))] + (run-store-timed + store done events flush-wait-ms + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + rect1' (cths/get-shape file' :rect1)] + (t/is (= 60 (:rotation rect1'))) + (t/is (<= @commits 3)))))))) diff --git a/frontend/test/frontend_tests/logic/update_position_test.cljs b/frontend/test/frontend_tests/logic/update_position_test.cljs index 8c55dacd43..93b51f95d6 100644 --- a/frontend/test/frontend_tests/logic/update_position_test.cljs +++ b/frontend/test/frontend_tests/logic/update_position_test.cljs @@ -12,7 +12,12 @@ [app.common.test-helpers.shapes :as cths] [app.main.data.workspace :as dw] [cljs.test :as t :include-macros true] - [frontend-tests.helpers.state :as ths])) + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(t/use-fixtures :each + {:before (fn [] (thw/setup-wasm-mocks!)) + :after (fn [] (thw/teardown-wasm-mocks!))}) (t/deftest test-update-positions-multiple-ids (t/async diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index a29132a22e..31896b987e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -35,6 +35,8 @@ [frontend-tests.logic.groups-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.logic.sidebar-transform-coalescing-test] + [frontend-tests.logic.update-position-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -130,6 +132,8 @@ 'frontend-tests.logic.nudge-selected-shapes-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.main-errors-test + 'frontend-tests.logic.sidebar-transform-coalescing-test + 'frontend-tests.logic.update-position-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test From 4ecd8ffb89da83e959e7cf69d391358556bdc522 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 11:53:48 +0200 Subject: [PATCH 118/298] :bug: Fix crash when editing tokens with group nodes (#11144) Make sd-token-uuid nil-safe when accessing .original.id to prevent crashes when StyleDictionary emits group nodes alongside real tokens. Group nodes have an original object but no id property, causing undefined is not an object errors during interactive token resolution in the edit modal. Closes #11143 AI-assisted-by: qwen3.7-plus --- frontend/src/app/main/data/style_dictionary.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 446e130bdb..ba1f2dd67f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -557,7 +557,8 @@ (.. sd-token -original -name)) (defn sd-token-uuid [^js sd-token] - (uuid (.-uuid (.. sd-token -original -id)))) + (when-let [id (.. sd-token -original -id)] + (uuid (.-uuid id)))) (defn- merge-name-collisions "Re-attach tokens that `ctob/tokens-tree` / `backtrace-tokens-tree` From 509f5395cb68438b46c21dd214d0c316239692ef Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 12:08:38 +0200 Subject: [PATCH 119/298] :paperclip: Update changelog --- CHANGES.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 293ebff0c5..f76af25171 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,15 +1,30 @@ # CHANGELOG -## 2.17.1 (Unreleased) +## 2.17.1 ### :bug: Bugs fixed +- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619)) - Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645)) - Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655)) -- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736)) -- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777)) - Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778)) +- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718)) +- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721)) +- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736)) +- Fix dashboard sidebar throwing removeChild NotFoundError during rapid keyboard navigation [#10714](https://github.com/penpot/penpot/issues/10714) (PR: [#10715](https://github.com/penpot/penpot/pull/10715)) +- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777)) +- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782)) - Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805)) +- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808)) +- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812)) +- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845)) +- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852)) +- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870)) +- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881)) +- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898)) +- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) +- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) +- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967)) ## 2.17.0 From 29dbf9ab12c789b4b85e50d5c5b41c949553a0fa Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 12:13:56 +0200 Subject: [PATCH 120/298] :bug: Validate content-type on management upload endpoints (#11026) Add media type validation to upload-tempfile and upload-org-logo management endpoints. Both stored user-supplied mtype without checking against an allowlist. Only image types and PDF are permitted. Non-public bucket assets now also carry Content-Disposition: attachment to prevent inline rendering. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/http/assets.clj | 23 ++++++++++++------- backend/src/app/rpc/management/exporter.clj | 6 +++-- backend/src/app/rpc/management/nitrate.clj | 2 ++ .../backend_tests/rpc_management_test.clj | 16 +++++++++++++ common/src/app/common/media.cljc | 3 +++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 04dd7842ca..6258760548 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -49,12 +49,16 @@ [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] (let [sig-max-age (or signature-max-age default-signature-max-age) cch-max-age (or cache-max-age default-cache-max-age) - {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})] + {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age}) + bucket (-> obj meta :bucket) + headers (cond-> {"location" (str url) + "x-host" (cond-> host port (str ":" port)) + "x-mtype" (-> obj meta :content-type) + "cache-control" (str "max-age=" (inst-ms cch-max-age))} + (not (contains? public-buckets bucket)) + (assoc "content-disposition" "attachment"))] {::yres/status 307 - ::yres/headers {"location" (str url) - "x-host" (cond-> host port (str ":" port)) - "x-mtype" (-> obj meta :content-type) - "cache-control" (str "max-age=" (inst-ms cch-max-age))}})) + ::yres/headers headers})) (defn- serve-object-from-fs [{:keys [::path ::cache-max-age]} obj] @@ -62,9 +66,12 @@ purl (u/join (u/uri path) (sto/object->relative-path obj)) mdata (meta obj) - headers {"x-accel-redirect" (:path purl) - "content-type" (:content-type mdata) - "cache-control" (str "max-age=" (inst-ms cch-max-age))}] + bucket (:bucket mdata) + headers (cond-> {"x-accel-redirect" (:path purl) + "content-type" (:content-type mdata) + "cache-control" (str "max-age=" (inst-ms cch-max-age))} + (not (contains? public-buckets bucket)) + (assoc "content-disposition" "attachment"))] {::yres/status 204 ::yres/headers headers})) diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index aac508669d..f4b7d9547f 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -6,11 +6,12 @@ (ns app.rpc.management.exporter (:require + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as u] [app.config :as cf] - [app.media.validation :refer [schema:upload]] + [app.media.validation :as media.v] [app.rpc :as-alias rpc] [app.rpc.doc :as doc] [app.storage :as sto] @@ -21,7 +22,7 @@ (def ^:private schema:upload-tempfile-params [:map {:title "upload-templfile-params"} - [:content schema:upload]]) + [:content media.v/schema:upload]]) (def ^:private schema:upload-tempfile-result @@ -32,6 +33,7 @@ ::sm/params schema:upload-tempfile-params ::sm/result schema:upload-tempfile-result} [cfg {:keys [::rpc/profile-id content]}] + (media.v/validate-media-type! content cm/tempfile-types) (let [storage (sto/resolve cfg) hash (sto/calculate-hash (:path content)) data (-> (sto/content (:path content)) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 8f07c90612..5df54b7e7f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -12,6 +12,7 @@ [app.auth.oidc :as oidc] [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.organization :as cto] @@ -136,6 +137,7 @@ ::sm/result schema:upload-organization-logo-result ::nitrate/sso false} [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] + (media.v/validate-media-type! content cm/image-types) (when previous-id (sto/touch-object! storage previous-id)) (let [hash (sto/calculate-hash (:path content)) diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 601e8b3d35..2dd25694a1 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -57,6 +57,22 @@ (t/is (not= (get-in out1 [:result :id]) (get-in out2 [:result :id]))))) +(t/deftest upload-tempfile-rejects-html-content-type + ;; N2-13: upload-tempfile must reject non-allowed content types + (let [profile (th/create-profile* 1 {:is-active true}) + path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-") + _ (io/write* path "") + params {::th/type :upload-tempfile + ::rpc/profile-id (:id profile) + :content {:filename "evil.html" + :path path + :mtype "text/html" + :size 27}} + out (th/management-command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :media-type-not-allowed (th/ex-code (:error out)))))) + (t/deftest duplicate-file (let [storage (-> (:app.storage/storage th/*system*) (configure-storage-backend)) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 3507ba5f59..3d67bc75b6 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -22,6 +22,9 @@ "image/gif" "image/svg+xml"}) +(def tempfile-types + (conj image-types "application/pdf")) + (defn format->extension [format] (case format From 57c9c3f6a493f7fc87853c6d47e10c2cf10ef269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Mon, 17 Aug 2026 12:26:49 +0200 Subject: [PATCH 121/298] :bug: Fix sso error message (#11252) --- backend/src/app/auth/oidc.clj | 13 +++++++++---- frontend/src/app/main/ui/routes.cljs | 20 +++++++++++--------- frontend/src/app/main/ui/static.cljs | 8 ++++---- frontend/translations/en.po | 4 ++-- frontend/translations/es.po | 4 ++-- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 715661b234..c636aeba24 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -648,10 +648,12 @@ (redirect-response uri)))) (defn- redirect-with-organization-sso-error - [{:keys [dest-url organization-id]}] + [{:keys [dest-url organization-id organization-name]}] (-> (str (or dest-url (cf/get :public-uri))) (u/append-query-param :sso-error true) (u/append-query-param :organization-id organization-id) + (cond-> organization-name + (u/append-query-param :organization-name organization-name)) (redirect-response))) (defn- redirect-to-register @@ -920,9 +922,12 @@ (l/err :hint "unexpected error on organization sso callback" :organization-id (:organization-id state) :cause cause)))) - (redirect-with-organization-sso-error - {:dest-url dest-url - :organization-id (:organization-id state)}))))) + (let [organization-id (:organization-id state) + organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))] + (redirect-with-organization-sso-error + {:dest-url dest-url + :organization-id organization-id + :organization-name organization-name})))))) (defn- callback-handler [cfg {:keys [params] :as request}] diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index cc0d07537d..0a898d7992 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -149,22 +149,24 @@ (defn- handle-sso-error-and-navigate "Check if the current route has an SSO error marker. If so, assign an - exception with type :sso-error and organization-id from query params, + exception with type :sso-error and organization-id/name from query params, and deliberately do NOT proceed with normal navigation: emitting `rt/navigated` would clear the exception that was just assigned. Otherwise, delegate to `check-sso-and-navigate`." [match send-event-info? url] - (let [route-name (name (get-in match [:data :name])) - sso-error? (some? (get-in match [:query-params :sso-error])) - organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) - team-id-str (or (get-in match [:query-params :team-id]) - (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes - team-id (some-> team-id-str uuid/parse*) - is-workspace? (str/starts-with? route-name "workspace") - is-dashboard? (str/starts-with? route-name "dashboard")] + (let [route-name (name (get-in match [:data :name])) + sso-error? (some? (get-in match [:query-params :sso-error])) + organization-id (some-> (get-in match [:query-params :organization-id]) uuid/parse*) + organization-name (some-> (get-in match [:query-params :organization-name]) str/trim) + team-id-str (or (get-in match [:query-params :team-id]) + (get-in match [:params :path :team-id])) ;; Fallback: team-id may be in path params for workspace routes + team-id (some-> team-id-str uuid/parse*) + is-workspace? (str/starts-with? route-name "workspace") + is-dashboard? (str/starts-with? route-name "dashboard")] (if sso-error? (st/emit! (rt/assign-exception {:type :sso-error :organization-id organization-id + :organization-name organization-name :team-id team-id :is-workspace is-workspace? :is-dashboard is-dashboard?})) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 9232c6598c..95b73d0191 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -484,7 +484,7 @@ `request-dialog*` used by the no-permission dialogs) when the organization SSO exchange with the identity provider fails." {::mf/private true} - [{:keys [organization-id team-id profile is-workspace is-dashboard]}] + [{:keys [organization-id team-id profile is-workspace is-dashboard organization-name]}] (let [clean-url (mf/with-memo [] (-> (rt/get-current-href) @@ -520,7 +520,7 @@ [:> context-wrapper* {:is-dashboard (or is-dashboard (not is-workspace)) :is-workspace is-workspace :profile profile} - [:> request-dialog* {:title (tr "labels.sso-error.title") + [:> request-dialog* {:title (tr "labels.sso-error.title", organization-name) :content [(tr "labels.sso-error.desc-message")] :button-text (tr "labels.sso-error.retry") :on-button-click on-retry @@ -532,7 +532,6 @@ [{:keys [data] :as props}] (let [type (get data :type) cause (get data ::errors/instance) - organization-id (get data :organization-id) report (mf/with-memo [cause] (when (ex/exception? cause) @@ -564,7 +563,8 @@ [:> nitrate-unavailable*] :sso-error - [:> sso-error-section* {:organization-id organization-id + [:> sso-error-section* {:organization-id (get data :organization-id) + :organization-name (get data :organization-name) :team-id (get data :team-id) :profile (mf/deref refs/profile) :is-workspace (get data :is-workspace false) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index ff36a11deb..014b63b258 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10338,10 +10338,10 @@ msgid "notifications.invitation-canceled" msgstr "This invitation is no longer available." msgid "labels.sso-error.title" -msgstr "We couldn't sign you in to your organization" +msgstr "We couldn't sign you in to %s" msgid "labels.sso-error.desc-message" -msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet. Your Penpot account isn't affected." +msgstr "Sign-in with your organization's identity provider didn't complete. The provider may be unavailable, or your account may not be in its directory yet." msgid "labels.sso-error.retry" msgstr "Try again" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 28c0689ba4..eacd139708 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9987,10 +9987,10 @@ msgid "notifications.invitation-canceled" msgstr "Esta invitación ya no está disponible." msgid "labels.sso-error.title" -msgstr "No pudimos iniciar sesión en tu organización" +msgstr "No pudimos iniciar sesión en %s" msgid "labels.sso-error.desc-message" -msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio. Tu cuenta de Penpot no se ha visto afectada." +msgstr "El inicio de sesión con el proveedor de identidad de tu organización no se completó. Es posible que el proveedor no esté disponible o que tu cuenta aún no esté en su directorio." msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" From ed04d509ed4bb99f41c9cc4ccf8f7e3d7ba3f0b0 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 17 Aug 2026 12:40:18 +0200 Subject: [PATCH 122/298] :bug: Fix bad managed error on backend sso failure (#11247) --- backend/src/app/rpc.clj | 40 +-- backend/src/app/rpc/commands/nitrate.clj | 15 +- .../test/backend_tests/rpc_nitrate_test.clj | 120 ++++++++- frontend/src/app/main/data/nitrate.cljs | 19 +- frontend/src/app/main/errors.cljs | 86 ++++++- .../test/frontend_tests/main_errors_test.cljs | 237 +++++++++++++++++- 6 files changed, 487 insertions(+), 30 deletions(-) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index c7e33312ca..bdc912ef2d 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -261,23 +261,28 @@ (defn- wrap-nitrate-sso "Enforce Nitrate organization SSO authentication for RPC handlers. - Resolves the organization/team context from request params using priority order: - 1. Explicit :organization-id param - 2. Explicit :team-id param - 3. Explicit :project-id param -> lookup project.team_id - 4. Explicit :file-id param -> lookup file's team via join - 5. :id param dispatched by ::rpc/id-type metadata (:team, :project, or :file) + Resolves the organization/team context from request params: + 1. Explicit :organization-id param identifies the organization directly + 2. The team comes from the first available of: explicit :team-id, explicit + :project-id -> lookup project.team_id, explicit :file-id -> lookup file's + team via join, or the :id param dispatched by ::rpc/id-type metadata + (:team, :project, or :file) Once the context is resolved, checks if the user is authorized within that organization's - SSO session using nitrate/sso-session-authorized?. Authorized results are cached - by [profile-id cache-ref] for 15 minutes to avoid repeated lookups. + SSO session using nitrate/sso-session-authorized?, against the organization when it is + known and against the team otherwise. The team is resolved either way, so the raised + error can carry it. Authorized results are cached by [profile-id cache-ref] for 15 + minutes to avoid repeated lookups. Only activates when: - Nitrate flag is enabled - Endpoint requires authentication (::auth true by default) - Endpoint is not marked with ::nitrate/organization-sso false - Raises :nitrate-sso-required error if user is not authorized in the organization." + Raises :nitrate-sso-required error if user is not authorized in the organization. + The error carries the resolved :organization-id and :team-id so the client can + restart the SSO flow (via :check-nitrate-sso) instead of reporting a plain + permission failure." [_ f mdata] (if (and (contains? cf/flags :admin-console) (::auth mdata true) ;; only for endpoints that needs auth @@ -302,17 +307,22 @@ cached (cache/get organization-sso-auth-cache cache-key) result (if (some? cached) cached - (let [team-id (when-not organization-id - (or team-id - (when project-id - (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + ;; The team is resolved even when the organization is + ;; already known: the client needs it to restart the + ;; SSO flow without sending non-members through the + ;; organization's identity provider. + (let [team-id (or team-id + (when project-id + (:team-id (db/get-by-id cfg :project project-id {:columns [:id :team-id]}))) + (when file-id (:id (teams/get-team-for-file cfg file-id)))) request (-> (meta params) (get ::http/request)) {:keys [authorized sso]} (if organization-id (nitrate/sso-session-authorized? cfg organization-id nil request) (nitrate/sso-session-authorized? cfg nil team-id request)) entry {:authorized authorized - :organization-id (:organization-id sso)}] + :organization-id (or (:organization-id sso) organization-id) + :team-id team-id}] (when authorized (cache/get organization-sso-auth-cache cache-key (constantly entry))) entry))] @@ -320,6 +330,8 @@ (f cfg params) (ex/raise :type :authentication :code :nitrate-sso-required + :organization-id (:organization-id result) + :team-id (:team-id result) :hint "organization SSO authentication required"))) (f cfg params)))) f)) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 76b34e8be8..c48834662e 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -673,10 +673,15 @@ (sv/defmethod ::check-nitrate-sso "Check if a user needs to login into the organization SSO. Accepts either team-id (to look up the organization via the team) or organization-id directly. - Returns {:authorized true} when SSO is not active or the user cannot access the team. + Returns {:authorized true :reason :sso-satisfied} when SSO is not active or the + session already holds a valid entry for the organization, and + {:authorized true :reason :no-team-access} when the gate was skipped because the + user cannot access the team; the reason lets the client tell a usable session + apart from a plain permission failure. Returns {:authorized false :redirect-uri } when SSO is active; the client must redirect there. The OIDC provider itself handles - re-authentication transparently if the user already has an active SSO session." + re-authentication transparently if the user already has an active SSO session. + A nil :redirect-uri means SSO is required but the provider is not usable." {::rpc/auth true ::doc/added "2.18" ::sm/params schema:check-nitrate-sso @@ -687,11 +692,11 @@ (not (teams/has-read-permissions? cfg profile-id team-id))) ;; Let the destination RPC enforce its own permissions. Starting SSO before ;; access is established sends unrelated users through the organization's IdP. - {:authorized true} + {:authorized true :reason :no-team-access} (let [request (rph/get-request params) {:keys [authorized sso]} (nitrate/sso-session-authorized? cfg organization-id team-id request)] (if authorized - {:authorized true} + {:authorized true :reason :sso-satisfied} (if (oidc/organization-sso-discovery-uri sso) {:authorized false :redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso @@ -699,4 +704,4 @@ :organization-id organization-id)} {:authorized false :redirect-uri nil})))) - {:authorized true})) + {:authorized true :reason :sso-satisfied})) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 640e68f2fc..d2ce0043bf 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -15,15 +15,17 @@ [app.db :as-alias db] [app.email :as eml] [app.http :as-alias http] + [app.http.errors :as http-errors] [app.nitrate :as nitrate] - [app.rpc :as-alias rpc] + [app.rpc :as rpc] [app.rpc.commands.nitrate] [app.rpc.commands.teams :as teams] [app.rpc.helpers :as rph] [backend-tests.helpers :as th] [buddy.core.codecs :as bc] [clojure.test :as t] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [yetti.response :as-alias yres])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -87,6 +89,31 @@ nil))) +(defn- unauthorized-sso-mock + "Creates a mock for nitrate/sso-session-authorized? that reports an active + SSO the session does not satisfy. Pass nil to leave the organization out of + the nitrate payload." + [organization-id] + (fn [_cfg _organization-id _team-id _request] + {:authorized false + :sso (cond-> {:active true + :issuer "https://idp.example.com"} + (some? organization-id) + (assoc :organization-id organization-id))})) + +(defn- sso-gate-error + "Builds the SSO gate around a handler that must never be reached, and + returns the exception it raises for `params`." + [mdata params cfg] + (let [handler (fn [_cfg _params] ::handler-called) + wrapped (binding [cf/flags (conj cf/flags :admin-console)] + (#'rpc/wrap-nitrate-sso nil handler mdata))] + (try + (wrapped cfg (with-meta params {::http/request {}})) + nil + (catch Throwable cause + cause)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -112,7 +139,9 @@ (constantly "https://idp.example.com/authorize")] (let [out (th/command! params)] (t/is (th/success? out)) - (t/is (= {:authorized true} (:result out)))))))) + ;; The reason tells the client this is a permission problem, not a + ;; usable SSO session. + (t/is (= {:authorized true :reason :no-team-access} (:result out)))))))) (t/deftest check-nitrate-sso-keeps-gate-for-team-member (let [team-owner (th/create-profile* 1 {:is-active true}) @@ -169,6 +198,91 @@ :redirect-uri redirect-uri} (:result out)))))))) +(t/deftest check-nitrate-sso-reports-a-satisfied-gate-for-a-valid-session + (let [team-owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id team-owner) + :team-id (:id team) + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :admin-console)] + (with-redefs [nitrate/sso-session-authorized? + (fn [_cfg _organization-id _team-id _request] + {:authorized true + :sso {:active true + :issuer "https://idp.example.com" + :organization-id organization-id}})] + (let [out (th/command! params)] + (t/is (th/success? out)) + (t/is (= {:authorized true :reason :sso-satisfied} (:result out)))))))) + +(t/deftest nitrate-sso-required-error-resolves-the-team-from-the-file + (t/testing "the workspace path, where the file id arrives as :id, still reports the team" + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)] + (let [data (ex-data (sso-gate-error {::rpc/id-type :file} + {::rpc/profile-id (:id profile) + :id (:id file)} + th/*system*))] + (t/is (= :authentication (:type data))) + (t/is (= :nitrate-sso-required (:code data))) + (t/is (= organization-id (:organization-id data))) + (t/is (= (:default-team-id profile) (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-keeps-the-team-known-by-the-request + (t/testing "an explicit team-id is not dropped by an explicit organization-id" + (let [profile-id (uuid/random) + team-id (uuid/random) + organization-id (uuid/random)] + ;; The nitrate payload carries no organization-id here, so the one from + ;; the request params is the only one left to report. + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)] + (let [data (ex-data (sso-gate-error {} + {::rpc/profile-id profile-id + :team-id team-id + :organization-id organization-id} + {}))] + (t/is (= organization-id (:organization-id data))) + (t/is (= team-id (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-resolves-the-team-with-a-known-organization + (t/testing "knowing the organization does not stop the team lookup" + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile)}) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock nil)] + (let [data (ex-data (sso-gate-error {} + {::rpc/profile-id (:id profile) + :organization-id organization-id + :file-id (:id file)} + th/*system*))] + (t/is (= organization-id (:organization-id data))) + (t/is (= (:default-team-id profile) (:team-id data)))))))) + +(t/deftest nitrate-sso-required-error-reaches-the-client-in-the-401-body + (t/testing "the ids survive the http error response, not only the exception" + (let [profile-id (uuid/random) + team-id (uuid/random) + organization-id (uuid/random)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)] + (let [cause (sso-gate-error {} + {::rpc/profile-id profile-id + :team-id team-id} + {}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 401 (::yres/status response))) + (t/is (= :nitrate-sso-required (:code body))) + (t/is (= organization-id (:organization-id body))) + (t/is (= team-id (:team-id body)))))))) + (t/deftest leave-organization-happy-path-no-extra-teams (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 3470dcc81d..81008c3fff 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -352,19 +352,30 @@ (rx/empty))))))))))) +(defn check-organization-sso + "Asks the backend whether the organization SSO gate can be satisfied for + `dest-url`, returning an observable of the raw `:check-nitrate-sso` + result: `:authorized` with a `:reason` of `:sso-satisfied` or + `:no-team-access`, or `:authorized false` with a `:redirect-uri` (nil + when SSO is required but the provider is unusable). Failures are not + caught, so a network blip stays a network error for the caller to + handle instead of masquerading as an answer." + [{:keys [team-id organization-id dest-url]}] + (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id + :organization-id organization-id + :url dest-url}))) + (defn retry-organization-sso "Retries the organization SSO login flow after a failed attempt, reusing the same check-nitrate-sso RPC used elsewhere to move the user through the organization's identity provider. Passing `team-id` enables the backend's non-member short-circuit. Falls back to navigating straight to `dest-url` when no fresh SSO redirect is needed or available." - [{:keys [team-id organization-id dest-url]}] + [{:keys [dest-url] :as params}] (ptk/reify ::retry-organization-sso ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! :check-nitrate-sso (d/without-nils {:team-id team-id - :organization-id organization-id - :url dest-url})) + (->> (check-organization-sso params) (rx/map (fn [{:keys [redirect-uri]}] (rt/nav-raw :uri (or redirect-uri dest-url)))) (rx/catch (fn [_] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index de4ca4f5bd..f8b5ad9dc4 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -13,6 +13,7 @@ [app.main.data.auth :as da] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.notifications :as ntf] [app.main.data.workspace :as-alias dw] [app.main.router :as rt] @@ -21,6 +22,7 @@ [app.util.globals :as g] [app.util.i18n :refer [tr]] [app.util.timers :as ts] + [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) @@ -234,9 +236,8 @@ ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception ;; for show the error page. Otherwise this explicitly clears all -;; profile data and redirect the user to the login page. This is here -;; and not in app.main.errors because of circular dependency. -(defmethod ptk/handle-error :authentication +;; profile data and redirect the user to the login page. +(defn- show-authentication-error [error] (let [message (tr "errors.auth.unable-to-login") uri (rt/get-current-href) @@ -253,6 +254,85 @@ (st/emit! (da/logout)) (ts/schedule 500 #(st/emit! (ntf/warn message))))))) +;; The user does belong to an organization with SSO active, but there is +;; no provider to send them to (unusable or incomplete SSO config). Show +;; the SSO error dialog, which offers an explicit retry, rather than +;; claiming they have no access. +(defn- show-sso-error + [{:keys [organization-id team-id]}] + (let [uri (rt/get-current-href)] + (st/async-emit! + (rt/assign-exception {:type :sso-error + :organization-id organization-id + :team-id team-id + :is-workspace (str/includes? uri "workspace") + :is-dashboard (str/includes? uri "dashboard")})))) + +;; A page issues many SSO-guarded requests at once, and all of them fail +;; together the moment the organization SSO session lapses; without this +;; only-one-in-flight guard each of them would start its own identity +;; provider round-trip. +(def ^:private sso-renewal-pending? (volatile! false)) + +(defn- renew-organization-sso + "Recover from a request rejected by the organization SSO gate. + + Asks the backend what can be done for the current location and acts on + the answer: go through the identity provider when there is one (it + re-authenticates transparently while the user still has a live session + with it), retry the location when the gate turns out to be satisfied + already (another tab renewed the session, or SSO was turned off), show + the SSO error dialog when SSO is required but unusable, and report a + permission failure only when the user really has no access to the team. + A failing check is left to the generic error handling, so a network + blip is not turned into a permission error." + [{:keys [organization-id team-id] :as error}] + (when-not @sso-renewal-pending? + (vreset! sso-renewal-pending? true) + (let [dest-url (rt/get-current-href)] + (->> (dnt/check-organization-sso + {:organization-id organization-id + :team-id team-id + :dest-url dest-url}) + ;; Release the guard however the check ends, including an + ;; unsubscription or a completion without a result: a stuck guard + ;; would silently drop every later rejection. + (rx/finalize (fn [] (vreset! sso-renewal-pending? false))) + (rx/subs! (fn [{:keys [authorized reason redirect-uri]}] + (cond + ;; SSO must be renewed and we know where to send them + (some? redirect-uri) + (st/emit! (rt/nav-raw :uri (str redirect-uri))) + + ;; The gate is satisfied after all, so the request + ;; that failed can be retried. Only an affirmative + ;; reason is accepted here: reloading on any + ;; unrecognized "authorized" answer would spin + ;; whenever the reload hits the same rejection. + (= :sso-satisfied reason) + (st/emit! (rt/reload false)) + + ;; SSO is required but the provider is unusable + (not authorized) + (show-sso-error error) + + ;; No access to the team, so the gate was never + ;; evaluated: this really is a permission failure + :else + (show-authentication-error error))) + on-error))))) + +(defmethod ptk/handle-error :authentication + [error] + ;; Without an organization or a team there is nothing to check, and asking + ;; anyway would fail schema validation and report that instead of the + ;; authentication problem the user actually hit. + (if (and (= :nitrate-sso-required (get error :code)) + (or (some? (get error :organization-id)) + (some? (get error :team-id)))) + (renew-organization-sso error) + (show-authentication-error error))) + ;; Error that happens on an active business model validation does not ;; passes an validation (example: profile can't leave a team). From ;; the user perspective a error flash message should be visualized but diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index d09024ac5c..fbbf852c82 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -11,10 +11,17 @@ - stale-asset-error? – pure predicate - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations - - flash schedules async emit – ntf/show is not emitted synchronously" + - flash schedules async emit – ntf/show is not emitted synchronously + - organization SSO recovery – expired SSO sessions go back to the provider" (:require [app.main.errors :as errors] + [app.main.repo :as rp] + [app.main.router :as rt] + [app.main.store :as st] + [app.util.timers :as tm] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) ;; --------------------------------------------------------------------------- @@ -134,3 +141,231 @@ (errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"})) ;; The guard must have allowed only the first invocation through. (t/is (= 1 @reentrant-call-count)))) + +;; --------------------------------------------------------------------------- +;; Expired organization SSO session +;; +;; The backend rejects SSO-guarded requests with an :authentication error +;; coded :nitrate-sso-required once the organization SSO session lapses. +;; The user must be sent back through the identity provider instead of +;; being told they have no access to the file. +;; --------------------------------------------------------------------------- + +(def ^:private workspace-href + "https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9") + +(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11") + +(defn- sso-required-error + [] + {:type :authentication + :code :nitrate-sso-required + :organization-id organization-id + :team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"}) + +(t/deftest expired-organization-sso-navigates-to-identity-provider + (t/testing "the browser is sent to the identity provider instead of an error page" + (let [events (atom [])] + (with-redefs [rp/cmd! + (mock/stub + (fn [_command _params] + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + + rt/get-current-href + (constantly workspace-href) + + st/emit! + (mock/stub (fn [& emitted] (swap! events into emitted)))] + + (errors/on-error (sso-required-error)) + + (t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))) + +(t/deftest expired-organization-sso-comes-back-to-the-current-location + (t/testing "the SSO check asks the provider to return the user where they were" + (let [rpc-calls (atom [])] + (with-redefs [rp/cmd! + (mock/stub + (fn [command params] + (swap! rpc-calls conj {:command command :params params}) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + + rt/get-current-href + (constantly workspace-href) + + st/emit! mock/noop] + + (errors/on-error (sso-required-error)) + + (t/is (= [{:command :check-nitrate-sso + :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" + :organization-id organization-id + :url workspace-href}}] + @rpc-calls)))))) + +(t/deftest already-satisfied-organization-sso-retries-the-location + (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" + (let [events (atom [])] + (with-redefs [rp/cmd! + (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :sso-satisfied}))) + + rt/get-current-href + (constantly workspace-href) + + st/emit! + (mock/stub (fn [& emitted] (swap! events into emitted)))] + + (errors/on-error (sso-required-error)) + + (t/is (= [::rt/reload] (mapv ptk/type @events))))))) + +(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog + (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" + (let [assigned* (atom nil)] + (with-redefs [rp/cmd! + (mock/stub + (fn [_command _params] + (rx/of {:authorized false :redirect-uri nil}))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))] + + (errors/on-error (sso-required-error)) + + (t/is (= :sso-error (:type @assigned*))) + (t/is (= organization-id (:organization-id @assigned*))) + (t/is (true? (:is-workspace @assigned*))))))) + +(t/deftest organization-sso-without-team-access-reports-a-permission-failure + (t/testing "a user who cannot reach the team keeps getting the authentication error" + (let [assigned* (atom nil)] + (with-redefs [rp/cmd! + (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :no-team-access}))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))] + + (errors/on-error (sso-required-error)) + + (t/is (= :authentication (:type @assigned*))) + (t/is (= :nitrate-sso-required (:code @assigned*))))))) + +(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization + (t/testing "reloading on an answer we don't understand would spin on the same rejection" + (let [events (atom [])] + (with-redefs [rp/cmd! + (mock/stub (fn [_command _params] (rx/of {:authorized true}))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] (ptk/data-event ::assigned error)) + + ;; async-emit! is variadic-only, so the replacement must be + ;; variadic too for the compiled static dispatch to find it + st/async-emit! + (fn [& emitted] (swap! events into emitted))] + + (errors/on-error (sso-required-error)) + + (t/is (= [::assigned] (mapv ptk/type @events))))))) + +(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives + (t/testing "with no organization and no team there is nothing to check" + (let [rpc-calls (atom 0) + assigned* (atom nil)] + (with-redefs [rp/cmd! + (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))] + + (errors/on-error {:type :authentication + :code :nitrate-sso-required}) + + (t/is (zero? @rpc-calls)) + (t/is (= :nitrate-sso-required (:code @assigned*))))))) + +(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections + (t/testing "the one-in-flight guard is released even when no answer arrives" + (let [rpc-calls (atom 0)] + (with-redefs [rp/cmd! + (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + + rt/get-current-href + (constantly workspace-href) + + st/emit! mock/noop] + + (errors/on-error (sso-required-error)) + (errors/on-error (sso-required-error)) + + (t/is (= 2 @rpc-calls)))))) + +;; A failing check must stay a failing check: the generic handling turns it +;; into a toast, whereas swallowing it would show a permission error for +;; what may be a momentary network blip. The mocked RPC fails on a later +;; tick, like a real request, so the handler is not inside on-error's +;; re-entrancy guard when the failure arrives. + +(def ^:private check-failures (atom [])) + +(defmethod ptk/handle-error ::test-check-failure + [error] + (swap! check-failures conj error)) + +(t/deftest failing-organization-sso-check-is-not-reported-as-missing-access + (t/async done + (reset! check-failures []) + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! + (mock/stub + (fn [_command _params] + (->> (rx/timer 0) + (rx/mapcat (fn [_] + (rx/throw (ex-info "boom" {:type ::test-check-failure}))))))) + + rt/get-current-href + (constantly workspace-href) + + rt/assign-exception + (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + + (fn [done'] + (errors/on-error (sso-required-error)) + (tm/schedule + 50 + (fn [] + (t/is (= [::test-check-failure] (mapv :type @check-failures))) + (t/is (nil? @assigned*)) + (done')))) + done)))) From f96d850049e07709480f867a15fa0ace1156e47c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 13:31:24 +0200 Subject: [PATCH 123/298] :paperclip: Add ste skill to opencode --- .opencode/skills/ste/SKILL.md | 78 +++++++++++++++++++ .opencode/skills/ste/references/examples.md | 67 ++++++++++++++++ .../ste/references/word-substitutions.md | 68 ++++++++++++++++ AGENTS.md | 6 ++ 4 files changed, 219 insertions(+) create mode 100644 .opencode/skills/ste/SKILL.md create mode 100644 .opencode/skills/ste/references/examples.md create mode 100644 .opencode/skills/ste/references/word-substitutions.md diff --git a/.opencode/skills/ste/SKILL.md b/.opencode/skills/ste/SKILL.md new file mode 100644 index 0000000000..a53456ccf0 --- /dev/null +++ b/.opencode/skills/ste/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ste +description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it. +--- + +# ASD-STE100 Simplified Technical English + +Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it. + +Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance. + +## Step 0 — Classify the text + +Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section. + +## Core rules + +### Sentences +- Procedural: maximum **20 words** per sentence. +- Descriptive: maximum **25 words** per sentence. +- Maximum **6 sentences** per paragraph. One topic per paragraph. +- One instruction per sentence. Two actions in one sentence only if they occur at the same time. +- Put a condition BEFORE its command: "If the pressure decreases, close the valve." +- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure." +- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word. + +### Verbs +- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective. +- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form. +- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging"). +- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant. +- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened." +- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file." +- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur." +- No phrasal verbs: "go down" → "decrease," "set up" → "install," "carry out" → "do." + +### Words +- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it. +- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill. +- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb. +- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle." +- American English spelling. +- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc." + +### Punctuation +- No semicolons — write two sentences. +- Parentheses only for references, abbreviations, and item numbers. +- Hyphenate words that act as one unit; a hyphenated word counts as one word. +- No contractions. + +### Warnings, cautions, notes +- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction. +- Start a warning or caution with the command or condition, then give the risk: + "WARNING: Do not touch the terminal. The terminal has a dangerous voltage." +- Notes obey the 25-word descriptive limit. + +## Step 2 — Self-check pass + +After drafting, scan your text once for each of these and fix every hit before you respond: + +1. Any sentence over the 20/25-word limit for its type +2. Contractions, semicolons +3. "should," "would," "could," "may," "might" +4. "has been," "have been," "had been," "is being," "was being" +5. -ing words used as verbs +6. Missing articles (a/an/the/this) before nouns +7. Synonym rotation (the same object under two names) +8. Any word in the unapproved column of `references/word-substitutions.md` +9. Warnings that state the risk before the command + +## Reference files + +- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short. +- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies. + +## What NOT to touch + +Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them. diff --git a/.opencode/skills/ste/references/examples.md b/.opencode/skills/ste/references/examples.md new file mode 100644 index 0000000000..b111db6229 --- /dev/null +++ b/.opencode/skills/ste/references/examples.md @@ -0,0 +1,67 @@ +# Worked before/after examples + +## Verb forms + +| Before | After | +|---|---| +| We have received the technical reports from HQ. | We received the technical reports from HQ. | +| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. | +| The test is continued by the operator. | Continue the test. | +| The screws should be replaced. | Replace the screws. | +| The system is currently running diagnostics. | The system does diagnostic tests now. | + +## Vocabulary and phrasing + +| Before | After | +|---|---| +| Ensure file exists before running. | Make sure that the file exists before you run the command. | +| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. | +| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. | +| Make sure that these steps are followed. | Obey these steps. | +| Utilize approximately 3 liters of water. | Use about 3 liters of water. | +| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. | + +## Noun clusters + +| Before | After | +|---|---| +| Main gear door retraction winch handle | Main-gear-door retraction-winch handle | +| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection | +| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. | + +## Procedural rewrite (condition first, one instruction per sentence) + +Before: +> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air. + +After: +> 1. Fill the reservoir with the correct fluid. +> 2. Attach a clear tube to the bleed screw. +> 3. Put the free end of the tube in a container of fluid. +> 4. Push the pedal three times. Hold the pedal down. +> 5. Open the bleed screw one half turn. Air and fluid flow into the tube. +> 6. Close the bleed screw. Release the pedal. +> 7. If air continues to come out, do steps 4 thru 6 again. + +## Warnings and cautions (command first, then risk) + +Before: +> Note that serious data loss may potentially occur if the --force flag is used against production. + +After: +> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source. + +Before: +> Touching the terminal could result in electrocution. + +After: +> WARNING: Do not touch the terminal. The terminal has a dangerous voltage. + +## Common mistakes checklist + +- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket." +- Synonym rotation: check/verify/confirm for the same action → one term, everywhere. +- Hedges: "you may want to," "it is recommended that" → an imperative or "must." +- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step. +- Semicolon joining two clauses → two sentences. +- "There are three bolts on the panel" → "The panel has three bolts." diff --git a/.opencode/skills/ste/references/word-substitutions.md b/.opencode/skills/ste/references/word-substitutions.md new file mode 100644 index 0000000000..8cda2511c1 --- /dev/null +++ b/.opencode/skills/ste/references/word-substitutions.md @@ -0,0 +1,68 @@ +# Word substitutions and one-meaning rulings + +Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative. + +## Unapproved → approved + +| Do not use | Use instead | +|---|---| +| utilize, leverage, employ | use | +| commence, initiate, begin, originate | start | +| terminate, cease, conclude | stop, end | +| ensure, verify, confirm, validate, check | make sure (that), examine | +| perform, conduct, execute, carry out | do | +| facilitate, assist | help | +| obtain, acquire, procure | get | +| sufficient, adequate | enough | +| approximately | about | +| prior to | before | +| subsequent to, following (prep.) | after | +| adjacent to | near | +| accomplish | do | +| additional, supplementary | more | +| attempt | try | +| require, necessitate | need, must | +| mandatory | necessary | +| indicate, signify | show | +| observe (=watch) | look at, examine | +| rotate | turn | +| deactivate | turn off, set to off | +| activate, energize (unless technical verb) | turn on, start | +| toxic | poisonous | +| in order to | to | +| via, by means of | through, with | +| due to, owing to | because of | +| in the event of/that | if | +| accessible | (rewrite: "you can get access to") | +| remainder | rest | +| demonstrate | show | +| modify, alter | change | +| construct, fabricate, build | assemble, make | +| retain | keep | +| locate (=find) | find | +| depress (a button) | push, press | +| proceed | continue, go | + +## One meaning, one part of speech (canonical rulings) + +- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller"). +- **test** — noun only: "do a test," never "test the system." +- **check** — do not use as a verb for verification → "make sure that" or "examine." +- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions." +- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season. +- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing." +- **right** — direction only, never "correct." +- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground." +- **help** — verb only; the noun is **aid** ("with the aid of a mirror"). +- **above / below** — physical position only. For quantities: **more than / less than**. +- **about** — two approved senses: "approximately" and "on the subject of." Use carefully. +- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard. +- **level** — approved as noun and adjective (documented exception to the one-POS rule). + +## Frequent-offender function words + +- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**. +- **etc.** — delete, or write the full list. +- **e.g. / i.e.** — "for example" / "that is." +- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant. +- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts." diff --git a/AGENTS.md b/AGENTS.md index ac4da5c663..e542f93eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,12 @@ Skipping this step is the #1 cause of incorrect or incomplete work. --- +## Writing Rules + +Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100. + +--- + # Memory system Memories are the **primary project guidance** — not docs or readme files. From c797656d17da345fcfa4ca75ebd689809f69ed9d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 13:32:12 +0200 Subject: [PATCH 124/298] :bug: Fix crash when pasting into an empty or element-focused caret (#11150) Pasting text could throw "Unknown node type" and lose the paste. The insertion paths assume the caret sits on a text node or a
    , but the browser can report it on a container element (the offset being a child index, common in Firefox) or, for an empty text shape that was just focused, on nothing at all: selectAll() returned early without ever setting a selection. Add resolveTextNodePosition(), which walks a (node, offset) pair down to the addressed text node or line break and returns null instead of throwing when it cannot. The selection controller normalizes the caret with it before inserting text or a pasted fragment, and selectAll() now collapses on the line break of an empty editor so the caret is always usable. Closes #11149 AI-assisted-by: longcat-2.0-free --- .../src/editor/clipboard/paste.test.js | 67 +++++++++++ .../src/editor/content/dom/TextNode.js | 53 +++++++++ .../src/editor/content/dom/TextNode.test.js | 109 +++++++++++++++++- .../editor/controllers/SelectionController.js | 44 +++++++ .../controllers/SelectionController.test.js | 90 +++++++++++++++ .../text-editor/src/test/TextEditorMock.js | 14 ++- 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 frontend/text-editor/src/editor/clipboard/paste.test.js diff --git a/frontend/text-editor/src/editor/clipboard/paste.test.js b/frontend/text-editor/src/editor/clipboard/paste.test.js new file mode 100644 index 0000000000..14dd7a9ac0 --- /dev/null +++ b/frontend/text-editor/src/editor/clipboard/paste.test.js @@ -0,0 +1,67 @@ +import { describe, test, expect } from "vitest"; +import { TextEditorMock } from "../../test/TextEditorMock.js"; +import { SelectionController } from "../controllers/SelectionController.js"; +import { paste } from "./paste.js"; + +/* @vitest-environment jsdom */ + +/** + * Creates a minimal `ClipboardEvent`-like object carrying plain text. + * + * @param {string} text + * @returns {object} + */ +function createPlainTextClipboardEvent(text) { + return { + preventDefault() {}, + clipboardData: { + types: ["text/plain"], + getData(type) { + return type === "text/plain" ? text : ""; + }, + }, + }; +} + +describe("paste", () => { + test("should insert plain text into an empty editor that was just focused", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + + paste( + createPlainTextClipboardEvent("Hello, World!"), + textEditorMock, + selectionController, + ); + + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + }); + + test("should insert plain text when the caret is on a paragraph element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(paragraph, 1, paragraph, 1); + document.dispatchEvent(new Event("selectionchange")); + + paste( + createPlainTextClipboardEvent("World!"), + textEditorMock, + selectionController, + ); + + expect(root.textContent).toBe("Hello, World!"); + }); +}); diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.js b/frontend/text-editor/src/editor/content/dom/TextNode.js index 25d484d6f4..24b2fe93c3 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.js @@ -62,3 +62,56 @@ export function getClosestTextNode(node) { if (isEditor(node)) return node.firstChild.firstChild.firstChild.firstChild; throw new Error("Cannot find a text node"); } + +/** + * @typedef {Object} TextNodePosition + * @property {Text|HTMLBRElement} node + * @property {number} offset + */ + +/** + * Resolves a (node, offset) pair to an equivalent position on a text node + * or a line break. + * + * Browsers are free to report a caret on a container element, in which case + * the offset is a child index instead of a character index (Firefox does this + * routinely, e.g. on empty paragraphs). This function walks down the content + * tree to the addressed descendant so callers can always work with text + * nodes. + * + * Unlike `getClosestTextNode`, this never throws: it returns `null` when the + * position cannot be resolved, letting the caller decide the fallback. + * + * @param {Node} node + * @param {number} [offset=0] + * @returns {TextNodePosition|null} + */ +export function resolveTextNodePosition(node, offset = 0) { + if (!node) return null; + if (node.nodeType === Node.TEXT_NODE || isLineBreak(node)) { + return { node, offset }; + } + + if (isTextSpan(node)) { + // Within a text span the children are text nodes or a line break, so an + // index past the last child means "at the end of the last child". + const child = node.childNodes[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastChild; + if (!lastChild) return null; + if (lastChild.nodeType !== Node.TEXT_NODE && !isLineBreak(lastChild)) { + return null; + } + return resolveTextNodePosition(lastChild, getTextNodeLength(lastChild)); + } + + if (isParagraph(node) || isRoot(node) || isEditor(node)) { + const child = node.children[offset]; + if (child) return resolveTextNodePosition(child, 0); + const lastChild = node.lastElementChild; + if (!lastChild) return null; + return resolveTextNodePosition(lastChild, lastChild.childNodes.length); + } + + return null; +} diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.test.js b/frontend/text-editor/src/editor/content/dom/TextNode.test.js index fc44374b85..166da09d07 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.test.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.test.js @@ -1,6 +1,13 @@ import { describe, test, expect } from "vitest"; -import { isTextNode, getTextNodeLength } from "./TextNode.js"; +import { + isTextNode, + getTextNodeLength, + resolveTextNodePosition, +} from "./TextNode.js"; import { createLineBreak } from "./LineBreak.js"; +import { createTextSpan, createEmptyTextSpan } from "./TextSpan.js"; +import { createParagraph } from "./Paragraph.js"; +import { createRoot } from "./Root.js"; /* @vitest-environment jsdom */ describe("TextNode", () => { @@ -25,4 +32,104 @@ describe("TextNode", () => { expect(() => getTextNodeLength(null)).toThrowError("Invalid text node"); expect(() => getTextNodeLength(0)).toThrowError("Invalid text node"); }); + + describe("resolveTextNodePosition", () => { + test("should return the same position when the node is already a text node", () => { + const textNode = new Text("Hello, World!"); + expect(resolveTextNodePosition(textNode, 5)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should return the same position when the node is a line break", () => { + const lineBreak = createLineBreak(); + expect(resolveTextNodePosition(lineBreak, 0)).toStrictEqual({ + node: lineBreak, + offset: 0, + }); + }); + + test("should resolve a text span to its child at the given index", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should resolve a text span index past the last child to the end of its text", () => { + const textNode = new Text("Hello"); + const textSpan = createTextSpan(textNode); + expect(resolveTextNodePosition(textSpan, 1)).toStrictEqual({ + node: textNode, + offset: 5, + }); + }); + + test("should resolve a paragraph to the text node of the indexed text span", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const paragraph = createParagraph([ + createTextSpan(first), + createTextSpan(second), + ]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: first, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + expect(resolveTextNodePosition(paragraph, 2)).toStrictEqual({ + node: second, + offset: 6, + }); + }); + + test("should resolve an empty paragraph to its line break", () => { + const textSpan = createEmptyTextSpan(); + const paragraph = createParagraph([textSpan]); + expect(resolveTextNodePosition(paragraph, 0)).toStrictEqual({ + node: textSpan.firstChild, + offset: 0, + }); + }); + + test("should resolve a root to the text node of the indexed paragraph", () => { + const first = new Text("Hello, "); + const second = new Text("World!"); + const root = createRoot([ + createParagraph([createTextSpan(first)]), + createParagraph([createTextSpan(second)]), + ]); + expect(resolveTextNodePosition(root, 1)).toStrictEqual({ + node: second, + offset: 0, + }); + }); + + test("should resolve an editor element to the first text node of its root", () => { + const textNode = new Text("Hello"); + const root = createRoot([createParagraph([createTextSpan(textNode)])]); + const editor = document.createElement("div"); + editor.dataset.itype = "editor"; + editor.appendChild(root); + expect(resolveTextNodePosition(editor, 0)).toStrictEqual({ + node: textNode, + offset: 0, + }); + }); + + test("should return null instead of throwing when the position cannot be resolved", () => { + expect(resolveTextNodePosition(null, 0)).toBe(null); + expect(resolveTextNodePosition(undefined, 0)).toBe(null); + expect(resolveTextNodePosition(document.createElement("div"), 0)).toBe( + null, + ); + expect(resolveTextNodePosition(createParagraph([]), 0)).toBe(null); + }); + }); }); diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 371d94e99f..4ba0b4655a 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -46,6 +46,7 @@ import { getTextNodeLength, getClosestTextNode, isTextNode, + resolveTextNodePosition, } from "../content/dom/TextNode.js"; import TextNodeIterator from "../content/dom/TextNodeIterator.js"; import TextEditor from "../TextEditor.js"; @@ -537,6 +538,14 @@ export class SelectionController extends EventTarget { */ selectAll() { if (this.#textEditor.isEmpty) { + // There is nothing to select, but we still need a valid caret: leaving + // the selection untouched keeps `focusNode` null and makes any later + // insertion (typing, pasting) fail. + const lineBreak = + this.#textEditor.root?.firstElementChild?.firstElementChild?.firstChild; + if (lineBreak) { + this.collapse(lineBreak, 0); + } return this; } @@ -1132,6 +1141,10 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ insertPaste(fragment) { + if (this.isCollapsed && !this.#normalizeFocus()) { + return; + } + const hasOnlyOneParagraph = fragment.children.length === 1; const forceTextSpan = fragment.firstElementChild?.dataset?.textSpan === "force"; @@ -1395,6 +1408,33 @@ export class SelectionController extends EventTarget { return this.collapse(this.focusNode, this.focusOffset + newText.length); } + /** + * Moves the caret to an equivalent position on a text node or a line break. + * + * The browser can report the caret on a container element (with the offset + * being a child index) or, when the editor was focused without any content, + * on nothing at all. Both states break every insertion path, which expects + * the focus node to be a text node or a
    . + * + * @returns {boolean} true when the focus is usable. + */ + #normalizeFocus() { + if (this.isTextFocus || this.isLineBreakFocus) { + return true; + } + + const position = + resolveTextNodePosition(this.focusNode, this.focusOffset) ?? + resolveTextNodePosition(this.#textEditor.root, 0); + + if (!position?.node?.isConnected) { + return false; + } + + this.collapse(position.node, position.offset); + return true; + } + /** * Replaces the currently focus element * with some text. @@ -1402,6 +1442,10 @@ export class SelectionController extends EventTarget { * @param {string} newText */ insertIntoFocus(newText) { + if (!this.#normalizeFocus()) { + return; + } + if (this.isTextFocus) { this.focusNode.nodeValue = insertInto( this.focusNode.nodeValue, diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 533e4c751c..662119ed29 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -1706,6 +1706,96 @@ describe("SelectionController", () => { ); }); + test("`selectAll` should collapse the caret on the line break when the editor is empty", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selectionController.selectAll(); + expect(selectionController.focusNode).toBe( + root.firstChild.firstChild.firstChild, + ); + expect(selectionController.isCollapsed).toBe(true); + }); + + test("`insertIntoFocus` should insert text when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the root", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root, 1); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertIntoFocus` should insert text when the focus node is the editor element", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Hello, "); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, textEditorMock.element, 0); + selectionController.insertIntoFocus("World!"); + expect(root.textContent).toBe("World!Hello, "); + }); + + test("`insertIntoFocus` should insert text when there is no known focus node", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(""); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + expect(selectionController.focusNode).toBe(null); + selectionController.insertIntoFocus("Hello, World!"); + expect(root.textContent).toBe("Hello, World!"); + }); + + test("`insertPaste` should insert a fragment when the focus node is a paragraph", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText(", World!"); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, paragraph, 0); + const fragment = document.createDocumentFragment(); + fragment.append(createParagraphWith(["Hello"])); + selectionController.insertPaste(fragment); + expect(root.textContent).toBe("Hello, World!"); + }); + test("`cursorToEnd` should move cursor to the end", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraphWith(["Hello, "], { diff --git a/frontend/text-editor/src/test/TextEditorMock.js b/frontend/text-editor/src/test/TextEditorMock.js index 0e20d209e7..457d184eb1 100644 --- a/frontend/text-editor/src/test/TextEditorMock.js +++ b/frontend/text-editor/src/test/TextEditorMock.js @@ -4,7 +4,10 @@ import { createEmptyTextSpan, createTextSpan, } from "../editor/content/dom/TextSpan.js"; -import { createLineBreak } from "../editor/content/dom/LineBreak.js"; +import { + createLineBreak, + isLineBreak, +} from "../editor/content/dom/LineBreak.js"; export class TextEditorMock extends EventTarget { /** @@ -135,6 +138,7 @@ export class TextEditorMock extends EventTarget { this.#element = element; this.#root = options?.root; this.#selectionImposterElement = options?.selectionImposterElement; + this.#element.dataset.itype = "editor"; this.#element.appendChild(options?.root); } @@ -145,6 +149,14 @@ export class TextEditorMock extends EventTarget { get root() { return this.#root; } + + get isEmpty() { + return ( + this.#root.children.length === 1 && + this.#root.firstElementChild.children.length === 1 && + isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild) + ); + } } export default TextEditorMock; From 8acb92b782916c5563da0b9c22c8e5e75cf46cff Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 15:27:06 +0200 Subject: [PATCH 125/298] :bug: Normalize fractional rate-limit reset durations (#11254) Round bucket reset intervals up to whole milliseconds before adding them to an instant. This prevents Clojure ratios from reaching duration conversion and disabling rate limiting for the request. Add a regression test for a refill rate that produces fractional milliseconds. Closes #11253 AI-assisted-by: gpt-5.6-luna --- backend/src/app/rpc/rlimit.clj | 5 ++-- .../test/backend_tests/rpc_rlimit_test.clj | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 backend/test/backend_tests/rpc_rlimit_test.clj diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index abc77d81ea..3e05eb9837 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -46,6 +46,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.math :as mth] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as uri] @@ -180,8 +181,8 @@ result (rds/eval rconn script) allowed? (boolean (nth result 0)) remaining (nth result 1) - reset (* (/ (inst-ms interval) rate) - (- capacity remaining))] + reset (long (mth/ceil (double (* (/ (inst-ms interval) rate) + (- capacity remaining)))))] (l/trace :hint "limit processed" :method method :limit (name (::name limit)) diff --git a/backend/test/backend_tests/rpc_rlimit_test.clj b/backend/test/backend_tests/rpc_rlimit_test.clj new file mode 100644 index 0000000000..87ef58244f --- /dev/null +++ b/backend/test/backend_tests/rpc_rlimit_test.clj @@ -0,0 +1,28 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en Espana SL + +(ns backend-tests.rpc-rlimit-test + (:require + [app.common.time :as ct] + [app.redis :as rds] + [app.rpc.rlimit :as rlimit] + [clojure.test :as t])) + +(t/deftest bucket-reset-supports-fractional-milliseconds + (let [now (ct/inst 0) + limit {::rlimit/name :test + ::rlimit/strategy :bucket + ::rlimit/key "test" + ::rlimit/method "main.test" + ::rlimit/capacity 5 + ::rlimit/rate 3 + ::rlimit/interval (ct/duration 1000) + ::rlimit/params [1 3 5] + ::rlimit/opts "5/3/1s"}] + (with-redefs [rds/eval (fn [_ _] [true 4])] + (let [result (rlimit/process-limit nil "profile" now limit)] + (t/is (= (ct/inst 334) + (:app.rpc.rlimit.result/reset result))))))) From 7ac61e059714a7bd7caaad48ce7af0fc7160de03 Mon Sep 17 00:00:00 2001 From: Sebastien MALOT Date: Mon, 17 Aug 2026 22:14:01 +0200 Subject: [PATCH 126/298] :bug: Fix typo in auto-file-snapshot timeout setting (#10909) Corrected a typo in the configuration documentation regarding the auto-file-snapshot timeout setting. Signed-off-by: Sebastien MALOT --- docs/technical-guide/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 49d5a23e8d..618b945df6 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -588,7 +588,7 @@ PENPOT_FLAGS: [...] enable-auto-file-snapshot # Enable automatic v # Backend PENPOT_AUTO_FILE_SNAPSHOT_EVERY: 5 # How many save operations trigger the auto-save-version? -PENPOT_AUTO_FILE_SNAPSHOT_TIIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met? +PENPOT_AUTO_FILE_SNAPSHOT_TIMEOUT: "1h" # How often is an automatic save forced even if the `every` trigger is not met? ``` Setting custom values for auto-file-snapshot does not change the behaviour for manual versions. From 9311737f66585722ead7abcfd0f046fce63f9791 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 17 Aug 2026 22:35:10 +0200 Subject: [PATCH 127/298] :bug: Do not cache the environment generated config.js (#11146) On self hosted installs /js/config.js is regenerated from PENPOT_FLAGS on every container start, but nginx served it with the same `public, max-age=604800` used for build assets, and index.html versions it only by the build. A flags only change therefore leaves the URL untouched, so a browser that had already loaded the app kept using its cached copy for up to a week: enabling a flag such as enable-login-with-google had no visible effect for returning users until the cache expired or they cleared their site data. Serve that one file with the same no-store headers already used for index.html, which is the other file whose contents change without its URL changing. Every other static asset keeps the long lived cache. Fixes #10556. Signed-off-by: Filip Sajdak Co-authored-by: Andrey Antukh --- docker/images/files/nginx.conf.template | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 5ca929c124..75a385f9d6 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -161,6 +161,16 @@ http { location / { include /etc/nginx/overrides/location.d/*.conf; + # Regenerated from the environment on every container start + # (see nginx-entrypoint.sh) while its URL is only versioned by + # the build, so a flags only restart leaves the URL untouched. + # Caching it like a build asset would keep returning users on + # the previous PENPOT_FLAGS for up to a week. + location = /js/config.js { + include /etc/nginx/nginx-security-headers.conf; + add_header Cache-Control "no-store, no-cache, max-age=0" always; + } + location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days From 7f2dc66e8662c2d801fb586c0eb67730cb6df705 Mon Sep 17 00:00:00 2001 From: Gennadiy Ivashchenko <94037347+UniversalWill@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:44:43 +0500 Subject: [PATCH 128/298] :bug: Preserve public URI subpath in asset download URLs (#11234) Join asset download paths relative to PENPOT_PUBLIC_URI so temporary exports and binary file downloads retain configured subpaths. Add regression coverage for both URL generation paths. AI-assisted-by: gpt-5.6-sol --- backend/src/app/rpc/commands/binfile.clj | 3 ++- backend/src/app/rpc/management/exporter.clj | 3 ++- backend/test/backend_tests/binfile_test.clj | 14 ++++++++++++++ backend/test/backend_tests/rpc_management_test.clj | 11 +++++++++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 685b450ecb..95197e70b9 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -63,7 +63,8 @@ :bucket "tempfile"})] (-> (cf/get :public-uri) - (u/join "/assets/by-id/") + (u/ensure-path-slash) + (u/join "assets/by-id/") (u/join (str (:id object))))) (finally diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index f4b7d9547f..317850b126 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -47,5 +47,6 @@ object (sto/put-object! storage content)] {:id (:id object) :uri (-> (cf/get :public-uri) - (u/join "/assets/by-id/") + (u/ensure-path-slash) + (u/join "assets/by-id/") (u/join (str (:id object))))})) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 05f1525c5e..1f8a3a51d8 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -16,10 +16,12 @@ [app.common.thumbnails :as thc] [app.common.types.shape :as cts] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.http :as http] [app.rpc :as-alias rpc] + [app.rpc.commands.binfile :as binfile] [app.storage :as sto] [app.storage.tmp :as tmp] [backend-tests.helpers :as th] @@ -207,6 +209,18 @@ (t/is (= (count result) 1)) (t/is (every? uuid? result))))) +(t/deftest export-binfile-preserves-public-uri-subpath + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + config (assoc cf/config :public-uri "https://example.com/penpot") + params {:file-id (:id file) + :include-libraries false + :embed-assets false} + uri (binding [cf/config config] + (#'binfile/export-binfile th/*system* params))] + (t/is (str/starts-with? (str uri) + "https://example.com/penpot/assets/by-id/")))) + (t/deftest read-obj-rejects-oversized-buffer ;; N1-07: read-obj! must reject objects exceeding max-object-size ;; before attempting to allocate the buffer diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 2dd25694a1..6191305e26 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -11,6 +11,7 @@ [app.common.pprint :as pp] [app.common.types.shape :as cts] [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as db] [app.http :as http] [app.rpc :as-alias rpc] @@ -19,6 +20,7 @@ [backend-tests.storage-test :refer [configure-storage-backend]] [buddy.core.bytes :as b] [clojure.test :as t] + [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io])) @@ -50,10 +52,15 @@ :path path :mtype "image/png" :size 7}} - out1 (th/management-command! params) - out2 (th/management-command! params)] + config (assoc cf/config :public-uri "https://example.com/penpot") + out1 (binding [cf/config config] + (th/management-command! params)) + out2 (binding [cf/config config] + (th/management-command! params))] (t/is (nil? (:error out1))) (t/is (nil? (:error out2))) + (t/is (str/starts-with? (str (get-in out1 [:result :uri])) + "https://example.com/penpot/assets/by-id/")) (t/is (not= (get-in out1 [:result :id]) (get-in out2 [:result :id]))))) From c72bb331effef9a5c069b64334a82fdc58a2428d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 18 Aug 2026 11:20:02 +0200 Subject: [PATCH 129/298] :bug: Fix nitrate advanced permissions error (#11255) --- frontend/src/app/main/data/team.cljs | 84 ++++++++++--------- frontend/src/app/main/ui/dashboard/team.cljs | 3 + .../src/app/main/ui/dashboard/team_form.cljs | 2 + 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 74b9a97bc9..d250543807 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -609,50 +609,56 @@ (defn check-and-submit-invite-members "Fetches fresh team data from the server to ensure up-to-date organization - permissions, then submits member invitations or shows a restriction modal." + permissions, then submits member invitations or shows a permission error." [{:keys [team-id] :as params} origin do-invite-members] (ptk/reify ::check-and-submit-invite-members ptk/WatchEvent - (watch [_ _ _] - (if (contains? cf/flags :admin-console) - (with-refreshed-team team-id - (fn [team] - (if (not (cto/allowed? :add-anybody-to-team - {:organization-perms (:organization team)})) - (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) - :emails (vec (:emails params))}) - (rx/mapcat - (fn [result] - (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) - (map first)) - result)] - (cond - (empty? blocked) - (do (do-invite-members params origin) (rx/empty)) + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (if (contains? cf/flags :admin-console) + (with-refreshed-team team-id + (fn [team] + (if (not (cto/can-send-invitations? + {:organization (:organization team) + :profile-id profile-id + :team-permissions (:permissions team)})) + (rx/of (modal/show :no-permission-modal {:type :invite-members})) + (if (not (cto/allowed? :add-anybody-to-team + {:organization-perms (:organization team)})) + (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) + :emails (vec (:emails params))}) + (rx/mapcat + (fn [result] + (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) + (map first)) + result)] + (cond + (empty? blocked) + (do (do-invite-members params origin) (rx/empty)) - (= (count blocked) (count result)) - (rx/of - (modal/show - {:type :alert - :title (tr "modals.invite-restricted-members.all-blocked-title") - :message (tr "modals.invite-restricted-members.all-blocked") - :accept-label (tr "labels.accept") - :accept-style :primary})) + (= (count blocked) (count result)) + (rx/of + (modal/show + {:type :alert + :title (tr "modals.invite-restricted-members.all-blocked-title") + :message (tr "modals.invite-restricted-members.all-blocked") + :accept-label (tr "labels.accept") + :accept-style :primary})) - :else - (rx/of - (modal/show - {:type :invite-restricted-members - :blocked-emails blocked - :on-accept (fn [] - (let [valid-emails (into #{} (filter (fn [e] (get result e))) - (:emails params)) - params' (assoc params :emails valid-emails)] - (do-invite-members params' origin)))}))))))) - (do (do-invite-members params origin) - (rx/empty))))) - (do (do-invite-members params origin) - (rx/empty)))))) + :else + (rx/of + (modal/show + {:type :invite-restricted-members + :blocked-emails blocked + :on-accept (fn [] + (let [valid-emails (into #{} (filter (fn [e] (get result e))) + (:emails params)) + params' (assoc params :emails valid-emails)] + (do-invite-members params' origin)))}))))))) + (do (do-invite-members params origin) + (rx/empty)))))) + (do (do-invite-members params origin) + (rx/empty))))))) (defn copy-invitation-link [{:keys [email team-id] :as params}] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index d9f8071981..027238e4b8 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -233,6 +233,9 @@ (= :email-domain-is-not-allowed code)) (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")) (modal/hide)) + (and (= :validation type) + (= :insufficient-permissions code)) + (st/emit! (modal/show :no-permission-modal {:type :invite-members})) :else (st/emit! (ntf/error (tr "errors.generic")) diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 21a9708c4d..d69c108183 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -152,6 +152,8 @@ (tr "dashboard.no-permission-create-team.message" organization-name)] :delete-team [(tr "dashboard.delete-team") (tr "dashboard.no-permission-delete-team.message" organization-name)] + :invite-members [(tr "modals.invite-team-member.title") + (tr "dashboard.invitations.no-permission")] :no-organizations-create [(tr "dashboard.select-organization-modal.title") (tr "dashboard.no-organization-allows-create-team.message")] :no-organizations-change [(tr "dashboard.change-organization-modal.title") From b4bc3dfe6abfd5e8c431e50295fa698ebbb535ba Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 18 Aug 2026 16:14:22 +0530 Subject: [PATCH 130/298] :bug: Fix grid item date tooltip showing deletion text on non-deleted files (#11161) The grid-item-metadata* component always used :will-be-deleted-at (falling back to :modified-at) and always showed the "Will be deleted %s" tooltip, even for files in the Recent tab that have no deletion date. Now the component branches on the presence of :will-be-deleted-at: - Deleted files: show the deletion timeago with the existing "Will be deleted %s" tooltip. - Regular files: show :modified-at timeago with a new "Last modified %s" tooltip key (dashboard.grid.last-modified-at). Closes #10873 Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/dashboard/grid.cljs | 18 ++++++++++++------ frontend/translations/en.po | 6 +++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 59442337f8..5588805f7e 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -239,12 +239,18 @@ (mf/defc grid-item-metadata* {::mf/private true} [{:keys [file layout]}] - (let [time (ct/timeago (or (:will-be-deleted-at file) - (:modified-at file)))] - [:span {:class (stl/css-case :grid-item-date (= layout :grid) - :list-item-date (= layout :list)) - :title (tr "dashboard.deleted.will-be-deleted-at" time)} - time])) + (let [deleted-at (:will-be-deleted-at file) + date-class (stl/css-case :grid-item-date (= layout :grid) + :list-item-date (= layout :list))] + (if deleted-at + (let [time (ct/timeago deleted-at)] + [:span {:class date-class + :title (tr "dashboard.deleted.will-be-deleted-at" time)} + time]) + (let [time (ct/timeago (:modified-at file))] + [:span {:class date-class + :title (tr "dashboard.grid.last-modified-at" time)} + time])))) (defn create-counter-element [_element file-count] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014b63b258..f2b57e315f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -453,10 +453,14 @@ msgstr "Delete team" msgid "dashboard.deleted.empty-state-description" msgstr "Your trash is empty. Deleted files and projects will appear here." -#: src/app/main/ui/dashboard/grid.cljs:247 +#: src/app/main/ui/dashboard/grid.cljs:252 msgid "dashboard.deleted.will-be-deleted-at" msgstr "Will be deleted %s" +#: src/app/main/ui/dashboard/grid.cljs:256 +msgid "dashboard.grid.last-modified-at" +msgstr "Last modified %s" + #: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:732 msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" From d745dc4a3cb9f8e4c1b1ccc31f317960bf9de5c8 Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 18 Aug 2026 16:14:22 +0530 Subject: [PATCH 131/298] :bug: Fix grid item date tooltip showing deletion text on non-deleted files (#11161) The grid-item-metadata* component always used :will-be-deleted-at (falling back to :modified-at) and always showed the "Will be deleted %s" tooltip, even for files in the Recent tab that have no deletion date. Now the component branches on the presence of :will-be-deleted-at: - Deleted files: show the deletion timeago with the existing "Will be deleted %s" tooltip. - Regular files: show :modified-at timeago with a new "Last modified %s" tooltip key (dashboard.grid.last-modified-at). Closes #10873 Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/dashboard/grid.cljs | 18 ++++++++++++------ frontend/translations/en.po | 6 +++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 59442337f8..5588805f7e 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -239,12 +239,18 @@ (mf/defc grid-item-metadata* {::mf/private true} [{:keys [file layout]}] - (let [time (ct/timeago (or (:will-be-deleted-at file) - (:modified-at file)))] - [:span {:class (stl/css-case :grid-item-date (= layout :grid) - :list-item-date (= layout :list)) - :title (tr "dashboard.deleted.will-be-deleted-at" time)} - time])) + (let [deleted-at (:will-be-deleted-at file) + date-class (stl/css-case :grid-item-date (= layout :grid) + :list-item-date (= layout :list))] + (if deleted-at + (let [time (ct/timeago deleted-at)] + [:span {:class date-class + :title (tr "dashboard.deleted.will-be-deleted-at" time)} + time]) + (let [time (ct/timeago (:modified-at file))] + [:span {:class date-class + :title (tr "dashboard.grid.last-modified-at" time)} + time])))) (defn create-counter-element [_element file-count] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014b63b258..f2b57e315f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -453,10 +453,14 @@ msgstr "Delete team" msgid "dashboard.deleted.empty-state-description" msgstr "Your trash is empty. Deleted files and projects will appear here." -#: src/app/main/ui/dashboard/grid.cljs:247 +#: src/app/main/ui/dashboard/grid.cljs:252 msgid "dashboard.deleted.will-be-deleted-at" msgstr "Will be deleted %s" +#: src/app/main/ui/dashboard/grid.cljs:256 +msgid "dashboard.grid.last-modified-at" +msgstr "Last modified %s" + #: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:732 msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" From 904570f97032523348c6b85e0781a8736d7de264 Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 18 Aug 2026 16:19:16 +0530 Subject: [PATCH 132/298] :bug: Handle clipboard API unavailable on access token copy (#8496) (#11156) When copying an access token over plain HTTP (non-secure context), the browser does not expose navigator.clipboard, causing to-clipboard to return a rejected Promise. The caller was ignoring the Promise entirely, so the rejection became an unhandled exception that crashed the UI. Fix: chain .then/.catch on the returned Promise so that a successful copy shows the existing success toast and a failure (including insecure-origin) shows an error toast using the existing errors.clipboard-api-unavailable translation key. Co-authored-by: Paperclip Co-authored-by: Andrey Antukh --- .../app/main/ui/settings/integrations.cljs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index d8c137561a..c87b99ed3b 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -79,13 +79,19 @@ (mf/deps token-created) (fn [event] (dom/prevent-default event) - (clipboard/to-clipboard (:token token-created)) - (st/emit! (ntf/show {:level :info - :type :toast - :content (if is-mcp - (tr "integrations.notification.success.mcp-key-copied") - (tr "integrations.notification.success.token-copied")) - :timeout notification-timeout}))))] + (-> (clipboard/to-clipboard (:token token-created)) + (.then (fn [_] + (st/emit! (ntf/show {:level :info + :type :toast + :content (if is-mcp + (tr "integrations.notification.success.mcp-key-copied") + (tr "integrations.notification.success.token-copied")) + :timeout notification-timeout})))) + (.catch (fn [_] + (st/emit! (ntf/show {:level :error + :type :toast + :content (tr "errors.clipboard-api-unavailable") + :timeout notification-timeout})))))))] [:div {:class (stl/css :modal-form)} [:> text* {:as "h2" From 5b4a5776cb05064d86f71e103c8537372d405b36 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 11:18:44 +0000 Subject: [PATCH 133/298] :bug: Prevent nil theme in profile updates Omit nil optional profile fields before frontend schema validation and RPC persistence. Preserve omitted language and theme values in backend updates, and add regression coverage for partial profile saves. AI-assisted-by: gpt-5.6-luna --- backend/src/app/rpc/commands/profile.clj | 8 +++-- .../test/backend_tests/rpc_profile_test.clj | 11 +++++++ frontend/src/app/main/data/profile.cljs | 8 +++-- .../frontend_tests/data/profile_test.cljs | 30 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 ++ 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/data/profile_test.cljs diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 77307a1482..114f07be3e 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -166,8 +166,12 @@ ;; the same row/object. (let [profile (get-profile conn profile-id ::db/for-update true) fullname (d/normalize-string fullname) - lang (d/normalize-string lang) - theme (d/normalize-string theme) + lang (if (contains? params :lang) + (d/normalize-string lang) + (:lang profile)) + theme (if (contains? params :theme) + (d/normalize-string theme) + (:theme profile)) ;; Update the profile map with direct params profile (-> profile (assoc :fullname fullname) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index f9900d44ab..de9ae0aacd 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -125,6 +125,17 @@ (t/is (= "en" (:lang result))) (t/is (= "dark" (:theme result)))))) + (t/testing "update profile preserves omitted optional fields" + (let [data {::th/type :update-profile + ::rpc/profile-id (:id profile) + :fullname "Updated Name"} + out (th/command! data)] + + (t/is (nil? (:error out))) + (t/is (= "Updated Name" (get-in out [:result :fullname]))) + (t/is (= "en" (get-in out [:result :lang]))) + (t/is (= "dark" (get-in out [:result :theme]))))) + (t/testing "update photo" (let [data {::th/type :update-profile-photo ::rpc/profile-id (:id profile) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 3f1237c2b7..9d6db2101d 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -122,6 +122,10 @@ ;; --- Update Profile +(defn profile-update-params + [profile] + (d/without-nils (select-keys profile [:fullname :lang :theme]))) + (defn persist-profile [& {:as opts}] (ptk/reify ::persist-profile @@ -130,7 +134,7 @@ (let [on-success (:on-success opts identity) on-error (:on-error opts rx/throw) profile (:profile state) - params (select-keys profile [:fullname :lang :theme])] + params (profile-update-params profile)] (->> (rp/cmd! :update-profile params) (rx/tap on-success) (rx/map set-profile) @@ -143,7 +147,7 @@ props" [profile] - (let [profile (check-profile profile)] + (let [profile (check-profile (d/without-nils profile))] (ptk/reify ::update-profile ptk/WatchEvent (watch [_ state _] diff --git a/frontend/test/frontend_tests/data/profile_test.cljs b/frontend/test/frontend_tests/data/profile_test.cljs new file mode 100644 index 0000000000..97e62f63d6 --- /dev/null +++ b/frontend/test/frontend_tests/data/profile_test.cljs @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.profile-test + (:require + [app.common.uuid :as uuid] + [app.main.data.profile :as dprof] + [cljs.test :as t :include-macros true])) + +(t/deftest profile-update-params-omits-nil-values + (t/is (= {:fullname "Updated Name"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang nil + :theme nil})))) + +(t/deftest profile-update-params-preserves-present-values + (t/is (= {:fullname "Updated Name" + :lang "en" + :theme "dark"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang "en" + :theme "dark"})))) + +(t/deftest update-profile-accepts-nil-optional-values + (t/is (some? (dprof/update-profile {:id uuid/zero + :fullname "Updated Name" + :theme nil})))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 31896b987e..f4ae117eda 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.nitrate-test] + [frontend-tests.data.profile-test] [frontend-tests.data.repo-test] [frontend-tests.data.store-test] [frontend-tests.data.uploads-test] @@ -106,6 +107,7 @@ 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.nitrate-test + 'frontend-tests.data.profile-test 'frontend-tests.data.repo-test 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test From 162a381aedb0aa5d7244d7fb26f6f1d131ed5a15 Mon Sep 17 00:00:00 2001 From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:33:24 +0200 Subject: [PATCH 134/298] :bug: Apply the asset attachment disposition on the s3 backend too (#10989) 29dbf9ab1 marks non public buckets as attachments, which works on the fs backend because nginx applies those headers to the internally redirected response. On the s3 backend the handler answers 307 and the client then fetches the bytes from the object store, so the header set on the redirect does not reach the response that carries the object. Sign the disposition into the presigned url as well, so the object store returns it. It is only signed when the bucket is not public, so urls for inline served objects are unchanged. Also cover the disposition in the handler tests, for the non public buckets and for the public ones that stay inline. Co-authored-by: Andrey Antukh --- backend/src/app/http/assets.clj | 12 ++++- backend/src/app/storage/s3.clj | 16 +++++-- .../test/backend_tests/http_assets_test.clj | 44 +++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 6258760548..98ba0d8ba5 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -49,13 +49,21 @@ [{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj] (let [sig-max-age (or signature-max-age default-signature-max-age) cch-max-age (or cache-max-age default-cache-max-age) - {:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age}) bucket (-> obj meta :bucket) + public? (contains? public-buckets bucket) + ;; The disposition is also signed into the presigned url: this + ;; response is a redirect, so the header below applies to the + ;; redirect itself and not to the bytes the client then fetches + ;; from the object store. + {:keys [host port] :as url} (sto/get-object-url storage obj + (cond-> {:max-age sig-max-age} + (not public?) + (assoc :content-disposition "attachment"))) headers (cond-> {"location" (str url) "x-host" (cond-> host port (str ":" port)) "x-mtype" (-> obj meta :content-type) "cache-control" (str "max-age=" (inst-ms cch-max-age))} - (not (contains? public-buckets bucket)) + (not public?) (assoc "content-disposition" "attachment"))] {::yres/status 307 ::yres/headers headers})) diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index 025749bee1..a56a23d99d 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -346,13 +346,21 @@ (ct/duration {:minutes 10})) (defn- get-object-url - [{:keys [::presigner ::bucket ::prefix]} {:keys [id]} {:keys [max-age] :or {max-age default-max-age}}] + [{:keys [::presigner ::bucket ::prefix]} {:keys [id]} + {:keys [max-age content-disposition] :or {max-age default-max-age}}] (assert (ct/duration? max-age) "expected valid duration instance") - (let [gor (.. (GetObjectRequest/builder) + ;; The content-disposition option is signed into the presigned url, so the + ;; object store sets that header on the response the client fetches after + ;; following the redirect. It is only set when asked for, so urls for + ;; objects served inline stay byte identical to before. + (let [gorb (.. (GetObjectRequest/builder) (bucket bucket) - (key (dm/str prefix (impl/id->path id))) - (build)) + (key (dm/str prefix (impl/id->path id)))) + gorb (cond-> gorb + (some? content-disposition) + (.responseContentDisposition ^String content-disposition)) + gor (.build gorb) gopr (.. (GetObjectPresignRequest/builder) (signatureDuration ^Duration max-age) (getObjectRequest ^GetObjectRequest gor) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 94510d73d6..bac3dbfe9a 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -269,6 +269,50 @@ (t/is (string? redirect)) (t/is (clojure.string/includes? redirect (sto/object->relative-path object))))) +;; ---------------------------------------------------------------- +;; Tests: objects-handler — content disposition +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-non-public-bucket-served-as-attachment + ;; A non-public bucket holds bytes the user uploaded and is reachable by + ;; direct navigation, so the response marks it as an attachment. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1)] + + (doseq [bucket ["profile" + "tempfile" + "file-data" + "file-thumbnail" + "file-change"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "some data") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id profile)} + response (assets/objects-handler cfg request)] + (t/is (= "attachment" (get (::yres/headers response) "content-disposition")) + (str "bucket " bucket " should be served as an attachment"))))))) + +(t/deftest objects-handler-public-bucket-served-inline + ;; Public buckets are embedded by the viewer and by outgoing mail, so they + ;; keep being served without a disposition. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage)] + + (doseq [bucket ["file-media-object" + "file-object-thumbnail" + "team-font-variant" + "file-data-fragment" + "organization"]] + (t/testing (str "bucket: " bucket) + (let [object (create-storage-object! storage bucket "some data") + request {:path-params {:id (str (:id object))}} + response (assets/objects-handler cfg request)] + (t/is (nil? (get (::yres/headers response) "content-disposition")) + (str "bucket " bucket " should stay inline"))))))) + ;; ---------------------------------------------------------------- ;; Tests: objects-handler — cache headers ;; ---------------------------------------------------------------- From d3bee4ba9d48eae028a13d1106b274a2bffe631c Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 18 Aug 2026 17:38:21 +0530 Subject: [PATCH 135/298] :bug: Fix comment bubbles rendering above workspace dropdown menus (#11201) * :bug: Fix comment bubbles rendering above workspace dropdowns (#10283) Comment bubbles (workspace-comments-container) had z-index: 1000, which placed them above dropdown menus (--z-index-dropdown: 400). Replace the hardcoded 1000 with $z-index-300 from the design-system z-index scale so comments sit above the canvas/guides but below menus and dropdowns. * Refactor workspace comments container styles Modernize CSS properties for workspace comments container. Signed-off-by: Luis de Dios --------- Signed-off-by: Luis de Dios Co-authored-by: Luis de Dios --- frontend/src/app/main/ui/workspace/viewport/comments.scss | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.scss b/frontend/src/app/main/ui/workspace/viewport/comments.scss index 2c672cab4b..95a0354b6b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.scss +++ b/frontend/src/app/main/ui/workspace/viewport/comments.scss @@ -4,14 +4,11 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "refactor/common-refactor.scss" as deprecated; - .workspace-comments-container { - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; grid-column: 1 / span 2; grid-row: 1 / span 2; - z-index: 1000; pointer-events: none; overflow: hidden; user-select: text; From 367e4d534c536c33d4f3fbad375f3e9c29b787a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 14:36:29 +0200 Subject: [PATCH 136/298] :bug: Scope assemble-chunks session lookup to profile-id (#11012) Prevent BOLA in chunked upload assembly by verifying session ownership. The assemble-chunks function now requires a profile-id parameter and scopes the upload_session lookup accordingly, matching the pattern already used by upload-chunk. All three callers (assemble-file-media-object, create-font-variant, import-binfile) updated to pass the authenticated profile-id. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/rpc/commands/binfile.clj | 2 +- backend/src/app/rpc/commands/fonts.clj | 4 +-- backend/src/app/rpc/commands/media.clj | 7 ++-- backend/test/backend_tests/rpc_media_test.clj | 35 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 685b450ecb..ea26e5e2ce 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -155,7 +155,7 @@ params (if (some? upload-id) - (let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)] + (let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)] (assoc params :file file)) params) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index c0ca2d8da7..7b5ac6ac4e 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -118,10 +118,10 @@ "Assembles each chunked-upload session in `uploads` (a `{mtype → session-id}` map) into a temp file, validates the media type and size of every entry, and returns a `{mtype → path}` data map." - [cfg {:keys [uploads] :as params}] + [cfg {:keys [::rpc/profile-id uploads] :as params}] (let [data (reduce-kv (fn [acc mtype session-id] - (let [assembled (assemble-chunks cfg session-id)] + (let [assembled (assemble-chunks cfg profile-id session-id)] (-> {:mtype mtype :size (:size assembled)} (media.v/validate-media-type! cm/font-types) (media.v/validate-font-size!)) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 612db21245..418eeb5b47 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -402,9 +402,10 @@ Raises a :validation/:missing-chunks error when the number of stored chunks does not match `:total-chunks` recorded in the session row. + Raises :not-found when the session does not belong to `profile-id`. Deletes the session row from `upload_session` on success." - [{:keys [::db/conn] :as cfg} session-id] - (let [session (db/get conn :upload-session {:id session-id}) + [{:keys [::db/conn] :as cfg} profile-id session-id] + (let [session (db/get conn :upload-session {:id session-id :profile-id profile-id}) chunks (get-upload-chunks conn session-id)] (when (not= (count chunks) (:total-chunks session)) @@ -447,7 +448,7 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [content (assemble-chunks cfg session-id) + (let [content (assemble-chunks cfg profile-id session-id) content (-> content (assoc :filename (str "upload:" name)) (assoc :mtype mtype) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 4669ad929d..f7ac5dccf6 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -581,6 +581,41 @@ (t/is (some? (:error out))) (t/is (= :not-found (-> out :error ex-data :type))))) +(t/deftest chunked-upload-other-profile-cannot-assemble + ;; assemble-chunks must scope the session lookup to the requesting + ;; profile so that a different profile cannot assemble chunks from + ;; a session they do not own (BOLA / CWE-639). + (let [prof1 (th/create-profile* 1) + prof2 (th/create-profile* 2) + session-id (create-session! prof1 1) + source-path (th/tempfile "backend_tests/test_files/sample.jpg") + mfile {:filename "sample.jpg" + :path source-path + :mtype "image/jpeg" + :size 312043}] + + ;; prof1 uploads a chunk into their own session + (let [out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof1) + :session-id session-id + :index 0 + :content mfile})] + (t/is (nil? (:error out)))) + + ;; prof2 tries to assemble prof1's session via create-font-variant + ;; (which calls assemble-chunks without ownership check) + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id (:default-team-id prof2) + :font-id (uuid/next) + :font-family "TestFont" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) + (t/deftest chunked-upload-invalid-media-type (let [prof (th/create-profile* 1) _ (th/create-project* 1 {:profile-id (:id prof) From 73c06688773f48356d0f5076177d3aaeee6bc494 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 14:36:50 +0200 Subject: [PATCH 137/298] :bug: Verify read access on source file in clone-file-media-object (#11090) The clone-file-media-object RPC command only checked edit permissions on the destination file. The source media object was fetched directly by UUID without verifying the caller had access to the file that owns it. This fix adds a read permission check on the source file before cloning. If the caller lacks read access to the source file, the operation fails with :not-found to avoid leaking information about the existence of files/media the caller cannot access. Closes #11087 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/media.clj | 7 +- backend/test/backend_tests/rpc_media_test.clj | 95 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 418eeb5b47..4cf3c68029 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -272,8 +272,13 @@ (clone-file-media-object cfg params)) (defn clone-file-media-object - [{:keys [::db/conn]} {:keys [id file-id is-local]}] + [{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}] (let [mobj (db/get-by-id conn :file-media-object id)] + (when-not mobj + (ex/raise :type :not-found + :code :object-not-found + :hint "source media object not found")) + (files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj)) (db/insert! conn :file-media-object {:id (uuid/next) :file-id file-id diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index f7ac5dccf6..d230b03769 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -769,3 +769,98 @@ (t/is (some? (:error out))) (t/is (= :restriction (-> out :error ex-data :type))) (t/is (= :max-quote-reached (-> out :error ex-data :code))))))) + +;; --- Clone File Media Object BOLA tests --- + +(defn- create-storage-object! + [content content-type] + (let [storage (:app.storage/storage th/*system*)] + (sto/put-object! storage {::sto/content (sto/content content) + :content-type content-type}))) + +(t/deftest clone-file-media-object-success + (let [prof1 (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:default-team-id prof1)}) + file1 (th/create-file* 1 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + sobj (create-storage-object! "image-content" "image/png") + mobj (th/create-file-media-object* {:file-id (:id file1) + :name "test-media" + :width 100 + :height 100 + :mtype "image/png" + :media-id (:id sobj)}) + file2 (th/create-file* 2 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof1) + :file-id (:id file2) + :is-local true + :id (:id mobj)} + out (th/command! params)] + + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (= (:id file2) (:file-id result))) + (t/is (= (:name mobj) (:name result))) + (t/is (= (:media-id mobj) (:media-id result))) + (t/is (uuid? (:id result))) + (t/is (not= (:id mobj) (:id result)))))) + +(t/deftest clone-file-media-object-no-read-access + (let [prof1 (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof1) + :team-id (:default-team-id prof1)}) + file1 (th/create-file* 1 {:profile-id (:id prof1) + :project-id (:default-project-id prof1) + :is-shared false}) + sobj (create-storage-object! "private-content" "image/png") + mobj (th/create-file-media-object* {:file-id (:id file1) + :name "private-media" + :width 100 + :height 100 + :mtype "image/png" + :media-id (:id sobj)}) + + prof2 (th/create-profile* 2) + _ (th/create-project* 2 {:profile-id (:id prof2) + :team-id (:default-team-id prof2)}) + file2 (th/create-file* 2 {:profile-id (:id prof2) + :project-id (:default-project-id prof2) + :is-shared false}) + + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof2) + :file-id (:id file2) + :is-local true + :id (:id mobj)} + out (th/command! params)] + + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= :not-found (:type error-data))) + (t/is (= :object-not-found (:code error-data)))))) + +(t/deftest clone-file-media-object-source-not-found + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + params {::th/type :clone-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :id (uuid/random)} + out (th/command! params)] + + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= :not-found (:type error-data))) + (t/is (= :object-not-found (:code error-data)))))) From 3be07cccedc2c7259c8d8ef26e57c9c927b6448d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 14:37:51 +0200 Subject: [PATCH 138/298] :bug: Add minimum validation for total-chunks in upload session (#11104) The create-upload-session RPC method accepted total-chunks values of 0 or negative numbers without validation, creating inconsistent session state. Add {:min 1} constraint to the schema to reject invalid values at input validation. Closes #11103 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/media.clj | 2 +- backend/test/backend_tests/rpc_media_test.clj | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 4cf3c68029..3dff04fa10 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -294,7 +294,7 @@ (def ^:private schema:create-upload-session [:map {:title "create-upload-session"} - [:total-chunks ::sm/int]]) + [:total-chunks [::sm/int {:min 1}]]]) (def ^:private schema:create-upload-session-result [:map {:title "create-upload-session-result"} diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index d230b03769..d22eabe64b 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -718,6 +718,24 @@ (t/is (= :max-quote-reached (-> out :error ex-data :code))) (t/is (= "upload-chunks-per-session" (-> out :error ex-data :target)))))) +(t/deftest chunked-upload-invalid-total-chunks + ;; total-chunks must be at least 1; zero and negative values are rejected + ;; with a :validation error. + (let [prof (th/create-profile* 1)] + ;; zero total-chunks + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 0})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))) + + ;; negative total-chunks + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks -1})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type)))))) + (t/deftest chunked-upload-invalid-chunk-index ;; Both a negative index and an index >= total-chunks must be ;; rejected with a :validation / :invalid-chunk-index error. From e72c1869eb383240c059b3bac5c7c6026da6f313 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 15:13:49 +0200 Subject: [PATCH 139/298] :bug: Validate version parameter in import-binfile (#11107) Restrict version parameter to supported values (1 or 3) via schema validation instead of accepting any integer. Add content-based format detection when version is not provided, using bfc/parse-file-format to inspect file magic bytes. Closes #11105 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/binfile.clj | 31 ++++++++++------ .../test/backend_tests/rpc_binfile_test.clj | 35 +++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ea26e5e2ce..ec4510200d 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -105,7 +105,11 @@ (try (case (int version) 1 (bf.v1/import-files! cfg) - 3 (bf.v3/import-files! cfg)) + 3 (bf.v3/import-files! cfg) + (throw (ex-info (str "Unsupported binfile version: " version) + {:type :validation + :code :unsupported-version + :version version}))) (finally (when owned? (fs/delete input-path))))] @@ -123,7 +127,7 @@ [:name [:or [:string {:max 250}] [:map-of ::sm/uuid [:string {:max 250}]]]] [:project-id ::sm/uuid] - [:version {:optional true} ::sm/int] + [:version {:optional true} [:enum 1 3]] [:file {:optional true} media.v/schema:upload] [:upload-id {:optional true} ::sm/uuid]] [:fn {:error/message "one of :file or :upload-id is required"} @@ -148,21 +152,28 @@ [:import-binfile/global]]} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}] (projects/check-edition-permissions! pool profile-id project-id) - (let [version (or version 3) + (let [params (if (some? upload-id) + (let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)] + (assoc params :file file)) + params) + + version (or version + (case (bfc/parse-file-format (-> params :file :path)) + :binfile-v1 1 + :binfile-v3 3)) + params (-> params (assoc :profile-id profile-id) (assoc :version version)) - params - (if (some? upload-id) - (let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)] - (assoc params :file file)) - params) - manifest (case (int version) 1 nil - 3 (bf.v3/get-manifest (-> params :file :path)))] + 3 (bf.v3/get-manifest (-> params :file :path)) + (throw (ex-info (str "Unsupported binfile version: " version) + {:type :validation + :code :unsupported-version + :version version})))] (with-meta (sse/response (partial import-binfile cfg params)) diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index 536a980339..b1b99762b0 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -11,8 +11,7 @@ [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [backend-tests.helpers :as th] - [clojure.test :as t] - [datoteka.fs :as fs])) + [clojure.test :as t])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -32,3 +31,35 @@ (t/is (not (contains? (sm/keys (second schema)) :file-id)) "file-id should not be a declared parameter"))) + +(t/deftest import-binfile-schema-rejects-unsupported-version + ;; T1-N2-03: version parameter should be restricted to supported values (1 or 3) + (let [schema @#'binfile/schema:import-binfile + validator (sm/lazy-validator schema) + base-params {:name "test" + :project-id (uuid/random) + :upload-id (uuid/random)}] + + ;; Version 1 should be accepted + (t/is (true? (validator (assoc base-params :version 1))) + "version 1 should be valid") + + ;; Version 3 should be accepted + (t/is (true? (validator (assoc base-params :version 3))) + "version 3 should be valid") + + ;; Version 2 should be rejected + (t/is (false? (validator (assoc base-params :version 2))) + "version 2 should be rejected") + + ;; Version 0 should be rejected + (t/is (false? (validator (assoc base-params :version 0))) + "version 0 should be rejected") + + ;; Negative version should be rejected + (t/is (false? (validator (assoc base-params :version -1))) + "negative version should be rejected") + + ;; Version 4 should be rejected + (t/is (false? (validator (assoc base-params :version 4))) + "version 4 should be rejected"))) From 1671cc4fccda7bfd572111ae5b1a5a6f125206a5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 17:43:25 +0200 Subject: [PATCH 140/298] :bug: Escape markdown in Mattermost error notifications (#11034) Add escape-markdown to common/data.cljc that escapes Markdown special characters (*, _, ~, `, [, ], >, #, @, etc.) by prefixing them with backslash. Apply it to user-controlled fields (:hint, :href) in the Mattermost error reporter before constructing the notification message. This is an internal-only feature not accessible to end users. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/loggers/mattermost.clj | 5 +++-- common/src/app/common/data.cljc | 9 +++++++++ common/test/common_tests/data_test.cljc | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index e3089f1f03..82a3c134e6 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -7,6 +7,7 @@ (ns app.loggers.mattermost "A mattermost integration for error reporting." (:require + [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.pprint :as pp] @@ -25,7 +26,7 @@ (defn- send-mattermost-notification! [cfg {:keys [id] :as report}] (let [type (get report :type) - text (str "#" type " | " (get report :hint) "\n" + text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n" (when id (str (u/join (cf/get :public-uri) "/dbg/error/" id) " ")) @@ -38,7 +39,7 @@ "- tenant: #" (:tenant report) "\n" "- origin: #" (:origin report) "\n" (when-let [href (get report :href)] - (str "- href: `" href "`\n")) + (str "- href: `" (d/escape-markdown href) "`\n")) (when-let [version (get report :frontend-version)] (str "- frontend-version: `" version "`\n")) (when-let [version (get report :backend-version)] diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 418c6e5bd5..090fe8cd69 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1192,6 +1192,15 @@ str/trim) "")) +(defn escape-markdown + "Escapes Markdown special characters by prefixing them with backslash. + Intended for user-controlled values embedded in Markdown messages + (e.g. Mattermost notifications)." + [s] + (if s + (str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c))) + "")) + (defn get-initials "Returns up to two uppercase initials extracted from a string. Non-letter prefixes in each token are ignored." diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 46f12fd8fb..ffa26a2386 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -54,6 +54,25 @@ (t/is (= :keyword (d/normalize-string :keyword))) (t/is (= true (d/normalize-string true)))) +(t/deftest escape-markdown-test + (t/is (= "hello" (d/escape-markdown "hello"))) + (t/is (= "" (d/escape-markdown nil))) + (t/is (= "" (d/escape-markdown ""))) + (t/is (= "\\*bold\\*" (d/escape-markdown "*bold*"))) + (t/is (= "\\_italic\\_" (d/escape-markdown "_italic_"))) + (t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~"))) + (t/is (= "\\`code\\`" (d/escape-markdown "`code`"))) + (t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)"))) + (t/is (= "\\> quote" (d/escape-markdown "> quote"))) + (t/is (= "\\# heading" (d/escape-markdown "# heading"))) + (t/is (= "\\@channel" (d/escape-markdown "@channel"))) + (t/is (= "\\!bang" (d/escape-markdown "!bang"))) + (t/is (= "normal\\-text" (d/escape-markdown "normal-text"))) + (t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c"))) + (t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated"))) + (t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces"))) + (t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash")))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Ordered Data Structures ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; From 4ac14cfd0808041037c77c3da9487240803ff71b Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 18 Aug 2026 17:45:42 +0200 Subject: [PATCH 141/298] :sparkles: Add component synchronization to waitForLayoutUpdate (#10964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Add component synchronization to waitForLayoutUpdate * :bug: Fix async mock leak in workspace-reflow-test Use mock/with-mocks instead of with-redefs for http/send! mock in failed-google-font-css-does-not-abort-shared-consumers test. with-redefs restores bindings when the block exits synchronously, but the RxJS subscription fires asynchronously. This caused the mock to leak into subsequent tests (workspace-media-test), producing 3 spurious failures. AI-assisted-by: mimo-v2.5-pro * :recycle: Replace async with-redefs with mock/with-mocks in frontend tests with-redefs restores bindings when the block exits synchronously, which is too early for async code (t/async, rx/subs!, promises). mock/with-mocks uses set! and restores in the done callback, keeping mocks alive across async boundaries. Converted 15 with-redefs usages across 4 test files: - workspace_reflow_test.cljs: 2 genuinely async tests (P1) - routes_test.cljs: 3 SSO caching tests (P2) - main_errors_test.cljs: 8 expired-org SSO tests (P2) - comments_test.cljs: 2 comment thread tests (P2) 36 purely sync with-redefs usages left unchanged — with-redefs is correct for synchronous code. AI-assisted-by: mimo-v2.5-pro * :paperclip: Fix fmt issues --------- Co-authored-by: Andrey Antukh --- .../app/main/data/workspace/libraries.cljs | 166 +++++---- frontend/src/app/main/data/workspace/mcp.cljs | 5 +- .../src/app/main/data/workspace/reflow.cljs | 195 +++++++---- .../main/data/workspace/reflow/signals.cljs | 141 ++++++++ .../app/main/data/workspace/selection.cljs | 19 + .../app/main/data/workspace/shape_layout.cljs | 5 +- .../src/app/main/data/workspace/shapes.cljs | 140 +++----- .../src/app/main/data/workspace/texts.cljs | 150 +++++--- .../app/main/data/workspace/wasm_text.cljs | 38 +- frontend/src/app/main/fonts.cljs | 51 ++- .../shapes/text/viewport_texts_html.cljs | 10 +- frontend/src/app/plugins/api.cljs | 16 +- frontend/src/app/plugins/reflow.cljs | 77 +++++ frontend/src/app/plugins/shape.cljs | 16 +- frontend/src/app/plugins/text.cljs | 8 +- frontend/src/app/plugins/utils.cljs | 8 - frontend/src/app/render_wasm/api/fonts.cljs | 141 ++++++-- .../data/workspace_reflow_test.cljs | 324 +++++++++++++++++- .../test/frontend_tests/main_errors_test.cljs | 280 +++++++-------- .../frontend_tests/plugins/comments_test.cljs | 88 ++--- .../plugins/context_shapes_test.cljs | 274 ++++++++++++++- .../test/frontend_tests/ui/routes_test.cljs | 124 ++++--- .../src/tests/wait-layout.test.ts | 110 +++++- plugins/apps/plugin-api-test-suite/src/ui.css | 33 +- plugins/apps/plugin-api-test-suite/src/ui.ts | 64 ++-- plugins/libs/plugin-types/index.d.ts | 16 +- 26 files changed, 1847 insertions(+), 652 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/reflow/signals.cljs create mode 100644 frontend/src/app/plugins/reflow.cljs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index d39d839ad7..fd2250c379 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -40,6 +40,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.notifications :as-alias dwn] [app.main.data.workspace.pages :as-alias dwpg] + [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.specialized-panel :as dwsp] @@ -1129,6 +1130,16 @@ (def valid-asset-types #{:colors :components :typographies}) +(defn- sync-file-pending-ids + [file-id changes] + ;; Track the file and every changed page object. + (into #{file-id} + (comp + (filter :page-id) + (keep :id) + (remove uuid/zero?)) + (:redo-changes changes))) + (defn set-updating-library [updating?] (ptk/reify ::set-updating-library @@ -1138,6 +1149,32 @@ (assoc state :updating-library true) (dissoc state :updating-library))))) +(defn- sync-file-frontend-events + [file-id changes updated-frames undo-group] + (rx/concat + (rx/of (set-updating-library false) + (ntf/hide {:tag :sync-dialog})) + (when (seq (:redo-changes changes)) + (rx/of (dch/commit-changes changes))) + (when-not (empty? updated-frames) + (let [frames-by-page (group-by :page-id updated-frames)] + (rx/merge + ;; Emit one layout/update event for each page. + (->> frames-by-page + (map (fn [[page-id frames]] + (ptk/data-event :layout/update + {:page-id page-id + :ids (map :id frames) + :undo-group undo-group}))) + (rx/from)) + (->> (rx/from updated-frames) + (rx/mapcat + (fn [shape] + (rx/of + (dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame") + (when-not (= (:frame-id shape) uuid/zero) + (dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame"))))))))))) + (defn sync-file "Synchronize the given file from the given library. Walk through all shapes in all pages in the file that use some color, typography or @@ -1196,35 +1233,20 @@ updated-frames (->> changes :redo-changes (mapcat find-frames) - distinct)] + distinct) + + pending-ids (sync-file-pending-ids file-id changes) + + frontend-sync + (sync-file-frontend-events + file-id changes updated-frames undo-group)] (log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes (:redo-changes changes) ldata)) (rx/concat - (rx/of (set-updating-library false) - (ntf/hide {:tag :sync-dialog})) - (when (seq (:redo-changes changes)) - (rx/of (dch/commit-changes changes))) - (when-not (empty? updated-frames) - (let [frames-by-page (->> updated-frames - (group-by :page-id))] - (rx/merge - ;; Emit one layout/update event for each page - (rx/from - (map (fn [[page-id frames]] - (ptk/data-event :layout/update - {:page-id page-id - :ids (map :id frames) - :undo-group undo-group})) - frames-by-page)) - (->> (rx/from updated-frames) - (rx/mapcat - (fn [shape] - (rx/of - (dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame") - (when-not (= (:frame-id shape) uuid/zero) - (dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame"))))))))) + ;; Keep the sync pending until its layout work starts. + (wrf/with-pending :sync-file pending-ids frontend-sync) (when (not= file-id library-id) ;; When we have just updated the library file, give some time for the @@ -1400,66 +1422,88 @@ (rx/buffer 2 1) (rx/map first)) - changes-s + ;; Barriers open before async inspection and close after detection. + pending-sync-barriers* (atom #{}) + + start-sync-barrier + (fn [{:keys [file-id save-undo?] :as event}] + (let [task (when (and save-undo? (uuid? file-id)) + (wrf/start! :sync-file [file-id]))] + (when task + (swap! pending-sync-barriers* conj task)) + [event task])) + + finish-sync-barrier! + (fn [task] + (when task + (wrf/finish! task) + (swap! pending-sync-barriers* disj task))) + + commits-s (->> stream (rx/filter dch/commit?) (rx/map deref) (rx/filter #(= :local (:source %))) + ;; Translation commits never propagate component changes. + (rx/filter (complement :translation?)) + ;; Keep waits pending while component changes are checked. + (rx/map start-sync-barrier) (rx/observe-on :async)) - check-changes + get-component-events (fn [[event old-data]] - (cond - (nil? old-data) - (rx/empty) + (let [{:keys [file-id changes save-undo? undo-group]} event + changed-components + (when (and old-data + (or (nil? file-id) (= file-id (:id old-data)))) + (into #{} + (mapcat (partial ch/components-changed old-data)) + changes))] + (cond + (empty? changed-components) + (rx/empty) - (:translation? event) - (rx/empty) + save-undo? + (do + (log/info :hint "detected component changes" + :ids (map str changed-components) + :undo-group undo-group) + (->> (rx/from changed-components) + (rx/map #(component-changed + % (:id old-data) undo-group)))) - :else - (let [{:keys [file-id changes save-undo? undo-group]} event + :else + ;; Undos only bump :modified-at. + (->> (rx/from changed-components) + (rx/map touch-component))))) - changed-components - (when (or (nil? file-id) (= file-id (:id old-data))) - (->> changes - (map (partial ch/components-changed old-data)) - (reduce into #{})))] - - (if (d/not-empty? changed-components) - (if save-undo? - (do (log/info :hint "detected component changes" - :ids (map str changed-components) - :undo-group undo-group) - (->> (rx/from changed-components) - (rx/map #(component-changed % (:id old-data) undo-group)))) - ;; save-undo? false (undos): just bump :modified-at - (->> (rx/from changed-components) - (rx/map touch-component))) - - (rx/empty))))) - - changes-s - (->> changes-s + component-events-s + (->> commits-s (rx/with-latest-from workspace-buffer-s) - (rx/mapcat check-changes) + (rx/mapcat + (fn [[[event task] old-data]] + (->> (get-component-events [event old-data]) + (rx/finalize #(finish-sync-barrier! task))))) + ;; Close barriers left behind when the page shuts down. + (rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*)) (rx/share)) notifier-s - (->> changes-s + (->> component-events-s (rx/debounce 5000) (rx/tap #(log/trc :hint "buffer initialized")))] (when (or (contains? cf/flags :component-thumbnails) (features/active-feature? state "render-wasm/v1")) (->> (rx/merge - changes-s + component-events-s ;; WASM only: render the thumbnail on every component ;; change so single edits (fill, etc.) update instantly. ;; Non-WASM persists on every render, so it stays on the ;; debounced path below to avoid per-edit backend posts. (if (features/active-feature? state "render-wasm/v1") - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/map render-component-thumbnail-event)) @@ -1467,7 +1511,7 @@ ;; Persist to the server in batches, 5s after the user ;; goes idle. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::component-changed)) (rx/map deref) (rx/buffer-until notifier-s) @@ -1476,7 +1520,7 @@ (update-component-thumbnail component-id file-id)))) ;; Undo/redo emit touch-component instead. - (->> changes-s + (->> component-events-s (rx/filter (ptk/type? ::touch-component)) (rx/map deref) (rx/map render-component-thumbnail-event))) @@ -1631,5 +1675,3 @@ (rx/mapcat (fn [_] (rp/cmd! :get-file-libraries {:file-id file-id}))) (rx/map (partial cleanup-unlinked-libraries file-id)))))) - - diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index fde7e22d6f..8931690c2b 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -14,6 +14,7 @@ [app.main.broadcast :as mbc] [app.main.data.plugins :as dp] [app.main.data.profile :as du] + [app.main.data.workspace :as-alias dw] [app.main.store :as st] [app.plugins.register :as preg] [app.util.timers :as ts] @@ -132,7 +133,7 @@ (assoc :host (str (u/join cf/public-uri "plugins/mcp/")))) stopper-s (rx/merge - (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::stop-mcp-plugin) stream)) extension #js {:getToken (constantly token) @@ -202,7 +203,7 @@ ptk/WatchEvent (watch [_ state stream] (let [stopper-s (rx/merge - (rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream) + (rx/filter (ptk/type? ::dw/finalize-workspace) stream) (rx/filter (ptk/type? ::init) stream)) session-id (get state :session-id) diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs index 3932aa7ca7..1fc4842672 100644 --- a/frontend/src/app/main/data/workspace/reflow.cljs +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -5,11 +5,13 @@ ;; Copyright (c) KALEIDOS INC (ns app.main.data.workspace.reflow - "Tracks the shape ids that have layout/reflow work in flight, broken down by - the kind of work so we can tell which type of reflow is blocking each shape. + "Tracks the ids that have layout/reflow work in flight, broken down by the + kind of work so we can tell which type of reflow is blocking each id. - Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer - opens an exact task with `start!` and closes that same task with `finish!`. + Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page + object ids plus, for `:sync-file`, the id of the file being synced. Every + producer opens an exact task with `start!` and closes that same task with + `finish!`. Tasks belong to a workspace generation, so a delayed completion from a finalized workspace cannot drain work opened after the workspace reloads. @@ -21,8 +23,10 @@ :layout flex/grid layout reflow (shape-layout) :text-resize text geometry resize (wasm-text, texts) :text-measure DOM text measurement (texts) + :text-position DOM text fragment geometry (texts) :text-bridge change awaiting its pipeline (texts) - :font font change measurement (texts)" + :font font change measurement (texts) + :sync-file component/library propagation (libraries)" (:require [beicon.v2.core :as rx] [promesa.core :as p])) @@ -55,22 +59,35 @@ acc ids))) +;; Single-task operations are wrapped as batches before reaching the reducer. (defn- reducer - [acc {:keys [op task ids]}] + [acc {:keys [op tasks ids]}] (case op - :add (add-task acc task) - :remove (remove-task acc task) + :add (reduce add-task acc tasks) + :remove (reduce remove-task acc tasks) :cancel (apply dissoc acc ids) :reset {} acc)) -;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`. -;; It replays its current value synchronously to new subscribers, which gives -;; `wait-for-layout-update` a free fast-path when there is nothing pending. -(defonce ^:private pending-shapes - (let [sub (rx/behavior-subject {})] - (rx/sub! (->> reflow-input (rx/scan reducer {})) sub) - sub)) +;; Holds pending tasks and replays them to new waiters. +;; Reloads rebuild the scan with the latest reducer. +(def ^:private pending-shapes (rx/behavior-subject {})) + +(defonce ^:private pending-subscription (atom nil)) + +(defn- install-pending-subscription! + [] + ;; Settle the old scan before installing the new one. + (swap! workspace-generation inc) + (rx/push! reflow-input {:op :reset}) + (when-let [subscription @pending-subscription] + (rx/dispose! subscription)) + (reset! pending-subscription + (rx/sub! (->> reflow-input (rx/scan reducer {})) + pending-shapes)) + (rx/push! reflow-input {:op :reset})) + +(install-pending-subscription!) (defn task "Creates an opaque task token without opening it." @@ -80,24 +97,42 @@ :kind kind :ids (into #{} ids)}) +(defn- push-tasks! + [op tasks] + ;; Empty and stale tasks must not affect the active workspace. + (let [generation @workspace-generation + tasks (into [] (filter #(and (seq (:ids %)) + (= (:generation %) generation))) + tasks)] + (when (seq tasks) + (rx/push! reflow-input {:op op :tasks tasks})) + tasks)) + +(defn- start-tasks! + "Opens task tokens in one pending-map update." + [tasks] + (push-tasks! :add tasks)) + (defn start! "Opens and returns a task. The one-argument form opens a token created with `task`; the two-argument form creates and opens it in one step." ([task] - (when (and (seq (:ids task)) - (= (:generation task) @workspace-generation)) - (rx/push! reflow-input {:op :add :task task})) + (push-tasks! :add [task]) task) ([kind ids] (start! (task kind ids)))) +(defn finish-tasks! + "Closes task tokens from the active workspace generation in one update." + [tasks] + (push-tasks! :remove tasks) + nil) + (defn finish! "Closes `task` if it belongs to the active workspace generation. Repeated or stale completion is a no-op." - [{:keys [generation ids] :as task}] - (when (and (seq ids) - (= generation @workspace-generation)) - (rx/push! reflow-input {:op :remove :task task}))) + [task] + (finish-tasks! [task])) (defn reset-pending! "Starts a new workspace generation and forgets every task from the old one." @@ -136,59 +171,77 @@ (finish! task) (throw cause))))) -(defn pending-signal - "Emits once any of `kinds` is pending for any of `ids`, then completes. - Emits right away when that work is already in flight." - [ids kinds] - (letfn [(id-pending? [pending id] - (some (partial contains? (get pending id)) kinds)) +(defn bridge-pending + "Keeps each id pending until matching work starts." + [ids target-kinds bridge-kind] + (let [ids (into #{} ids)] + (if (empty? ids) + (rx/empty) + (rx/create + (fn [subs] + ;; Separate tasks let renderer work release each shape independently. + (let [tasks-by-id + (into {} (map (fn [id] [id (task bridge-kind [id])])) ids) - (any-pending? [pending] - (some (partial id-pending? pending) ids))] - (->> pending-shapes - (rx/filter any-pending?) - (rx/take 1)))) + remaining + (atom ids) -;; Ceiling for callers that pass no timeout, so a pipeline that never drains -;; its marks rejects the promise rather than leaving it unsettled. -(def ^:private default-timeout 30000) + release! + (fn [released] + (let [released (into #{} (filter @remaining) released)] + (when (seq released) + (finish-tasks! (map tasks-by-id released)) + (swap! remaining #(apply disj % released)) + (when (empty? @remaining) + (rx/end! subs))))) -(defn wait-for-layout-update - "Returns a JS Promise that resolves when every id in `shape-ids` has drained - from the pending map. A nil `shape-ids` waits for every pending shape; an - empty one has nothing to wait for and resolves right away. The promise is - rejected when `timeout` (ms) elapses first; a nil `timeout` uses - `default-timeout`. + matching-task-ids + (fn [tasks] + (into #{} + (comp + (filter #(contains? target-kinds (:kind %))) + (mapcat :ids) + (filter ids)) + tasks)) + + ;; Listen before opening bridges so synchronous work is not missed. + lifecycle-sub + (rx/sub! + reflow-input + (fn [{:keys [op tasks ids]}] + (case op + :add + (release! (matching-task-ids tasks)) + + :cancel + (release! ids) + + :reset + (release! @remaining) + + nil))) + + _ + (start-tasks! (vals tasks-by-id))] + (fn [] + (rx/dispose! lifecycle-sub) + (when (seq @remaining) + (finish-tasks! (map tasks-by-id @remaining)) + (reset! remaining #{}))))))))) + +(defn settled + "Observable that emits once every id in `ids` has drained from the pending + map, then completes. A nil `ids` waits for every pending id; an empty one has + nothing to wait for. Replays on subscribe, so an already drained map emits + immediately. Callers waiting on one shape pass its whole subtree: reflow work lands either on the shape (a board laying out its children) or on its descendants (a group whose texts are re-measured)." - ([timeout] - (wait-for-layout-update nil timeout)) - ([shape-ids timeout] - (js/Promise. - (fn [resolve reject] - (let [timeout (or timeout default-timeout) - - done? (if (some? shape-ids) - (fn [pending] (not-any? #(contains? pending %) shape-ids)) - empty?) - - settled (->> pending-shapes - (rx/filter done?) - (rx/map (constantly :ok))) - - ;; Race the settle signal against the deadline; the loser is - ;; unsubscribed. `settled` replays on subscribe, so an already - ;; drained map wins even against a 1ms deadline. - source (rx/race (->> (rx/of :timeout) - (rx/delay timeout)) - settled)] - (->> source - (rx/take 1) - (rx/subs! - (fn [value] - (if (= value :timeout) - (reject (js/Error. "waitForLayoutUpdate timeout")) - (resolve))) - reject))))))) + [ids] + (let [done? (if (some? ids) + (fn [pending] (not-any? #(contains? pending %) ids)) + empty?)] + (->> pending-shapes + (rx/filter done?) + (rx/take 1)))) diff --git a/frontend/src/app/main/data/workspace/reflow/signals.cljs b/frontend/src/app/main/data/workspace/reflow/signals.cljs new file mode 100644 index 0000000000..d2c145676e --- /dev/null +++ b/frontend/src/app/main/data/workspace/reflow/signals.cljs @@ -0,0 +1,141 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.data.workspace.reflow.signals + "Decides which reflow signals a shape update raises: `:layout/update` for the + shapes whose layout attrs changed, `:text/reflow` for the texts the renderer + has to re-measure. + + Which text attrs matter depends on the renderer: the DOM one measures every + changed text, so its own geometry counts as a change; wasm only resizes + auto-sized texts from their content." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.files.changes-builder :as pcb] + [app.common.files.helpers :as cfh] + [app.common.math :as mth] + [app.main.features :as features])) + +;; If anything a translation can mutate is added here, drop the +;; `(when-not translation? …)` guard in `update-shapes`. +(def ^:private update-layout-attr? #{:hidden}) + +;; Text attrs that can start async renderer work. +(def ^:private text-reflow-attr? + #{:content :grow-type :x :y :width :height}) + +(def ^:private wasm-text-reflow-attr? + #{:content :grow-type}) + +(def ^:private dom-text-geometry-reflow-attr? + #{:x :y :width :height}) + +(defn- renderer-text-reflow-attr? + [state] + (if (features/active-feature? state "render-wasm/v1") + wasm-text-reflow-attr? + text-reflow-attr?)) + +(defn- reflow-attr? + [state attr] + (or (update-layout-attr? attr) + ((renderer-text-reflow-attr? state) attr))) + +;; Caller metadata can rule out reflow before objects are compared. +(defn- reflow-candidate? + [attr? {:keys [attrs translation? update-layout?] + :or {update-layout? true}}] + (and update-layout? + (not translation?) + (or (nil? attrs) + (some attr? attrs)))) + +(defn- text-reflow-changed? + [state shape changed-shape changed] + ;; Match the DOM renderer's geometry checks. + (let [wasm? (features/active-feature? state "render-wasm/v1") + reflow-attr? (renderer-text-reflow-attr? state)] + (some + (fn [attr] + (and (reflow-attr? attr) + (or wasm? + (not (dom-text-geometry-reflow-attr? attr)) + (not (mth/close? (get shape attr) + (get changed-shape attr)))))) + changed))) + +(defn- async-text-reflow? + "Whether `shape` enters an asynchronous text geometry pipeline. The HTML + renderer measures every changed text; WASM only resizes auto-sized texts. + A grow-type transition is included because `shape` is the value before the + update and may still be fixed." + [state shape changed] + (and (cfh/text-shape? shape) + (or (not (features/active-feature? state "render-wasm/v1")) + (not= :fixed (:grow-type shape)) + (contains? changed :grow-type)))) + +(defn- get-reflow-changes + [state objects changed-objects ids {:keys [attrs] :as props}] + ;; Reuse built objects so update functions only run once. + (let [reflow-attr? (partial reflow-attr? state)] + (when (reflow-candidate? reflow-attr? props) + (into [] + (comp + (map (d/getf objects)) + (keep (fn [shape] + (let [changed-shape (get changed-objects (:id shape)) + changed (pcb/changed-attrs + shape objects (constantly changed-shape) + {:attrs attrs})] + (when (some reflow-attr? changed) + [shape changed-shape changed]))))) + ids)))) + +(defn- get-layout-reflow-ids + [reflow-changes] + (->> reflow-changes + (into [] (comp (filter (fn [[_ _ changed]] (some update-layout-attr? changed))) + (map (comp :id first)))) + (not-empty))) + +(defn- get-text-reflow-ids + [state page-id reflow-changes] + ;; Track measurable texts on the active page. + (when (= page-id (get state :current-page-id)) + (let [edition (dm/get-in state [:workspace-local :edition])] + (->> reflow-changes + (into [] (comp (filter (fn [[shape changed-shape changed]] + (and (async-text-reflow? state shape changed) + (text-reflow-changed? + state shape changed-shape changed)))) + (map (comp :id first)) + (remove #(= % edition)))) + (not-empty))))) + +(defn reflow-ids + "Ids a shape update has to signal: `:layout-ids` for `:layout/update`, + `:text-ids` for `:text/reflow`. Both are nil when nothing changed. + + Both sets come from one comparison pass, so `update-fn` and the attribute + diff only run once per shape." + [state page-id objects changed-objects ids props] + (let [reflow-changes (get-reflow-changes state objects changed-objects ids props)] + {:layout-ids (get-layout-reflow-ids reflow-changes) + :text-ids (get-text-reflow-ids state page-id reflow-changes)})) + +(defn text-reflow-candidate? + "Whether `props` can start renderer text work, judged from the caller metadata + alone. Cheap pre-filter for callers that buffer updates before they have + objects to compare." + [state props] + (reflow-candidate? (renderer-text-reflow-attr? state) props)) + +(defn new-text-reflow? + "Whether a newly added `shape` enters an asynchronous text geometry pipeline." + [state shape] + (async-text-reflow? state shape nil)) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 36039f230b..838d6cc02e 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -29,6 +29,7 @@ [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] + [app.main.features :as features] [app.main.refs :as refs] [app.main.router :as rt] [app.main.streams :as ms] @@ -452,6 +453,16 @@ (gpt/subtract new-pos pt-obj))))) +(defn- get-new-dom-text-ids + [state changes] + (when-not (features/active-feature? state "render-wasm/v1") + (->> (:redo-changes changes) + (keep (fn [{:keys [type obj]}] + (when (and (= type :add-obj) + (cfh/text-shape? obj)) + (:id obj)))) + (not-empty)))) + (defn duplicate-shapes [ids & {:keys [move-delta? alt-duplication? change-selection? return-ref] :or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}] @@ -493,6 +504,9 @@ (map #(get-in % [:obj :id])) (into (d/ordered-set))) + new-dom-text-ids + (get-new-dom-text-ids state changes) + id-duplicated (first new-ids) frames (into #{} @@ -531,6 +545,11 @@ ;; Warning: This order is important for the focus mode. (->> (rx/of (dwu/start-undo-transaction undo-id) + ;; Track cloned texts before they mount. + (when new-dom-text-ids + (ptk/data-event :text/reflow + {:ids new-dom-text-ids + :page-id (:id page)})) (dch/commit-changes changes) (when change-selection? (select-shapes new-ids)) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index fade07bd2d..d4c09ec75e 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -23,6 +23,7 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.colors :as cl] [app.main.data.workspace.grid-layout.editor :as dwge] [app.main.data.workspace.modifiers :as dwm] @@ -131,14 +132,14 @@ (->> stream (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) (rx/take 1) - (rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)) + (rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream)) ;; No events are derived from this (rx/ignore)) (rx/empty))] (cond->> (rx/concat update-positions-stream drain-stream) (d/not-empty? reflow-tasks) - (rx/finalize #(run! wrf/finish! reflow-tasks))))))) + (rx/finalize #(wrf/finish-tasks! reflow-tasks))))))) (defn- without-root-board [ids] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 0b3cfb3f94..3530e35f3b 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -24,34 +24,12 @@ [app.main.data.workspace.collapse :as dwco] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.reflow :as wrf] + [app.main.data.workspace.reflow.signals :as wrfs] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.undo :as dwu] - [app.main.features :as features] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -;; If anything a translation can mutate is added here, drop the -;; `(when-not translation? …)` guard in `update-shapes` below. -(def ^:private update-layout-attr? #{:hidden}) - -;; Text attrs whose change makes the DOM text pipeline re-measure the shape. -(def ^:private text-reflow-attr? #{:content :grow-type}) - -(defn- reflow-attr? - [attr] - (or (update-layout-attr? attr) (text-reflow-attr? attr))) - -(defn- async-text-reflow? - "Whether `shape` enters an asynchronous text geometry pipeline. The HTML - renderer measures every changed text; WASM only resizes auto-sized texts. - A grow-type transition is included because `shape` is the value before the - update and may still be fixed." - [state shape changed] - (and (cfh/text-shape? shape) - (or (not (features/active-feature? state "render-wasm/v1")) - (not= :fixed (:grow-type shape)) - (contains? changed :grow-type)))) - (defn- add-undo-group [changes state] (let [undo (:workspace-undo state) @@ -82,15 +60,35 @@ (update [_ state] (assoc state ::update-shapes-buffer false)))) +(defn- get-buffered-text-reflow-event + [state page-id ids] + (when (= page-id (get state :current-page-id)) + ;; Analyze accumulated objects through the same path as immediate updates. + (let [objects (dsh/lookup-page-objects state page-id) + changed-objects (-> (get-in state [::update-shapes-buffer-changes page-id]) + (pcb/lookup-objects)) + {:keys [text-ids]} + (wrfs/reflow-ids state page-id objects changed-objects ids nil)] + (when text-ids + (ptk/data-event :text/reflow {:ids text-ids :page-id page-id}))))) + (defn update-shapes-buffer-commit [] (ptk/reify ::update-shapes-buffer-commit ptk/WatchEvent (watch [_ state _] - (->> (get state ::update-shapes-buffer-changes) - (vals) - (map dch/commit-changes) - (rx/from))))) + (let [text-reflow-events + (->> (get state ::update-shapes-buffer-text-candidates) + (keep (fn [[page-id ids]] + (get-buffered-text-reflow-event state page-id ids)))) + + commits + (->> (get state ::update-shapes-buffer-changes) + (vals) + (map dch/commit-changes))] + ;; Open bridges before commits start rendering. + (rx/concat (rx/from text-reflow-events) + (rx/from commits)))))) ;; Looks for the objects data in the state, if there is an "in progress" ;; update-shapes-buffer will return the objeccts inside the current changes @@ -111,7 +109,8 @@ (update-shapes-buffer ids update-fn nil)) ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation?] + ignore-touched undo-group with-objects? changed-sub-attr + translation?] :or {reg-objects? false save-undo? true stack-undo? false @@ -126,9 +125,14 @@ (assoc state ::update-shapes-buffer-event cur-event) (let [page-id (or page-id (get state :current-page-id)) - objects (dsh/lookup-page-objects state page-id)] - (-> state + objects (lookup-changed-objects state page-id) + text-ids + (into #{} + (filter #(cfh/text-shape? objects %)) + ids) + state (update-in + state [::update-shapes-buffer-changes page-id] (fn [changes] (-> (or changes @@ -148,7 +152,15 @@ :ignore-touched ignore-touched :with-objects? with-objects?}) (cond-> reg-objects? (pcb/resize-parents ids)) - (pcb/set-translation? translation?)))))))) + (pcb/set-translation? translation?))))] + ;; Check buffered text candidates when the buffer is committed. + (if (or (empty? text-ids) + (not (wrfs/text-reflow-candidate? state props))) + state + (update-in state + [::update-shapes-buffer-text-candidates page-id] + (fnil into #{}) + text-ids))))) ptk/WatchEvent (watch [_ state stream] @@ -165,6 +177,7 @@ (rx/of #(dissoc % ::update-shapes-buffer-changes + ::update-shapes-buffer-text-candidates ::update-shapes-buffer-event)))) (rx/empty))))))) @@ -174,14 +187,12 @@ ([ids update-fn {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation? - update-layout?] + ignore-touched undo-group with-objects? changed-sub-attr translation?] :or {reg-objects? false save-undo? true stack-undo? false ignore-touched false - with-objects? false - update-layout? true}}] + with-objects? false}}] (assert (every? uuid? ids) "expect a coll of uuid for `ids`") (assert (fn? update-fn) "the `update-fn` should be a valid function") @@ -197,49 +208,6 @@ objects (dsh/lookup-page-objects state page-id) ids (into [] (filter some?) ids) - ;; Pairs of [shape changed-attrs] for the shapes whose change - ;; matters to a reflow, feeding both id sets below. - xf-reflow - (comp - (map (d/getf objects)) - (keep (fn [shape] - (let [changed (pcb/changed-attrs shape objects update-fn - {:attrs attrs :with-objects? with-objects?})] - (when (some reflow-attr? changed) - [shape changed]))))) - - ;; `changed-attrs` runs `update-fn` in full for every shape, which - ;; can be expensive (e.g. `update-bool-shape` recalculates the whole - ;; boolean path in WASM). Skip the pass entirely when we can prove it - ;; cannot match: when the caller declares `attrs`, `changed-attrs` - ;; filters its result to that set, so if no reflow attr is present - ;; the check is always empty. - reflow-changes - (when-not (or translation? - (not update-layout?) - (and (some? attrs) - (not (some reflow-attr? attrs)))) - (into [] xf-reflow ids)) - - update-layout-ids - (->> reflow-changes - (into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed))) - (map (comp :id first)))) - (not-empty)) - - ;; Text shapes the DOM pipeline has to re-measure, narrowed to what - ;; it actually measures: the active page, never the edited shape. - text-reflow-ids - (when (= page-id (get state :current-page-id)) - (let [edition (dm/get-in state [:workspace-local :edition])] - (->> reflow-changes - (into [] (comp (filter (fn [[shape changed]] - (and (async-text-reflow? state shape changed) - (some text-reflow-attr? changed)))) - (map (comp :id first)) - (remove #(= % edition)))) - (not-empty)))) - changes (-> (pcb/empty-changes it page-id) (pcb/set-save-undo? save-undo?) @@ -257,6 +225,12 @@ (pcb/set-undo-group undo-group)) (pcb/set-translation? translation?)) + changed-objects + (pcb/lookup-objects changes) + + {:keys [layout-ids text-ids]} + (wrfs/reflow-ids state page-id objects changed-objects ids props) + changes (add-undo-group changes state)] @@ -264,8 +238,8 @@ ;; Announces the texts still to be re-measured, so a reflow wait ;; covers the render that measures them. Goes before the commit, ;; which is what triggers that render. - (if text-reflow-ids - (rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id})) + (if text-ids + (rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id})) (rx/empty)) (if (seq (:redo-changes changes)) @@ -274,8 +248,8 @@ (rx/empty)) ;; Update layouts for properties marked - (if update-layout-ids - (rx/of (ptk/data-event :layout/update {:ids update-layout-ids})) + (if layout-ids + (rx/of (ptk/data-event :layout/update {:ids layout-ids})) (rx/empty))))))))) (defn add-shape @@ -321,7 +295,7 @@ (rx/of (dwu/start-undo-transaction undo-id) ;; A new text has no geometry until the pipeline measures it, ;; so it raises the same signal an edit does. - (when (async-text-reflow? state shape nil) + (when (wrfs/new-text-reflow? state shape) (ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id})) (dch/commit-changes changes) (when-not no-update-layout? diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index fb33068adc..3db75ccb8e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -24,9 +24,11 @@ [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.modifiers :as dwm] + [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] @@ -66,15 +68,19 @@ "Marks `ids` pending until the text pipeline marks its own work: `:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing." [ids] - (->> (rx/from ids) - ;; Each id owns its bridge. Starting work for one text must not release - ;; siblings that the renderer has not picked up yet. - (rx/mapcat - (fn [id] - (->> (wrf/pending-signal [id] #{:text-measure :text-resize}) - (rx/ignore) - (wrf/with-pending :text-bridge [id])))) - (rx/ignore))) + (wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge)) + +(defn- page-finalize? + [event] + (= ::dwpg/finalize-page (ptk/type event))) + +(defn- text-work-stopper + [stream] + (rx/filter + (fn [event] + (or (= ::dw/finalize-workspace (ptk/type event)) + (page-finalize? event))) + stream)) (defn initialize-text-reflow "Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait @@ -83,11 +89,15 @@ (ptk/reify ::initialize-text-reflow ptk/WatchEvent (watch [_ _ stream] - (let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)] + (let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream) + page-stopper (rx/filter page-finalize? stream)] (->> stream (rx/filter (ptk/type? :text/reflow)) (rx/map deref) - (rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids))) + (rx/merge-map + (fn [{:keys [ids]}] + (->> (bridge-to-measurement ids) + (rx/take-until page-stopper)))) (rx/take-until stopper)))))) (defn finalize-text-reflow @@ -110,28 +120,51 @@ :else []))) +(defn- await-font-faces + "Waits for missing WASM faces, then resizes the affected texts." + [stream face-keys ids] + (let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))] + (if (empty? face-keys) + resize-stream + (->> (rx/merge wasm.fonts/font-stored-stream + wasm.fonts/font-storage-failed-stream) + (rx/filter face-keys) + (rx/scan disj face-keys) + (rx/filter empty?) + (rx/take 1) + (rx/take-until (text-work-stopper stream)) + (rx/observe-on :async) + (rx/mapcat (constantly resize-stream)) + (wrf/with-pending :font ids))))) + +(defn- pending-font-faces + [ids] + (let [objects (dsh/lookup-page-objects @st/state)] + (into #{} + (comp + (map #(get objects %)) + (keep :content) + (mapcat wasm.fonts/get-content-fonts) + (map wasm.fonts/make-font-data) + (remove wasm.fonts/font-ready?) + (map wasm.fonts/font-data-key)) + ids))) + (defn- await-font-resize - "Marks `ids` as pending font work and dispatches their wasm resize once wasm - can measure with `font-id`, draining the marks afterwards. The fetch of that - font is started by the wasm shape sync of the content change these shapes - receive, so measuring before it lands would use the fallback font." - [stream font-id ids] + "Waits for missing font faces, then resizes `ids`." + [stream ids] (if (empty? ids) (rx/empty) - (let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)] - (->> wasm.fonts/font-stored-stream - (rx/filter #(= % font-id)) - (rx/take 1) - (rx/take-until stopper) - (rx/observe-on :async) - (rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids)))) - (wrf/with-pending :font ids))))) + (->> (rx/of ::await-fonts) + (rx/mapcat + (fn [_] + (await-font-faces stream (pending-font-faces ids) ids)))))) (defn- await-html-font "Keeps legacy DOM text pending while its new font is loading. The DOM measurement also awaits this promise, so the font task bridges the state update to the renderer commit without relying on a fixed settle delay." - [font-id font-variant-id ids] + [stream font-id font-variant-id ids] (if (or (nil? font-id) (empty? ids)) (rx/empty) (->> (rx/of ::load-font) @@ -140,6 +173,7 @@ ;; gap before the task is visible to waiters. (rx/mapcat (fn [_] (rx/from (fonts/ensure-loaded! font-id font-variant-id)))) + (rx/take-until (text-work-stopper stream)) (rx/ignore) (wrf/with-pending :font ids)))) @@ -525,7 +559,7 @@ [id start end attrs] (ptk/reify ::update-text-range ptk/WatchEvent - (watch [_ state _] + (watch [_ state stream] (let [objects (dsh/lookup-page-objects state) shape (get objects id) @@ -547,7 +581,7 @@ (rx/map dwwt/resize-wasm-text-debounce)) (contains? attrs :font-id) - (await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids) + (await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids) :else (rx/empty))))))) @@ -798,7 +832,7 @@ (watch [_ state stream] (wrf/start! reflow-task) (if (= (::resize-text-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream @@ -809,7 +843,7 @@ (rx/take-until stopper)) (rx/of (resize-text id new-width new-height))) (rx/of (fn [state] - (run! wrf/finish! (::resize-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-text-reflow-tasks state)) (dissoc state ::resize-text-debounce-props ::resize-text-reflow-tasks @@ -878,7 +912,7 @@ ptk/WatchEvent (watch [_ state stream] (if (= (::update-text-modifier-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream @@ -926,40 +960,49 @@ ptk/WatchEvent (watch [_ state _] (let [position-data (::update-position-data state)] - (rx/concat - (rx/of (dwsh/update-shapes - (keys position-data) - (fn [shape] - (-> shape - (assoc :position-data (get position-data (:id shape))))) - {:stack-undo? true :reg-objects? false})) - (rx/of (fn [state] - (dissoc state ::update-position-data-debounce ::update-position-data)))))))) + (rx/of (dwsh/update-shapes + (keys position-data) + (fn [shape] + (-> shape + (assoc :position-data (get position-data (:id shape))))) + {:stack-undo? true :reg-objects? false})))))) (defn update-position-data [id position-data] - (let [cur-event (js/Symbol)] + (let [cur-event (js/Symbol) + reflow-task (wrf/task :text-position [id])] (ptk/reify ::update-position-data ptk/UpdateEvent (update [_ state] (let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)] - (if (nil? (::update-position-data-debounce state)) - (assoc state ::update-position-data-debounce cur-event) - (assoc-in state [::update-position-data id] position-data)))) + (-> state + (update ::update-position-data-reflow-tasks (fnil conj []) reflow-task) + (cond-> (nil? (::update-position-data-debounce state)) + (assoc ::update-position-data-debounce cur-event)) + (cond-> (some? (::update-position-data-debounce state)) + (assoc-in [::update-position-data id] position-data))))) ptk/WatchEvent (watch [_ state stream] + (wrf/start! reflow-task) (if (= (::update-position-data-debounce state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] - (rx/merge - (->> stream - (rx/filter (ptk/type? ::update-position-data)) - (rx/debounce 50) - (rx/take 1) - (rx/map #(commit-position-data)) - (rx/take-until stopper)) - (rx/of (update-position-data id position-data)))) + (let [stopper (text-work-stopper stream)] + (rx/concat + (rx/merge + (->> stream + (rx/filter (ptk/type? ::update-position-data)) + (rx/debounce 50) + (rx/take 1) + (rx/map #(commit-position-data)) + (rx/take-until stopper)) + (rx/of (update-position-data id position-data))) + (rx/of (fn [state] + (wrf/finish-tasks! (::update-position-data-reflow-tasks state)) + (dissoc state + ::update-position-data-debounce + ::update-position-data + ::update-position-data-reflow-tasks))))) (rx/empty)))))) (defn update-attrs @@ -1010,7 +1053,7 @@ (let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)] (if (contains? attrs :font-id) ;; The geometry depends on the font, so wait until wasm has it. - (await-font-resize stream (:font-id attrs) auto-ids) + (await-font-resize stream auto-ids) ;; No font change: measurable right away. (->> (rx/from auto-ids) (rx/map dwwt/resize-wasm-text))))) @@ -1019,6 +1062,7 @@ ;; but font loading starts before that render commits. (if (contains? attrs :font-id) (await-html-font + stream (:font-id attrs) (:font-variant-id attrs) text-ids) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index eba1fdb8f6..bb98793dc3 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -16,6 +16,7 @@ [app.common.geom.point :as gpt] [app.common.types.modifiers :as ctm] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] @@ -159,7 +160,7 @@ (watch [_ state stream] (wrf/start! reflow-task) (if (= (::resize-wasm-text-debounce-event state) cur-event) - (let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))] + (let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))] (rx/concat (rx/merge (->> stream @@ -179,7 +180,7 @@ ;; pending until the resize is applied. All exact tasks in the ;; batch are retained in state and finished by the cleanup. (rx/of (fn [state] - (run! wrf/finish! (::resize-wasm-text-reflow-tasks state)) + (wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state)) (dissoc state ::resize-wasm-text-debounce-ids ::resize-wasm-text-reflow-tasks @@ -198,15 +199,15 @@ content (dm/get-in objects [id :content]) fonts (wasm.fonts/get-content-fonts content) - fonts-loaded? + fonts-ready? (->> fonts (every? (fn [font] (let [font-data (wasm.fonts/make-font-data font)] - (wasm.fonts/font-stored? font-data (:emoji? font-data)))))) + (wasm.fonts/font-ready? font-data))))) resize-wasm-stream - (if fonts-loaded? + (if fonts-ready? (let [pass-opts (when (or (some? undo-group) (some? undo-id)) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) @@ -232,15 +233,32 @@ (watch [_ state stream] (let [resize-stream (->> (rx/from ids) - (rx/map #(resize-wasm-text-debounce % opts)))] + (rx/map #(resize-wasm-text-debounce % opts))) + + buffer-finished-stream + (->> (rx/merge + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) + (rx/map (constantly :commit))) + ;; Let a buffered commit beat the stop signal. + (->> stream + (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop)) + (rx/observe-on :async) + (rx/map (constantly :stop))) + (->> stream + (rx/filter (ptk/type? ::dw/finalize-workspace)) + (rx/map (constantly :finalize)))) + (rx/take 1))] (if (::dwsh/update-shapes-buffer state) ;; If we're in the middle of a token propagation we wait until is finished to ;; recalculate the text sizes. The shapes stay pending for that whole wait, ;; since the per-shape debounce only marks them once dispatched. (wrf/with-pending :text-resize ids - (->> stream - (rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit)) - (rx/take 1) - (rx/mapcat (constantly resize-stream)))) + (->> buffer-finished-stream + (rx/mapcat + (fn [reason] + (if (= reason :finalize) + (rx/empty) + resize-stream))))) resize-stream)))))) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 677f8aa1fb..15fa05534b 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -18,6 +18,7 @@ [app.util.globals :as globals] [app.util.http :as http] [app.util.object :as obj] + [app.util.timers :as tm] [beicon.v2.core :as rx] [cuerdas.core :as str] [okulary.core :as l] @@ -243,8 +244,10 @@ (defmulti ^:private load-font :backend) (defmethod load-font :default - [{:keys [backend] :as font}] - (log/wrn :msg "no implementation found for" :backend backend)) + [{:keys [backend ::on-failed] :as font}] + (log/wrn :msg "no implementation found for" :backend backend) + (when (fn? on-failed) + (on-failed (ex-info "unsupported font backend" {:backend backend})))) (defmethod load-font :builtin [{:keys [id ::on-loaded] :as font}] @@ -269,23 +272,30 @@ (let [base (u/join cf/public-uri "internal/gfonts/font")] (str/replace css "https://fonts.gstatic.com/s" (dm/str base)))) -(defn- fetch-gfont-css +(defn- request-gfont-css [url] (->> (http/send! {:method :get :uri url :mode :cors :response-type :text}) - (rx/map :body) - (rx/catch (fn [err] - (log/wrn :hint "cannot find the font" :cause err) + (rx/map :body))) + +(defn- fetch-gfont-css + [url] + (->> (request-gfont-css url) + (rx/catch (fn [cause] + ;; Keep CSS streams alive when a font cannot load. + (log/wrn :hint "cannot find the font" :cause cause) (rx/empty))))) (defmethod load-font :google - [{:keys [id ::on-loaded] :as font}] + [{:keys [id ::on-loaded ::on-failed] :as font}] (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "google") (let [url (generate-gfonts-url font)] - (->> (fetch-gfont-css url) + ;; Keep raw errors so the loader can use its fallback. + (->> (request-gfont-css url) (rx/map process-gfont-css) (rx/tap #(on-loaded id)) - (rx/subs! (partial add-font-css! id))) + (rx/subs! (partial add-font-css! id) + #(when (fn? on-failed) (on-failed %)))) nil))) ;; --- LOADER: CUSTOM @@ -358,15 +368,30 @@ ;; First caller, we create the promise and then wait :else - (let [on-load (fn [resolve] - (swap! loaded conj font-id) - (swap! loading dissoc font-id) - (resolve font-id)) + (let [settle! (fn [resolve loaded?] + ;; Defer cleanup until a synchronous load is cached. + (tm/schedule + #(do + (when loaded? + (swap! loaded conj font-id)) + (swap! loading dissoc font-id) + (resolve font-id)))) + + on-load (fn [resolve] + (settle! resolve true)) + + on-failed + (fn [resolve cause] + (log/wrn :hint "font load failed; using fallback" + :font-id font-id + :cause cause) + (settle! resolve false)) load-p (-> (p/create (fn [resolve _] (-> font (assoc ::on-loaded (partial on-load resolve)) + (assoc ::on-failed (partial on-failed resolve)) (load-font)))) ;; We need to wait for the font to be loaded (p/then (partial p/delay 120)))] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 77d659c17b..634392e97a 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -12,6 +12,7 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.text :as gsht] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.modifiers :as ctm] [app.common.types.text :as txt] @@ -96,9 +97,12 @@ (st/emit! (dwt/resize-text id width height))))) (st/emit! (dwt/clean-text-modifier id)))) - ;; Swallowed so a text whose position data cannot be computed still - ;; settles and still reports its measurement as finished. - (p/catch (fn [_] nil)))) + ;; Always clear the task and log measurement errors. + (p/catch (fn [cause] + (log/error :hint "Could not measure text shape" + :shape-id id + :cause cause) + nil)))) (defn- update-text-modifier [{:keys [grow-type id] :as shape} node] diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index bea36ad027..6582b76e62 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -28,7 +28,6 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.pages :as dwpg] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.variants :as dwv] [app.main.data.workspace.wasm-text :as dwwt] @@ -47,6 +46,7 @@ [app.plugins.local-storage :as local-storage] [app.plugins.page :as page] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.shape :as shape] [app.plugins.system-events :as se] [app.plugins.user :as user] @@ -416,7 +416,10 @@ (cb/with-objects (:objects page)) (cb/add-object shape))] - (st/emit! (ch/commit-changes changes) + ;; Track the commit until the renderer starts. + (st/emit! (ptk/data-event :text/reflow {:ids [(:id shape)] + :page-id (:id page)}) + (ch/commit-changes changes) (se/event plugin-id "create-shape" :type :text)) (when (features/active-feature? @st/state "render-wasm/v1") @@ -734,10 +737,5 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? timeout) - ;; Resolves once every shape with reflow work in flight has settled. - (wrf/wait-for-layout-update timeout) - (js/Promise. - (fn [_ reject] - (u/reject-not-valid reject :waitForLayoutUpdate timeout))))))) + ;; Resolves once every shape with reflow work in flight has settled. + (wrfp/wait-for-layout-update timeout)))) diff --git a/frontend/src/app/plugins/reflow.cljs b/frontend/src/app/plugins/reflow.cljs new file mode 100644 index 0000000000..306fff667b --- /dev/null +++ b/frontend/src/app/plugins/reflow.cljs @@ -0,0 +1,77 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.plugins.reflow + "Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the + argument validation, the default deadline and the rejection shape; the + workspace only reports when its pending work has drained." + (:require + [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] + [app.common.uuid :as uuid] + [app.main.data.workspace.reflow :as wrf] + [beicon.v2.core :as rx])) + +;; Ceiling for callers that pass no timeout, so a pipeline that never drains +;; its marks rejects the promise rather than leaving it unsettled. +(def ^:private default-timeout 30000) + +;; Largest value a signed 32-bit timer accepts. +(def ^:private max-timeout 2147483647) + +(defn- valid-timeout? + "Checks that a plugin timeout fits a signed 32-bit timer." + [value] + (or (nil? value) + (and (number? value) + (pos? value) + (<= value max-timeout) + (js/Number.isFinite value)))) + +(defn- reject-invalid! + [reject value] + (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value + ". Code: " :waitForLayoutUpdate)] + (.error js/console msg) + (reject (js/Error. msg)))) + +(defn shape-wait-ids + "Ids a per-shape wait covers: the shape subtree, its ancestors, and the file + its components sync from." + [objects file-id id] + (-> (into #{} (cfh/get-children-ids-with-self objects id)) + (into (cfh/get-parent-ids objects id)) + (conj file-id) + (disj uuid/zero))) + +(defn wait-for-layout-update + "Returns a JS Promise that resolves once every id in `ids` has drained from + the workspace pending map. A nil `ids` waits for every pending id; an empty + one has nothing to wait for and resolves right away. + + The promise is rejected when `timeout` (ms) is not a valid timer value, or + when it elapses first; a nil `timeout` uses `default-timeout`." + ([timeout] + (wait-for-layout-update nil timeout)) + ([ids timeout] + (js/Promise. + (fn [resolve reject] + (if-not (valid-timeout? timeout) + (reject-invalid! reject timeout) + ;; Race the settle signal against the deadline; the loser is + ;; unsubscribed. `settled` replays on subscribe, so an already drained + ;; map wins even against a 1ms deadline. + (->> (rx/race (->> (rx/of :timeout) + (rx/delay (or timeout default-timeout))) + (->> (wrf/settled ids) + (rx/map (constantly :ok)))) + (rx/take 1) + (rx/subs! + (fn [value] + (if (= value :timeout) + (reject (js/Error. "waitForLayoutUpdate timeout")) + (resolve))) + reject))))))) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 88177b39ab..205f7d0d97 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -43,7 +43,6 @@ [app.main.data.workspace.guides :as dwgu] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] - [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] @@ -58,6 +57,7 @@ [app.plugins.format :as format] [app.plugins.grid :as grid] [app.plugins.parser :as parser] + [app.plugins.reflow :as wrfp] [app.plugins.register :as r] [app.plugins.ruler-guides :as rg] [app.plugins.shadows :as shadows] @@ -1057,15 +1057,11 @@ :waitForLayoutUpdate (fn [timeout] - ;; Always a promise, so a bad argument travels as a rejection. - (if (u/valid-timeout? timeout) - ;; Resolves once the reflow work of this shape's subtree has - ;; settled: it can be marked on the shape or on its descendants. - (let [objects (u/locate-objects file-id page-id)] - (wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout)) - (js/Promise. - (fn [_ reject] - (u/reject-not-valid reject :waitForLayoutUpdate timeout))))) + ;; Wait for layout work that can affect this shape. + (let [objects (u/locate-objects file-id page-id)] + (wrfp/wait-for-layout-update + (wrfp/shape-wait-ids objects file-id id) + timeout))) ;; Plugin data :getPluginData diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index 3692ae1a59..f8e32458de 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -499,10 +499,10 @@ (u/not-valid plugin-id :growType "Cannot modify a page that is not currently active") :else - (st/emit! - (dwsh/update-shapes [id] #(assoc % :grow-type value)) - (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} + (do + (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))) + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-debounce id)))))))} {:name "fontId" :get #(-> % u/proxy->shape text-props :font-id format/format-mixed) diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 21afd5cdde..49622d9710 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -291,14 +291,6 @@ (throw-not-valid code value) (display-not-valid code value))) -(defn valid-timeout? - "A plugin timeout argument: omitted, or a finite positive number of msecs." - [value] - (or (nil? value) - (and (number? value) - (pos? value) - (js/Number.isFinite value)))) - (defn reject-not-valid [reject code value] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c3d5a32a35..eef60480fe 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -30,11 +30,30 @@ (def ^:private custom-fonts (l/derived :fonts st/state)) -;; Emits the font-id of every font whose glyphs wasm can already shape and -;; measure with. The browser-side loading of `app.main.fonts` is a separate -;; signal: it only says the DOM can render the font. +;; Emits every font face that WASM can measure. (defonce font-stored-stream (rx/subject)) +;; Emits failed font faces so layout can fall back. +(defonce font-storage-failed-stream (rx/subject)) + +;; Stores faces that currently use WASM fallbacks. +(defonce ^:private failed-font-data-keys (atom #{})) + +(defn font-data-key + "Returns the identity WASM uses to distinguish stored faces in one family." + [font-data] + (select-keys font-data [:font-id :weight :style :emoji?])) + +(defn- clear-font-storage-failure! + [font-data] + (swap! failed-font-data-keys disj (font-data-key font-data))) + +(defn- report-font-storage-failed! + [font-data] + (let [key (font-data-key font-data)] + (swap! failed-font-data-keys conj key) + (rx/push! font-storage-failed-stream key))) + (def ^:private default-font-size 14) (def ^:private default-line-height 1.2) (def ^:private default-letter-spacing 0.0) @@ -157,39 +176,91 @@ (:style font-data) emoji? fallback?) + (clear-font-storage-failure! font-data) ;; Reported after the store call: subscribers react by measuring text. - (rx/push! font-stored-stream (:font-id font-data)) + (rx/push! font-stored-stream (font-data-key font-data)) true))) -;; 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. +;; Tracks every font face waiting on each shared request. (def fetching (atom {})) +(defn- register-font-fetch! + [font-url font-data emoji? fallback?] + (let [key (font-data-key font-data)] + (clear-font-storage-failure! font-data) + (swap! fetching + update-in + [font-url key] + (fn [request] + {:font-data font-data + :emoji? emoji? + :fallback? (or fallback? (:fallback? request))})))) + +(defn- take-font-fetches! + [font-url] + (let [requests (vals (get @fetching font-url))] + (swap! fetching dissoc font-url) + requests)) + +(defn- fail-font-fetches! + [font-url cause] + (let [requests (take-font-fetches! font-url)] + (log/error :hint "Could not fetch font" + :font-url font-url + :cause cause) + (doseq [{:keys [font-data]} requests] + (report-font-storage-failed! font-data)))) + +(defn- store-font-fetch! + [body {:keys [font-data emoji? fallback?]}] + (try + (let [stored? (store-font-buffer font-data body emoji? fallback?)] + (when-not stored? + (report-font-storage-failed! font-data)) + stored?) + (catch :default cause + (log/error :hint "Could not store font" + :font-id (:font-id font-data) + :cause cause) + (report-font-storage-failed! font-data) + false))) + (defn- fetch-font [font-data font-url emoji? fallback?] - (if (contains? @fetching font-url) - (do (when fallback? (swap! fetching assoc font-url true)) - nil) + (cond + (nil? font-url) + ;; Fail missing font assets without sharing a nil request. (do - (swap! fetching assoc font-url fallback?) + (clear-font-storage-failure! font-data) + (tm/schedule #(report-font-storage-failed! font-data)) + nil) + + (contains? @fetching font-url) + (do + (register-font-fetch! font-url font-data emoji? fallback?) + nil) + + :else + (do + (register-font-fetch! font-url font-data emoji? 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)))))}))) + (try + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map + (fn [{:keys [body]}] + (let [requests (take-font-fetches! font-url)] + (mapv (partial store-font-fetch! body) requests)))) + (rx/catch + (fn [cause] + (fail-font-fetches! font-url cause) + (rx/empty)))) + (catch :default cause + (fail-font-fetches! font-url cause) + (rx/empty))))}))) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] @@ -220,9 +291,15 @@ (:style font-data) emoji?)))) +(defn font-ready? + "Returns true when WASM can lay out with the requested face or its fallback." + [font-data] + (or (contains? @failed-font-data-keys (font-data-key font-data)) + (font-stored? font-data (:emoji? font-data)))) + (defn- store-font-id [font-data asset-id emoji? fallback?] - (when asset-id + (if asset-id (let [uri (font-id->ttf-url (:font-id font-data) asset-id (:font-variant-id font-data) @@ -234,8 +311,16 @@ (if font-stored? ;; Deferred so consumers, which subscribe after dispatching the sync ;; that lands here, are listening when an already-stored font reports. - (tm/schedule #(rx/push! font-stored-stream (:font-id font-data))) - (fetch-font font-data uri emoji? fallback?))))) + (do + (clear-font-storage-failure! font-data) + (tm/schedule #(rx/push! font-stored-stream (font-data-key font-data)))) + (fetch-font font-data uri emoji? fallback?))) + ;; Report missing font assets asynchronously. + (do + (clear-font-storage-failure! font-data) + (tm/schedule + #(report-font-storage-failed! font-data)) + nil))) (defn serialize-font-style [font-style] diff --git a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs index a1a50bf985..efbf9234e9 100644 --- a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs @@ -6,13 +6,24 @@ (ns frontend-tests.data.workspace-reflow-test "Tests the reflow tasks the layout and text pipelines feed to - `app.main.data.workspace.reflow`, which is what plugin waits observe." + `app.main.data.workspace.reflow`, which is what plugin waits observe. The + promise view of the settle signal lives in `app.plugins.reflow`; these tests + use it because it is the wait the plugin API ships." (:require [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shape-layout :as dwsl] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtxt] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.fonts :as fonts] + [app.plugins.reflow :as pwrf] + [app.render-wasm.api.fonts :as wasm.fonts] + [app.util.globals :as globals] + [app.util.http :as http] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [potok.v2.core :as ptk])) (t/use-fixtures :each {:before wrf/reset-pending! @@ -43,7 +54,7 @@ (t/async done (let [store (start-pipeline!)] (ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]})) - (-> (wrf/wait-for-layout-update nil 20) + (-> (pwrf/wait-for-layout-update nil 20) (.then #(t/is true "resolved with no pending work")) (.catch #(t/is false "a root-only update was marked as pending work")) (.then (fn [] @@ -59,16 +70,41 @@ _ (wrf/reset-pending!) current-task (wrf/start! :text-measure [id])] (wrf/finish! stale-task) - (-> (wrf/wait-for-layout-update [id] 20) + (-> (pwrf/wait-for-layout-update [id] 20) (.then #(t/is false "a stale completion drained current work")) (.catch #(t/is true "current work stayed pending")) (.then (fn [] (wrf/finish! current-task) - (wrf/wait-for-layout-update [id] 100))) + (pwrf/wait-for-layout-update [id] 100))) (.then #(t/is true "the exact current task drained normally")) (.catch #(t/is false "the current task did not drain")) (.then (fn [] (done))))))) +(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking + ;; Reinstall the pending scan with the latest reducer. + (t/async done + (let [id (uuid/next) + stale (wrf/start! :text-measure [id]) + current* (atom nil)] + (#'wrf/install-pending-subscription!) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then #(t/is true "reinstalling the scan reset its previous generation")) + (.catch #(t/is false "the replaced scan kept stale work pending")) + (.then + (fn [] + (reset! current* (wrf/start! :text-measure [id])) + (pwrf/wait-for-layout-update [id] 20))) + (.then #(t/is false "the replacement scan did not track new work")) + (.catch #(t/is true "the replacement scan tracked new work")) + (.then + (fn [] + (wrf/finish! stale) + (wrf/finish! @current*) + (pwrf/wait-for-layout-update [id] 100))) + (.then #(t/is true "the replacement scan drained its exact task")) + (.catch #(t/is false "the replacement scan did not drain")) + (.then (fn [] (done))))))) + (t/deftest pending-promise-finishes-at-the-operation-boundary ;; Imperative render work is pending from before its thunk starts until the ;; exact promise returned by that thunk settles; no timer is involved. @@ -82,12 +118,12 @@ (fn [] (reset! started? true) (js/Promise. (fn [resolve _] (reset! resolve* resolve))))) - (-> (wrf/wait-for-layout-update [id] 20) + (-> (pwrf/wait-for-layout-update [id] 20) (.then #(t/is false "resolved while the render operation was pending")) (.catch #(t/is @started? "the task was opened before running the operation")) (.then (fn [] (@resolve*) - (wrf/wait-for-layout-update [id] 100))) + (pwrf/wait-for-layout-update [id] 100))) (.then #(t/is true "resolved as soon as the render operation settled")) (.catch #(t/is false "the settled render operation stayed pending")) (.then (fn [] (done))))))) @@ -98,7 +134,7 @@ (wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom"))) (catch :default _)) (t/async done - (-> (wrf/wait-for-layout-update [id] 100) + (-> (pwrf/wait-for-layout-update [id] 100) (.then #(t/is true "a synchronous failure drained its exact task")) (.catch #(t/is false "a synchronous failure leaked pending work")) (.then (fn [] (done))))))) @@ -110,10 +146,10 @@ task-a (wrf/start! :text-bridge [id-a]) task-b (wrf/start! :text-bridge [id-b])] (wrf/cancel-shapes! [id-a]) - (-> (wrf/wait-for-layout-update [id-a] 100) + (-> (pwrf/wait-for-layout-update [id-a] 100) (.then #(t/is true "deleted shape work was cancelled")) (.catch #(t/is false "deleted shape work stayed pending")) - (.then #(wrf/wait-for-layout-update [id-b] 20)) + (.then #(pwrf/wait-for-layout-update [id-b] 20)) (.then #(t/is false "cancelling one shape drained its sibling")) (.catch #(t/is true "sibling work stayed pending")) (.then (fn [] @@ -130,28 +166,290 @@ (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) (let [task-a (wrf/start! :text-measure [id-a])] (wrf/finish! task-a)) - (-> (wrf/wait-for-layout-update [id-b] 20) + (-> (pwrf/wait-for-layout-update [id-b] 20) (.then #(t/is false "the first text released its sibling bridge")) (.catch #(t/is true "the sibling bridge stayed pending")) (.then (fn [] (let [task-b (wrf/start! :text-measure [id-b])] (wrf/finish! task-b)) - (wrf/wait-for-layout-update [id-b] 100))) + (pwrf/wait-for-layout-update [id-b] 100))) (.then #(t/is true "the sibling drained after its own measurement")) (.catch #(t/is false "the sibling never drained")) (.then (fn [] (stop-text-pipeline! store) (done))))))) +(t/deftest text-bridge-observes-out-of-order-work + ;; Start all bridges before matching work can finish. + (t/async done + (let [store (start-text-pipeline!) + id-a (uuid/next) + id-b (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]})) + (let [task-b (wrf/start! :text-measure [id-b])] + (wrf/finish! task-b)) + (let [task-a (wrf/start! :text-measure [id-a])] + (wrf/finish! task-a)) + (-> (pwrf/wait-for-layout-update [id-a id-b] 100) + (.then #(t/is true "both out-of-order bridges observed their work")) + (.catch #(t/is false "a bridge missed work that started out of order")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest text-bridge-does-not-consume-preexisting-work + ;; Ignore matching work that started before the bridge. + (t/async done + (let [store (start-text-pipeline!) + id (uuid/next) + prior-task (wrf/start! :text-measure [id])] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]})) + (wrf/finish! prior-task) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "preexisting work released the new bridge")) + (.catch #(t/is true "the new bridge remained pending")) + (.then (fn [] + (let [current-task (wrf/start! :text-measure [id])] + (wrf/finish! current-task)) + (pwrf/wait-for-layout-update [id] 100))) + (.then #(t/is true "work started after the bridge drained it")) + (.catch #(t/is false "the causal measurement did not drain the bridge")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events + (t/async done + (let [store (start-text-pipeline!) + id-a (uuid/next) + id-b (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]})) + (wrf/cancel-shapes! [id-a]) + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]})) + (-> (pwrf/wait-for-layout-update [id-b] 20) + (.then #(t/is false "the later bridge was not opened")) + (.catch #(t/is true "the later bridge stayed pending")) + (.then (fn [] + (let [task-b (wrf/start! :text-measure [id-b])] + (wrf/finish! task-b)) + (pwrf/wait-for-layout-update [id-b] 100))) + (.then #(t/is true "the later bridge drained after its own work")) + (.catch #(t/is false "the cancelled bridge blocked the pipeline")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest finalizing-a-page-cancels-its-text-bridges + (t/async done + (let [store (start-text-pipeline!) + id (uuid/next)] + (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]})) + (ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page)) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then #(t/is true "page teardown drained the unmeasured text bridge")) + (.catch #(t/is false "page teardown left text work pending")) + (.then (fn [] + (stop-text-pipeline! store) + (done))))))) + +(t/deftest failed-wasm-font-storage-falls-back-and-drains + (t/async done + (let [id (uuid/next) + font-key {:font-id "gfont-does-not-load" + :weight 400 + :style 0 + :emoji? false} + stream (rx/subject) + events (atom [])] + (->> (#'dwtxt/await-font-faces stream #{font-key} [id]) + (rx/subs! #(swap! events conj %))) + (#'wasm.fonts/report-font-storage-failed! font-key) + (-> (pwrf/wait-for-layout-update [id] 100) + (.then (fn [] + (t/is (= 1 (count @events)) + "font failure dispatches one fallback resize") + (t/is (wasm.fonts/font-ready? font-key) + "the resize gate accepts the failed face's fallback") + (done))) + (.catch (fn [_] + (t/is false "font failure leaked pending work") + (done))))))) + +(t/deftest failed-dom-font-load-falls-back-and-drains + (t/async done + (let [id (uuid/next) + font-id "gfont-layout-failure-test"] + (swap! fonts/fontsdb assoc font-id + {:id font-id + :backend :google + :family "Layout Failure Test" + :variants [{:id "regular"}]}) + (swap! fonts/loaded disj font-id) + (swap! fonts/loading dissoc font-id) + (mock/with-mocks + {globals/browser? (constantly true) + http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))} + (fn [done'] + (wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id)) + (-> (pwrf/wait-for-layout-update [id] 500) + (.then (fn [] + (t/is (not (contains? @fonts/loading font-id)) + "a failed load must not remain cached as loading"))) + (.catch #(t/is false "failed DOM font load leaked pending work")) + (.then (fn [] + (swap! fonts/fontsdb dissoc font-id) + (swap! fonts/loaded disj font-id) + (swap! fonts/loading dissoc font-id) + (done'))))) + done)))) + +(t/deftest failed-google-font-css-does-not-abort-shared-consumers + (t/async done + (let [font-id "gfont-optional-css-test" + values (atom []) + cleanup #(swap! fonts/fontsdb dissoc font-id)] + (swap! fonts/fontsdb assoc font-id + {:id font-id + :backend :google + :family "Optional CSS Test" + :variants [{:id "regular"}]}) + (mock/with-mocks + {http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))} + (fn [done'] + (->> (fonts/fetch-font-css {:font-id font-id}) + (rx/subs! + #(swap! values conj %) + (fn [_] + (cleanup) + (t/is false "an optional font CSS failure escaped the shared helper") + (done')) + (fn [] + (cleanup) + (t/is (empty? @values) + "a failed optional font contributes no CSS") + (done'))))) + done)))) + +(t/deftest deduplicated-wasm-font-failure-settles-every-face + (t/async done + (let [font-url "https://example.test/shared-font.ttf" + regular {:font-id "gfont-shared-regular" + :weight 400 + :style 0 + :emoji? false} + bold {:font-id "gfont-shared-bold" + :weight 700 + :style 0 + :emoji? false}] + (mock/with-mocks + {http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))} + (fn [done'] + (let [request (#'wasm.fonts/fetch-font regular font-url false false) + duplicate (#'wasm.fonts/fetch-font bold font-url false false)] + (t/is (some? request) "the first face owns the shared fetch") + (t/is (nil? duplicate) "the second face reuses the shared fetch") + (->> ((:callback request)) + (rx/subs! + (fn [_]) + (fn [_] + (t/is false "the shared fetch failure escaped its fallback") + (done')) + (fn [] + (t/is (wasm.fonts/font-ready? regular) + "the first face settled through fallback") + (t/is (wasm.fonts/font-ready? bold) + "the deduplicated face settled through fallback") + (done')))))) + done)))) + +(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map + (t/async done + (let [font-data {:font-id "gfont-missing-url" + :weight 400 + :style 0 + :emoji? false}] + (t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false)) + "a missing URL starts no request") + (t/is (not (contains? @wasm.fonts/fetching nil)) + "missing URLs are not deduplicated under nil") + (js/setTimeout + (fn [] + (t/is (wasm.fonts/font-ready? font-data) + "the missing face settled through fallback") + (done)) + 0)))) + +(t/deftest wasm-font-resize-waits-for-every-face + (t/async done + (let [id (uuid/next) + regular-key {:font-id "gfont-mixed" + :weight 400 + :style 0 + :emoji? false} + bold-key {:font-id "gfont-mixed" + :weight 700 + :style 0 + :emoji? false} + stream (rx/subject) + events (atom [])] + (->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id]) + (rx/subs! #(swap! events conj %))) + (rx/push! wasm.fonts/font-stored-stream regular-key) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the first face released the font task")) + (.catch #(t/is true "the second face remained pending")) + (.then (fn [] + (rx/push! wasm.fonts/font-storage-failed-stream bold-key) + (pwrf/wait-for-layout-update [id] 100))) + (.then (fn [] + (t/is (= 1 (count @events)) + "all faces settling dispatches exactly one resize") + (done))) + (.catch (fn [_] + (t/is false "the complete face set did not drain") + (done))))))) + +(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit + (t/async done + (let [store (ptk/store {:state {} :on-error #(js/console.error %)}) + id (uuid/next)] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (ptk/emit! store (dwwt/resize-wasm-text-all [id])) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the buffered resize was not marked pending")) + (.catch #(t/is true "the resize stayed pending while the buffer was open")) + (.then (fn [] + (ptk/emit! store (dwsh/update-shapes-buffer-stop)) + (pwrf/wait-for-layout-update [id] 500))) + (.then #(t/is true "buffer stop released the fallback resize")) + (.catch #(t/is false "buffer stop without a commit leaked pending work")) + (.then (fn [] (done))))))) + +(t/deftest buffered-wasm-resize-releases-on-workspace-finalize + (t/async done + (let [store (ptk/store {:state {} :on-error #(js/console.error %)}) + id (uuid/next)] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (ptk/emit! store (dwwt/resize-wasm-text-all [id])) + (-> (pwrf/wait-for-layout-update [id] 20) + (.then #(t/is false "the buffered resize was not marked pending")) + (.catch #(t/is true "the resize stayed pending while the buffer was open")) + (.then (fn [] + (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace)) + (pwrf/wait-for-layout-update [id] 500))) + (.then #(t/is true "workspace finalization released the buffered resize")) + (.catch #(t/is false "workspace finalization leaked pending work")) + (.then (fn [] (done))))))) + (t/deftest layout-update-is-pending-until-the-buffer-flushes ;; A shape id is marked on arrival and drained when the update is processed. (t/async done (let [store (start-pipeline!)] (ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]})) - (-> (wrf/wait-for-layout-update nil 20) + (-> (pwrf/wait-for-layout-update nil 20) (.then #(t/is false "resolved while the update was still buffered")) (.catch #(t/is true "stayed pending until the flush")) - (.then #(wrf/wait-for-layout-update nil 5000)) + (.then #(pwrf/wait-for-layout-update nil 5000)) (.then #(t/is true "resolved once the update was processed")) (.catch #(t/is false "the pipeline never drained its mark")) (.then (fn [] diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index fbbf852c82..207b295a11 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -164,169 +164,151 @@ :team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"}) (t/deftest expired-organization-sso-navigates-to-identity-provider - (t/testing "the browser is sent to the identity provider instead of an error page" - (let [events (atom [])] - (with-redefs [rp/cmd! - (mock/stub - (fn [_command _params] - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - - rt/get-current-href - (constantly workspace-href) - - st/emit! - (mock/stub (fn [& emitted] (swap! events into emitted)))] - - (errors/on-error (sso-required-error)) - - (t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))) + (t/async done + (t/testing "the browser is sent to the identity provider instead of an error page" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::rt/nav-raw] (mapv ptk/type @events))) + (done')) + done))))) (t/deftest expired-organization-sso-comes-back-to-the-current-location - (t/testing "the SSO check asks the provider to return the user where they were" - (let [rpc-calls (atom [])] - (with-redefs [rp/cmd! - (mock/stub - (fn [command params] - (swap! rpc-calls conj {:command command :params params}) - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - - rt/get-current-href - (constantly workspace-href) - - st/emit! mock/noop] - - (errors/on-error (sso-required-error)) - - (t/is (= [{:command :check-nitrate-sso - :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" - :organization-id organization-id - :url workspace-href}}] - @rpc-calls)))))) + (t/async done + (t/testing "the SSO check asks the provider to return the user where they were" + (let [rpc-calls (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [command params] + (swap! rpc-calls conj {:command command :params params}) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [{:command :check-nitrate-sso + :params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9" + :organization-id organization-id + :url workspace-href}}] + @rpc-calls)) + (done')) + done))))) (t/deftest already-satisfied-organization-sso-retries-the-location - (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" - (let [events (atom [])] - (with-redefs [rp/cmd! - (mock/stub - (fn [_command _params] - (rx/of {:authorized true :reason :sso-satisfied}))) - - rt/get-current-href - (constantly workspace-href) - - st/emit! - (mock/stub (fn [& emitted] (swap! events into emitted)))] - - (errors/on-error (sso-required-error)) - - (t/is (= [::rt/reload] (mapv ptk/type @events))))))) + (t/async done + (t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :sso-satisfied}))) + rt/get-current-href (constantly workspace-href) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::rt/reload] (mapv ptk/type @events))) + (done')) + done))))) (t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog - (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" - (let [assigned* (atom nil)] - (with-redefs [rp/cmd! - (mock/stub - (fn [_command _params] - (rx/of {:authorized false :redirect-uri nil}))) - - rt/get-current-href - (constantly workspace-href) - - rt/assign-exception - (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))] - - (errors/on-error (sso-required-error)) - - (t/is (= :sso-error (:type @assigned*))) - (t/is (= organization-id (:organization-id @assigned*))) - (t/is (true? (:is-workspace @assigned*))))))) + (t/async done + (t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error" + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized false :redirect-uri nil}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= :sso-error (:type @assigned*))) + (t/is (= organization-id (:organization-id @assigned*))) + (t/is (true? (:is-workspace @assigned*))) + (done')) + done))))) (t/deftest organization-sso-without-team-access-reports-a-permission-failure - (t/testing "a user who cannot reach the team keeps getting the authentication error" - (let [assigned* (atom nil)] - (with-redefs [rp/cmd! - (mock/stub - (fn [_command _params] - (rx/of {:authorized true :reason :no-team-access}))) - - rt/get-current-href - (constantly workspace-href) - - rt/assign-exception - (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))] - - (errors/on-error (sso-required-error)) - - (t/is (= :authentication (:type @assigned*))) - (t/is (= :nitrate-sso-required (:code @assigned*))))))) + (t/async done + (t/testing "a user who cannot reach the team keeps getting the authentication error" + (let [assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub + (fn [_command _params] + (rx/of {:authorized true :reason :no-team-access}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= :authentication (:type @assigned*))) + (t/is (= :nitrate-sso-required (:code @assigned*))) + (done')) + done))))) (t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization - (t/testing "reloading on an answer we don't understand would spin on the same rejection" - (let [events (atom [])] - (with-redefs [rp/cmd! - (mock/stub (fn [_command _params] (rx/of {:authorized true}))) - - rt/get-current-href - (constantly workspace-href) - - rt/assign-exception - (fn [error] (ptk/data-event ::assigned error)) - - ;; async-emit! is variadic-only, so the replacement must be - ;; variadic too for the compiled static dispatch to find it - st/async-emit! - (fn [& emitted] (swap! events into emitted))] - - (errors/on-error (sso-required-error)) - - (t/is (= [::assigned] (mapv ptk/type @events))))))) + (t/async done + (t/testing "reloading on an answer we don't understand would spin on the same rejection" + (let [events (atom [])] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true}))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] (ptk/data-event ::assigned error)) + st/async-emit! (fn [& emitted] (swap! events into emitted))} + (fn [done'] + (errors/on-error (sso-required-error)) + (t/is (= [::assigned] (mapv ptk/type @events))) + (done')) + done))))) (t/deftest organization-sso-error-without-context-is-reported-as-it-arrives - (t/testing "with no organization and no team there is nothing to check" - (let [rpc-calls (atom 0) - assigned* (atom nil)] - (with-redefs [rp/cmd! - (mock/stub (fn [_command _params] - (swap! rpc-calls inc) - (rx/empty))) - - rt/get-current-href - (constantly workspace-href) - - rt/assign-exception - (fn [error] - (reset! assigned* error) - (ptk/data-event ::assigned error))] - - (errors/on-error {:type :authentication - :code :nitrate-sso-required}) - - (t/is (zero? @rpc-calls)) - (t/is (= :nitrate-sso-required (:code @assigned*))))))) + (t/async done + (t/testing "with no organization and no team there is nothing to check" + (let [rpc-calls (atom 0) + assigned* (atom nil)] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + rt/assign-exception (fn [error] + (reset! assigned* error) + (ptk/data-event ::assigned error))} + (fn [done'] + (errors/on-error {:type :authentication + :code :nitrate-sso-required}) + (t/is (zero? @rpc-calls)) + (t/is (= :nitrate-sso-required (:code @assigned*))) + (done')) + done))))) (t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections - (t/testing "the one-in-flight guard is released even when no answer arrives" - (let [rpc-calls (atom 0)] - (with-redefs [rp/cmd! - (mock/stub (fn [_command _params] - (swap! rpc-calls inc) - (rx/empty))) - - rt/get-current-href - (constantly workspace-href) - - st/emit! mock/noop] - - (errors/on-error (sso-required-error)) - (errors/on-error (sso-required-error)) - - (t/is (= 2 @rpc-calls)))))) + (t/async done + (t/testing "the one-in-flight guard is released even when no answer arrives" + (let [rpc-calls (atom 0)] + (mock/with-mocks + {rp/cmd! (mock/stub (fn [_command _params] + (swap! rpc-calls inc) + (rx/empty))) + rt/get-current-href (constantly workspace-href) + st/emit! mock/noop} + (fn [done'] + (errors/on-error (sso-required-error)) + (errors/on-error (sso-required-error)) + (t/is (= 2 @rpc-calls)) + (done')) + done))))) ;; A failing check must stay a failing check: the generic handling turns it ;; into a toast, whereas swallowing it would show a permission error for diff --git a/frontend/test/frontend_tests/plugins/comments_test.cljs b/frontend/test/frontend_tests/plugins/comments_test.cljs index ee19153a73..519c8581fa 100644 --- a/frontend/test/frontend_tests/plugins/comments_test.cljs +++ b/frontend/test/frontend_tests/plugins/comments_test.cljs @@ -17,44 +17,54 @@ (def ^:private plugin-id "00000000-0000-0000-0000-000000000000") (t/deftest comment-thread-remove-allows-the-owner - (let [owner-id (random-uuid) - file-id (random-uuid) - page-id (random-uuid) - thread-id (random-uuid) - emitted (atom nil) - thread (comments/comment-thread-proxy - plugin-id - file-id - page-id - {:id thread-id :owner-id owner-id})] - (set! st/state (atom {:profile {:id owner-id}})) - (with-redefs [r/check-permission (constantly true) - dc/delete-comment-thread-on-workspace - (mock/stub (fn [params callback] - (callback) - [:delete-thread params])) - st/emit! (mock/stub (fn [event] (reset! emitted event)))] - (let [result (.remove thread)] - (t/is (instance? js/Promise result)) - (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + (t/async done + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (set! st/state (atom {:profile {:id owner-id}})) + (mock/with-mocks + {r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))} + (fn [done'] + (let [result (.remove thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)) + (done'))) + done)))) (t/deftest page-remove-comment-thread-emits-delete-event - (let [file-id (random-uuid) - page-id (random-uuid) - thread-id (random-uuid) - emitted (atom nil) - page (page/page-proxy plugin-id file-id page-id) - thread (comments/comment-thread-proxy - plugin-id - file-id - page-id - {:id thread-id :owner-id (random-uuid)})] - (with-redefs [r/check-permission (constantly true) - dc/delete-comment-thread-on-workspace - (mock/stub (fn [params callback] - (callback) - [:delete-thread params])) - st/emit! (mock/stub (fn [event] (reset! emitted event)))] - (let [result (.removeCommentThread page thread)] - (t/is (instance? js/Promise result)) - (t/is (= [:delete-thread {:id thread-id}] @emitted)))))) + (t/async done + (let [file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + emitted (atom nil) + page (page/page-proxy plugin-id file-id page-id) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id (random-uuid)})] + (mock/with-mocks + {r/check-permission (constantly true) + dc/delete-comment-thread-on-workspace + (mock/stub (fn [params callback] + (callback) + [:delete-thread params])) + st/emit! (mock/stub (fn [event] (reset! emitted event)))} + (fn [done'] + (let [result (.removeCommentThread page thread)] + (t/is (instance? js/Promise result)) + (t/is (= [:delete-thread {:id thread-id}] @emitted)) + (done'))) + done)))) diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index c417b43dab..e6c5d2d629 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -11,9 +11,11 @@ [app.common.uuid :as uuid] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.wasm-text :as dwwt] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.reflow :as pwrf] [app.plugins.shape :as shape] [app.util.object :as obj] [beicon.v2.core :as rx] @@ -445,6 +447,24 @@ (set! st/stream (ptk/input-stream test-store)) test-store)) +(t/deftest test-update-shapes-invokes-update-function-once + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg}) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js ctx (api/create-context zero-id) + ^js rect (.createRectangle ctx) + id (obj/get rect "$id") + calls (atom 0)] + (ptk/emit! store + (dwsh/update-shapes + [id] + (fn [shape] + (swap! calls inc) + (assoc shape :opacity 0.5)))) + (t/is (= 1 @calls) "the update function ran once for the committed shape") + (t/is (= 0.5 (.-opacity rect)) "the single computed result was committed"))) + (t/deftest test-wait-for-layout-update-no-pending ;; When nothing is pending the promise resolves immediately via the fast path ;; (the behavior-subject replays the empty map on subscribe). @@ -459,6 +479,209 @@ (t/is false (str "unexpected rejection: " err)) (done))))))) +(t/deftest test-create-text-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Measure me") + id (obj/get text "$id")] + (-> (.waitForLayoutUpdate ctx 20) + (.then #(t/is false "createText resolved before DOM measurement started")) + (.catch #(t/is true "createText stayed bridged to DOM measurement")) + (.then (fn [] + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate ctx 100))) + (.then #(t/is true "the bridge drained after measurement started")) + (.catch #(t/is false "the createText bridge did not drain")) + (.then (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-dom-position-data-stays-pending-until-commit + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Position me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id]) + position-data + [{:x 10 :y 20 :width 30 :height 12}]] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (ptk/emit! store (dwtxt/update-position-data id position-data)) + (-> (.waitForLayoutUpdate text 20) + (.then (constantly false)) + (.catch (constantly true))))) + (.then + (fn [timed-out?] + (t/is timed-out? + "position data stayed pending across its debounce") + (.waitForLayoutUpdate text 500))) + (.then + (fn [] + (let [bounds (.-textBounds text)] + (t/is (= 30 (obj/get bounds "width")) + "the wait exposed the committed text bounds")) + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "position-data wait did not settle: " cause)) + (done)))))))) + +(t/deftest test-buffered-text-update-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Before") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (ptk/emit! store (dwsh/update-shapes-buffer-start)) + (set! (.-characters text) "After") + (ptk/emit! store (dwsh/update-shapes-buffer-stop)) + (.waitForLayoutUpdate text 20))) + (.then #(t/is false "buffered update resolved before DOM measurement")) + (.catch #(t/is true "buffered update stayed bridged to DOM measurement")) + (.then + (fn [] + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then #(t/is true "the buffered update bridge drained")) + (.catch #(t/is false "the buffered update bridge did not drain")) + (.then + (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-wasm-grow-type-wait-observes-its-resize + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Resize after grow type")] + (-> (.waitForLayoutUpdate text 500) + (.then + (fn [] + (set! (.-growType text) "fixed") + (.waitForLayoutUpdate text 500))) + (.then + (fn [] + (t/is (= "fixed" (.-growType text)) + "the grow-type bridge drained after its WASM resize") + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "grow-type wait did not settle: " cause)) + (done)))))))) + +(t/deftest test-cloned-text-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Clone me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + (let [^js clone (.clone text) + clone-id (obj/get clone "$id")] + (-> (.waitForLayoutUpdate clone 20) + (.then #(t/is false "clone resolved before DOM measurement")) + (.catch #(t/is true "clone stayed bridged to DOM measurement")) + (.then + (fn [] + (let [task (wrf/start! :text-measure [clone-id])] + (wrf/finish! task)) + (.waitForLayoutUpdate clone 100))))))) + (.then #(t/is true "the cloned text bridge drained")) + (.catch #(t/is false "the cloned text bridge did not drain")) + (.then + (fn [] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done)))))))) + +(t/deftest test-fixed-text-resize-bridges-dom-measurement + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1) + {:renderer :svg})] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (ptk/emit! store (dwtxt/initialize-text-reflow)) + (let [^js ctx (api/create-context zero-id) + ^js text (.createText ctx "Resize me") + id (obj/get text "$id") + task (wrf/start! :text-measure [id])] + (wrf/finish! task) + (-> (.waitForLayoutUpdate text 100) + (.then + (fn [] + ;; Finish the grow-type update before resizing. + (set! (.-growType text) "fixed") + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (.resize text 240 80) + ;; Turn only this short wait into a boolean. + (-> (.waitForLayoutUpdate text 20) + (.then (fn [] false)) + (.catch (fn [_] true))))) + (.then + (fn [timed-out?] + (t/is timed-out? + "fixed text resize stayed bridged to DOM measurement") + (let [task (wrf/start! :text-measure [id])] + (wrf/finish! task)) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (t/is true "the resize bridge drained after measurement") + ;; Match the DOM renderer's 0.001 geometry tolerance. + (.resize text 240.0005 80) + (.waitForLayoutUpdate text 100))) + (.then + (fn [] + (t/is true "a sub-tolerance resize opened no DOM bridge") + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (done))) + (.catch + (fn [cause] + (ptk/emit! store (dwtxt/finalize-text-reflow)) + (t/is false (str "unexpected resize bridge rejection: " cause)) + (done)))))))) + (t/deftest test-wait-for-layout-update-pending ;; While a shape is pending the context promise stays unresolved; it resolves ;; once that shape is marked done. @@ -584,6 +807,54 @@ 20)) 20)))))) +(t/deftest test-wait-for-layout-update-ancestor + ;; A shape wait also covers layout on its parents. + (t/async done + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))] + (set! st/state store) + (set! st/stream (ptk/input-stream store)) + (let [^js ctx (api/create-context zero-id) + ^js board (.createBoard ctx) + ^js rect (.createRectangle ctx)] + (.appendChild board rect) + (let [board-id (obj/get board "$id") + task (wrf/start! :layout [board-id]) + resolved (atom false)] + (-> (.waitForLayoutUpdate rect) + (.then (fn [] (reset! resolved true))) + (.catch (fn [err] + (t/is false (str "unexpected rejection: " err))))) + (js/setTimeout + (fn [] + (t/is (false? @resolved) "child wait must block on a pending ancestor") + (wrf/finish! task) + (js/setTimeout + (fn [] + (t/is (true? @resolved) "resolves once the ancestor drains") + (done)) + 20)) + 20)))))) + +(t/deftest test-shape-wait-observes-file-sync + (t/async done + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + _ (set! st/state store) + _ (set! st/stream (ptk/input-stream store)) + ^js ctx (api/create-context zero-id) + ^js shape (.createRectangle ctx) + task (wrf/start! :sync-file [(:id file)])] + (-> (.waitForLayoutUpdate shape 20) + (.then #(t/is false "shape wait ignored its pending file sync")) + (.catch + (fn [] + (t/is true "shape wait remained pending for its file sync") + (wrf/finish! task) + (.waitForLayoutUpdate shape 100))) + (.then #(t/is true "shape wait drained after the file sync")) + (.catch #(t/is false "shape wait did not drain its file sync")) + (.then (fn [] (done))))))) + (t/deftest test-wait-for-layout-update-invalid-timeout ;; A non-numeric or non-positive timeout is an invalid argument. The method ;; always hands back a promise and rejects it, whatever the plugin's @@ -603,6 +874,7 @@ (rejected? (.waitForLayoutUpdate ctx -5)) (rejected? (.waitForLayoutUpdate ctx js/NaN)) (rejected? (.waitForLayoutUpdate ctx js/Infinity)) + (rejected? (.waitForLayoutUpdate ctx 2147483648)) (rejected? (.waitForLayoutUpdate shape "soon"))]) (.then (fn [results] (t/is (every? true? (array-seq results)) @@ -661,7 +933,7 @@ resolved (atom false)] (ptk/emit! store (dwsh/update-shapes-buffer-start)) (ptk/emit! store (dwwt/resize-wasm-text-all [id])) - (-> (wrf/wait-for-layout-update [id] nil) + (-> (pwrf/wait-for-layout-update [id] nil) (.then (fn [] (reset! resolved true))) (.catch (fn [err] (t/is false (str "unexpected rejection: " err))))) diff --git a/frontend/test/frontend_tests/ui/routes_test.cljs b/frontend/test/frontend_tests/ui/routes_test.cljs index 3ebb7edbbe..ad52f2fb05 100644 --- a/frontend/test/frontend_tests/ui/routes_test.cljs +++ b/frontend/test/frontend_tests/ui/routes_test.cljs @@ -23,63 +23,75 @@ :query-params {:team-id (str team-id)}}) (t/deftest sso-check-is-cached-for-five-minutes - (let [team-id (uuid/next) - match (workspace-match team-id) - now (atom (ct/inst "2026-08-11T10:00:00Z")) - rpc-calls (atom 0) - events (atom [])] - (with-redefs [cf/flags (conj cf/flags :admin-console) - ct/now (mock/stub (fn [] @now)) - rp/cmd! (mock/stub - (fn [command params] - (t/is (= :check-nitrate-sso command)) - (t/is (= team-id (:team-id params))) - (swap! rpc-calls inc) - (rx/of {:authorized true}))) - st/emit! (mock/stub - (fn [& emitted] - (swap! events into emitted)))] - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - (reset! now (ct/plus @now #js {:minutes 4 :seconds 59})) - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - - (t/is (= 1 @rpc-calls)) - (t/is (= 2 (count @events)))))) + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0) + events (atom [])] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [command params] + (t/is (= :check-nitrate-sso command)) + (t/is (= team-id (:team-id params))) + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 4 :seconds 59})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 1 @rpc-calls)) + (t/is (= 2 (count @events))) + (done')) + done)))) (t/deftest sso-check-is-refreshed-after-five-minutes - (let [team-id (uuid/next) - match (workspace-match team-id) - now (atom (ct/inst "2026-08-11T10:00:00Z")) - rpc-calls (atom 0)] - (with-redefs [cf/flags (conj cf/flags :admin-console) - ct/now (mock/stub (fn [] @now)) - rp/cmd! (mock/stub - (fn [_ _] - (swap! rpc-calls inc) - (rx/of {:authorized true}))) - st/emit! mock/noop] - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - (reset! now (ct/plus @now #js {:minutes 5})) - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - - (t/is (= 2 @rpc-calls))))) + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + now (atom (ct/inst "2026-08-11T10:00:00Z")) + rpc-calls (atom 0)] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + ct/now (mock/stub (fn [] @now)) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized true}))) + st/emit! mock/noop} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (reset! now (ct/plus @now #js {:minutes 5})) + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 2 @rpc-calls)) + (done')) + done)))) (t/deftest sso-redirect-result-is-not-cached - (let [team-id (uuid/next) - match (workspace-match team-id) - rpc-calls (atom 0) - events (atom [])] - (with-redefs [cf/flags (conj cf/flags :admin-console) - rp/cmd! (mock/stub - (fn [_ _] - (swap! rpc-calls inc) - (rx/of {:authorized false - :redirect-uri "https://idp.example.com/authorize"}))) - st/emit! (mock/stub - (fn [& emitted] - (swap! events into emitted)))] - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") - - (t/is (= 2 @rpc-calls)) - (t/is (= 2 (count @events)))))) + (t/async done + (let [team-id (uuid/next) + match (workspace-match team-id) + rpc-calls (atom 0) + events (atom [])] + (mock/with-mocks + {cf/flags (conj cf/flags :admin-console) + rp/cmd! (mock/stub + (fn [_ _] + (swap! rpc-calls inc) + (rx/of {:authorized false + :redirect-uri "https://idp.example.com/authorize"}))) + st/emit! (mock/stub + (fn [& emitted] + (swap! events into emitted)))} + (fn [done'] + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace") + (t/is (= 2 @rpc-calls)) + (t/is (= 2 (count @events))) + (done')) + done)))) diff --git a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts index 79c0fb330d..c2b67a4d5b 100644 --- a/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts +++ b/plugins/apps/plugin-api-test-suite/src/tests/wait-layout.test.ts @@ -1,6 +1,13 @@ import { expect, expectReject } from '../framework/expect'; import { describe, test } from '../framework/registry'; -import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types'; +import type { + Board, + Font, + Group, + Penpot, + Shape, + Text, +} from '@penpot/plugin-types'; import type { TestContext } from '../framework/types'; // waitForLayoutUpdate (context-level and per-shape). @@ -43,8 +50,17 @@ function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } { return a.x <= b.x ? { left: a, right: b } : { left: b, right: a }; } -/** Font ids already handed out by `unloadedFont`. */ -const claimedFonts = new Set(); +/** Tracks fonts used by this test run. */ +const claimedFontsByRun = new WeakMap>(); + +function claimedFonts(ctx: TestContext): Set { + let claimed = claimedFontsByRun.get(ctx.penpot); + if (!claimed) { + claimed = new Set(); + claimedFontsByRun.set(ctx.penpot, claimed); + } + return claimed; +} /** * Picks an unclaimed font differing from the text's current one, so assigning @@ -53,11 +69,12 @@ const claimedFonts = new Set(); */ function unloadedFont(ctx: TestContext, t: Text): Font { const all = ctx.penpot.fonts.all; + const claimed = claimedFonts(ctx); for (let i = all.length - 1; i >= 0; i--) { const f = all[i]; if (f.fontId === t.fontId || f.variants.length === 0) continue; - if (claimedFonts.has(f.fontId)) continue; - claimedFonts.add(f.fontId); + if (claimed.has(f.fontId)) continue; + claimed.add(f.fontId); return f; } throw new Error('no alternative font available'); @@ -351,6 +368,89 @@ describe('WaitForLayoutUpdate', () => { }); }); + describe('Components', () => { + test('wait covers propagation from a component main to its copy', async (ctx) => { + const source = ctx.penpot.createRectangle(); + ctx.board.appendChild(source); + const component = ctx.penpot.library.local.createComponent([source]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const mainChild = main.children[0]; + const copyChild = copy.children[0]; + mainChild.opacity = 0.37; + + await ctx.penpot.waitForLayoutUpdate(); + expect(copyChild.opacity).toBeCloseTo(0.37); + }); + + test('shape wait covers propagation from a component main to its copy', async (ctx) => { + const source = ctx.penpot.createRectangle(); + ctx.board.appendChild(source); + const component = ctx.penpot.library.local.createComponent([source]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const mainChild = main.children[0]; + const copyChild = copy.children[0]; + mainChild.opacity = 0.63; + + await copyChild.waitForLayoutUpdate(); + expect(copyChild.opacity).toBeCloseTo(0.63); + }); + + test('wait covers layout triggered by component propagation', async (ctx) => { + const host = flexBoard(ctx); + const flex = host.addFlexLayout(); + flex.dir = 'row'; + flex.columnGap = 10; + const first = ctx.penpot.createRectangle(); + first.resize(50, 50); + flex.appendChild(first); + const second = ctx.penpot.createRectangle(); + second.resize(50, 50); + flex.appendChild(second); + + const component = ctx.penpot.library.local.createComponent([host]); + const main = component.mainInstance() as Board; + const copy = component.instance() as Board; + ctx.board.appendChild(copy); + await ctx.penpot.waitForLayoutUpdate(); + + const { left: mainLeft } = byX([main.children[0], main.children[1]]); + mainLeft.resize(120, 50); + + await ctx.penpot.waitForLayoutUpdate(); + const { left: copyLeft, right: copyRight } = byX([ + copy.children[0], + copy.children[1], + ]); + expect(copyLeft.width).toBeCloseTo(120, 0); + expect(copyRight.x - copyLeft.x).toBeCloseTo(130, 0); + }); + }); + + describe('Library assets', () => { + test('wait covers propagation from a local color to a referenced shape', async (ctx) => { + const color = ctx.penpot.library.local.createColor(); + color.color = '#112233'; + color.opacity = 1; + const rect = ctx.penpot.createRectangle(); + rect.fills = [color.asFill()]; + ctx.board.appendChild(rect); + await ctx.penpot.waitForLayoutUpdate(); + + color.color = '#aabbcc'; + + await ctx.penpot.waitForLayoutUpdate(); + expect(rect.fills[0]?.fillColor).toBe('#aabbcc'); + }); + }); + // A font applied to a group reaches its text descendants, so the work is // pending on the children and never on the group itself. Skipped under a // mocked backend for the same reason as the Text group: no fonts are served, diff --git a/plugins/apps/plugin-api-test-suite/src/ui.css b/plugins/apps/plugin-api-test-suite/src/ui.css index 104ac64c90..6e9913f1e4 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.css +++ b/plugins/apps/plugin-api-test-suite/src/ui.css @@ -60,15 +60,41 @@ body { background-color: var(--background-secondary); } -.group-summary { +.group-header { display: flex; align-items: center; gap: var(--spacing-8, 8px); padding: var(--spacing-8, 8px); +} + +/* Makes the full header row toggle the group. */ +.group-toggle { + display: flex; + flex: 1; + align-items: center; + gap: var(--spacing-8, 8px); + min-width: 0; + margin: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; cursor: pointer; user-select: none; } +.group-chevron { + flex: 0 0 auto; + color: var(--foreground-secondary); + transition: transform 0.15s ease; +} + +.group-toggle[aria-expanded='true'] .group-chevron { + transform: rotate(90deg); +} + .group-name { color: var(--foreground-primary); } @@ -144,6 +170,11 @@ body { padding: 0; } +/* Keeps hidden test lists out of the layout. */ +.test-list[hidden] { + display: none; +} + .test-row { display: grid; grid-template-columns: 1fr auto auto; diff --git a/plugins/apps/plugin-api-test-suite/src/ui.ts b/plugins/apps/plugin-api-test-suite/src/ui.ts index 4aacfd1a8e..fe5fc8a7cf 100644 --- a/plugins/apps/plugin-api-test-suite/src/ui.ts +++ b/plugins/apps/plugin-api-test-suite/src/ui.ts @@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement { return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false); } +/** Shows whether a group is expanded. */ +function chevronIcon(): SVGSVGElement { + const icon = svgIcon(['M6 3.5 10.5 8 6 12.5'], false); + icon.classList.add('group-chevron'); + return icon; +} + function render() { root.replaceChildren( renderHeader(), @@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement { return row; } -function renderGroupSummary( +/** Builds a group header with separate select, toggle, and run controls. */ +function renderGroupHeader( name: string, groupTestList: TestMeta[], + panelId: string, + expanded: boolean, ): HTMLElement { const statuses = groupTestList.map( (t) => results.get(t.id)?.status ?? 'pending', @@ -291,12 +301,12 @@ function renderGroupSummary( const groupCheckbox = el('input', { type: 'checkbox', className: 'checkbox-input', + title: `Select every test in "${name}"`, + ariaLabel: `Select every test in "${name}"`, checked: selectedCount === total && total > 0, disabled: running, }); groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total; - // Keep the checkbox from toggling the
    when clicked. - groupCheckbox.addEventListener('click', (e) => e.stopPropagation()); groupCheckbox.addEventListener('change', () => { if (groupCheckbox.checked) ids.forEach((id) => selected.add(id)); else ids.forEach((id) => selected.delete(id)); @@ -305,17 +315,14 @@ function renderGroupSummary( const runButton = el('button', { className: 'icon-button run-group', + type: 'button', title: `Run "${name}"`, ariaLabel: `Run "${name}"`, disabled: running, }); runButton.dataset.appearance = 'secondary'; runButton.append(playIcon()); - runButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - run(ids); - }); + runButton.addEventListener('click', () => run(ids)); const counts = el('span', { className: 'group-counts' }, [ el('span', { className: 'count-pass', textContent: `${passed}` }), @@ -327,14 +334,26 @@ function renderGroupSummary( }), ]); - return el('summary', { className: 'group-summary' }, [ - groupCheckbox, + const toggle = el('button', { className: 'group-toggle', type: 'button' }, [ + chevronIcon(), el('span', { className: `status-dot dot-${aggregate}`, title: statusLabel(aggregate), }), el('span', { className: 'group-name', textContent: name }), counts, + ]); + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.setAttribute('aria-controls', panelId); + toggle.addEventListener('click', () => { + if (expanded) expandedGroups.delete(name); + else expandedGroups.add(name); + render(); + }); + + return el('div', { className: 'group-header' }, [ + groupCheckbox, + toggle, runButton, ]); } @@ -342,25 +361,24 @@ function renderGroupSummary( function renderList(): HTMLElement { const container = el('div', { className: 'groups' }); - for (const group of groupTests()) { - const details = el('details', { className: 'group' }); + groupTests().forEach((group, index) => { // Groups are collapsed by default; remember the ones the user expands. - details.open = expandedGroups.has(group.name); - details.addEventListener('toggle', () => { - if (details.open) expandedGroups.add(group.name); - else expandedGroups.delete(group.name); - }); + const expanded = expandedGroups.has(group.name); + const panelId = `group-panel-${index}`; - details.append(renderGroupSummary(group.name, group.tests)); - - const list = el('ul', { className: 'test-list' }); + const list = el('ul', { className: 'test-list', id: panelId }); + list.hidden = !expanded; for (const test of group.tests) { list.append(renderRow(test)); } - details.append(list); - container.append(details); - } + container.append( + el('div', { className: 'group' }, [ + renderGroupHeader(group.name, group.tests, panelId, expanded), + list, + ]), + ); + }); return container; } diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 483cfecca8..abb4e981c1 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1353,12 +1353,13 @@ export interface Context { /** * This method returns a promise that will be resolved when all the - * pending layout updates have finished. If no layout work is pending - * the promise resolves immediately. + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the layout settles, the promise is rejected. Defaults to * 30000; the promise never waits indefinitely. - * @return The promise to be resolved when the layout is updated + * @return The promise to be resolved when the layout is updated. It is + * rejected with an Error, both on timeout and on an invalid timeout value. */ waitForLayoutUpdate(timeout?: number): Promise; } @@ -4109,13 +4110,14 @@ export interface ShapeBase extends PluginData { remove(): void; /** - * This method returns a promise that will be resolved when the pending - * layout updates for this shape and its children have finished. If no layout - * work is pending for them the promise resolves immediately. + * This method returns a promise that will be resolved when all the + * pending layout updates have finished and the components have synchronized. + * If no layout work is pending the promise resolves immediately. * @param timeout Maximum time to wait, in milliseconds. If the timeout * elapses before the shape's layout settles, the promise is rejected. * Defaults to 30000; the promise never waits indefinitely. - * @return The promise to be resolved when the shape's layout is updated + * @return The promise to be resolved when the shape's layout is updated. It + * is rejected with an Error, both on timeout and on an invalid timeout value. */ waitForLayoutUpdate(timeout?: number): Promise; } From 7061ecae0a1ae38488adfdecf32e7d19e6b497ea Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 18 Aug 2026 17:50:15 +0200 Subject: [PATCH 142/298] :bug: Fix gitch of placeholder when switching between teams on dashboard (#10922) * :bug: Fix use single point for retrieving state and propagate it * :bug: Fix use loading message instead of placeholder when loading files --- frontend/src/app/main/ui/dashboard.cljs | 84 +++++++++++-------- .../src/app/main/ui/dashboard/deleted.cljs | 12 +-- frontend/src/app/main/ui/dashboard/files.cljs | 20 ++--- .../src/app/main/ui/dashboard/projects.cljs | 16 ++-- 4 files changed, 64 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 622873ac4c..17c6cfe69a 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -27,6 +27,7 @@ [app.main.ui.dashboard.files :refer [files-section*]] [app.main.ui.dashboard.fonts :refer [fonts-page* font-providers-page*]] [app.main.ui.dashboard.import] + [app.main.ui.dashboard.layout-toggle :as lt] [app.main.ui.dashboard.libraries :refer [libraries-page*]] [app.main.ui.dashboard.projects :refer [projects-section*]] [app.main.ui.dashboard.search :refer [search-page*]] @@ -51,7 +52,7 @@ (mf/defc dashboard-content* {::mf/private true} - [{:keys [team projects project section search-term profile default-project]}] + [{:keys [team projects project section search-term profile default-project layout on-layout-change]}] (let [container (mf/use-ref) content-width (mf/use-state 0) @@ -100,18 +101,18 @@ :dashboard-recent (when (seq projects) [:* - [:> projects-section* - {:team team - :projects projects - :profile profile}] + [:> projects-section* {:team team + :projects projects + :profile profile + :layout layout + :on-layout-change on-layout-change}] (when ^boolean show-templates? - [:> templates-section* - {:profile profile - :project-id project-id - :team-id team-id - :default-project-id default-project-id - :content-width @content-width}])]) + [:> templates-section* {:profile profile + :project-id project-id + :team-id team-id + :default-project-id default-project-id + :content-width @content-width}])]) :dashboard-fonts [:> fonts-page* {:team team}] @@ -123,14 +124,15 @@ (when project [:* [:> files-section* {:team team - :project project}] + :project project + :layout layout + :on-layout-change on-layout-change}] (when ^boolean show-templates? - [:> templates-section* - {:profile profile - :team-id team-id - :project-id project-id - :default-project-id default-project-id - :content-width @content-width}])]) + [:> templates-section* {:profile profile + :team-id team-id + :project-id project-id + :default-project-id default-project-id + :content-width @content-width}])]) :dashboard-search [:> search-page* {:team team @@ -155,7 +157,9 @@ :dashboard-deleted [:> deleted-section* {:team team :projects projects - :profile profile}] + :profile profile + :layout layout + :on-layout-change on-layout-change}] nil)])) @@ -313,7 +317,15 @@ (mf/with-memo [projects] (->> projects (filter :is-default) - (first)))] + (first))) + + layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) + layout (deref layout*) + + on-layout-change + (mf/use-fn + (fn [value] + (reset! layout* (keyword value))))] (hooks/use-shortcuts ::dashboard sc/shortcuts-dashboard :dashboard) @@ -347,22 +359,22 @@ ;; team is already set so don't put the team into mf/deps. [:main {:class (stl/css :dashboard) :key (dm/str (:id team))} - [:> sidebar* - {:team team - :projects projects - :project project - :default-project default-project - :profile profile - :section section - :search-term search-term}] - [:> dashboard-content* - {:projects projects - :profile profile - :project project - :default-project default-project - :section section - :search-term search-term - :team team}]]])) + [:> sidebar* {:team team + :projects projects + :project project + :default-project default-project + :profile profile + :section section + :search-term search-term}] + [:> dashboard-content* {:projects projects + :profile profile + :project project + :default-project default-project + :section section + :search-term search-term + :team team + :layout layout + :on-layout-change on-layout-change}]]])) (mf/defc dashboard-page* {::mf/lazy-load true} diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index 9a46b697ba..a66714209d 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -219,16 +219,8 @@ (tr "labels.deleted")]]])) (mf/defc deleted-section* - [{:keys [team projects]}] - (let [layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - - deleted-map + [{:keys [team projects layout on-layout-change]}] + (let [deleted-map (mf/deref ref:deleted-files) projects diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index ed3781faba..cf2b4fce35 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -137,7 +137,7 @@ :on-import on-import}])]])) (mf/defc files-section* - [{:keys [project team]}] + [{:keys [project team layout on-layout-change]}] (let [files (mf/deref refs/files) project-id (get project :id) @@ -147,7 +147,6 @@ (sort-by :modified-at) (reverse))) - can-edit? (-> team :permissions :can-edit) project-id (:id project) is-draft-proyect (:is-default project) @@ -155,19 +154,16 @@ [rowref limit] (hooks/use-dynamic-grid-item-width) file-count (or (count files) 0) + + loading? (and (some? (:count project)) + (not= (:count project) file-count)) + empty-state-viewer (and (not can-edit?) - (= 0 file-count)) + (= 0 file-count) + (not loading?)) selected-files (mf/deref refs/selected-files) - layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - on-file-created (mf/use-fn (fn [file-data] @@ -216,7 +212,7 @@ (tr "dashboard.empty-placeholder-drafts-subtitle") (tr "dashboard.empty-placeholder-files-subtitle"))}] [:> grid* {:project project - :files files + :files (if loading? nil files) :selected-files selected-files :can-edit can-edit? :origin :files diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 542395caa7..441bf5cc5c 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -109,6 +109,10 @@ team-id (get team :id) file-count (or (:count project) 0) + + loading? (and (pos? (:count project)) + (empty? files)) + is-draft? (:is-default project) empty? (and (not can-edit) (= 0 file-count)) @@ -292,7 +296,7 @@ [:> line-grid* {:project project :team team - :files files + :files (if loading? nil files) :create-fn create-file :can-edit can-edit :limit limit @@ -313,7 +317,7 @@ (l/derived :recent-files st/state)) (mf/defc projects-section* - [{:keys [team projects profile]}] + [{:keys [team projects profile layout on-layout-change]}] (let [team-id (get team :id) @@ -334,14 +338,6 @@ show-deleted? (:can-edit permisions) - layout* (hooks/use-persisted-state lt/layout-key lt/default-layout) - layout (deref layout*) - - on-layout-change - (mf/use-fn - (fn [value] - (reset! layout* (keyword value)))) - projects (mf/with-memo [projects] (->> projects From df664fe96b5b0937bfc1a8de328f2c12d075e286 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 18:05:17 +0200 Subject: [PATCH 143/298] :paperclip: Add improvement for review command --- .opencode/commands/review.md | 107 +++++++-- .opencode/skills/plan-review/SKILL.md | 315 ++++++++++++++++++++++++++ 2 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 .opencode/skills/plan-review/SKILL.md diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md index 3e6f59cee5..23d7941ce7 100644 --- a/.opencode/commands/review.md +++ b/.opencode/commands/review.md @@ -1,27 +1,30 @@ -Act as a senior software engineer and perform a thorough code review. +Act as a senior software engineer and perform a thorough review. ## Instructions +1. **Determine what is being reviewed** from the provided context: + - **If it is a plan** (implementation plan, design document, task breakdown) → follow the **Plan Review** path below. + - **If it is code** (diff, PR, code change) → follow the **Code Review** path below. + +--- + +## Code Review Path + 1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format. -2. Determine the diff or code to review from the provided context. -3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. -4. Read the diff and the surrounding context for each changed file. -5. Review across all five axes: correctness, readability, architecture, security, performance. -6. Produce the review using this structure: - - **Summary**: One-paragraph overview of the change and its impact - - **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix) - - **Other Findings**: Medium/Low issues and suggestions - - **Testing Recommendations**: Missing test coverage or test quality issues - - **Positive Observations**: What was done well (brief, specific) - - **Verdict**: Approve / Request Changes / Needs Discussion -7. For each finding: +2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing the code. +3. Determine the diff or code to review from the provided context. +4. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. +5. Read the diff and the surrounding context for each changed file. +6. Review across all five axes: correctness, readability, architecture, security, performance. +7. Produce the review using the **Code Review Format** below. +8. For each finding: - State the severity (Critical / High / Medium / Low / Suggestion) - Identify the file and line - Describe failure circumstances - **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code - **For Medium/Low**: Describe the fix clearly; code snippet optional - If multiple approaches exist, briefly note trade-offs -8. **Perform a second review pass if the change is complex:** +9. **Perform a second review pass if the change is complex:** - **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed - **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings - Second pass checks: @@ -30,45 +33,76 @@ Act as a senior software engineer and perform a thorough code review. - Remove false positives: Discard findings that aren't real issues - Verify fixes: Are the proposed solutions actually correct and complete? +--- + +## Plan Review Path + +1. Load the **`plan-review`** skill — it defines the six axes, severity taxonomy, and output format. +2. Read the full plan from the provided context. +3. Review across all six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality (if the plan includes implementation details). +4. Produce the review using the **Plan Review Format** below. +5. For each finding: + - State the severity (Critical / Required / Nit / Optional / FYI) + - Identify the section or task it refers to + - Describe the gap or problem + - **For Critical/Required**: Propose a concrete fix or addition + - **For Nit/Optional**: Describe the improvement; concrete text optional +6. **Perform a second review pass if the plan is complex:** + - **Complex indicators**: Critical findings, >10 tasks, migrations or breaking changes, security-sensitive features + - **Skip for simple plans**: 1–2 tasks, no risks, no code proposals + - Second pass checks: + - Validate severity assignments + - Catch missed gaps: edge cases, missing dependencies, unaddressed risks + - Remove false positives + - Verify proposed remedies are actionable + +--- + ## Strong Rules 1. Do not invent problems. Every finding must be real and actionable. 2. Do not modify any code and do not create a commit — this command only reviews. 3. Be specific and constructive. "This could be better" is not helpful — explain why and how. 4. Prioritize by impact. One structural issue outweighs ten nits. -5. If tests are missing for new functionality, flag it as High severity. +5. Missing tests are an issue, not a suggestion. If tests are missing or inadequate for new functionality, report it as a severity-tagged finding in the findings sections below — High severity (code) or Required (plan) — never as a recommendation. ## Context $ARGUMENTS -## Expected Format +## Expected Format — Code Review ``` ## Review Summary [1-2 sentences on what the change does and overall assessment] ## Critical/High Findings + ### [Severity] file.ts:123 **Issue**: [Description of the problem] -**Impact**: [What could go wrong] +**Impact**: [What could go wrong if this is not fixed] **Fix**: -```[language] + +````[language] // Current code [problematic code] // Fixed code [corrected code] -``` [Optional: note trade-offs if multiple approaches exist] +```` + +### [Severity] file.ts:456 +**Issue**: [Description of the problem] +**Impact**: [What could go wrong if this is not fixed] +**Fix**: [Clear description of the fix; code snippet if it clarifies] ## Other Findings -### [Severity] file.ts:456 -**Issue**: [Description] -**Fix**: [Clear description; code snippet optional] -## Testing Recommendations -[List specific test cases that should be added] +### [Severity] file.ts:789 +**Issue**: [Description] +**Impact**: [Minor consequence or risk] +**Fix**: [Clear description; code snippet optional] ## Positive Observations [2-3 specific things done well] @@ -77,3 +111,28 @@ $ARGUMENTS [Approve / Request Changes / Needs Discussion] [If Request Changes: list the must-fix items] ``` + +## Expected Format — Plan Review + +``` +## Review Summary +[1-2 sentences on the plan's goal and overall assessment] + +## Critical/Required Findings +### [Severity] [Section or Task N] +**Issue**: [Description of the gap or problem] +**Impact**: [What could go wrong during implementation] +**Proposed fix**: [Concrete addition or change to the plan] + +## Other Findings +### [Severity] [Section or Task N] +**Issue**: [Description] +**Proposed fix**: [Clear description; concrete text optional] + +## Strengths +[2-3 specific things done well in the plan] + +## Verdict +[Approve / Request Changes / Needs Discussion] +[If Request Changes: list the must-fix items] +``` diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review/SKILL.md new file mode 100644 index 0000000000..60b61c75e8 --- /dev/null +++ b/.opencode/skills/plan-review/SKILL.md @@ -0,0 +1,315 @@ +--- +name: plan-review +description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human. +--- + +# Plan Review + +## Overview + +Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality. + +**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it. + +## When to Use + +- After the planner skill produces a plan +- Before starting implementation on any non-trivial task +- When reviewing a plan written by another agent or a human +- When a plan feels too large, vague, or risky to start + +**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do. + +## The Six-Axis Review + +Every plan gets evaluated across these dimensions: + +### 1. Completeness + +Does the plan cover everything needed to implement successfully? + +- Is the **context** clear? (What problem, why now, what's the goal?) +- Are **affected modules** identified with paths? +- Are **architecture decisions** documented with rationale? +- Is there a **testing strategy**? +- Are **verification commands** explicit (not "run the tests")? +- Are **open questions** listed (not buried in someone's head)? +- Is there a **parallelization** assessment for multi-task plans? + +**Missing any of these is a gap, not a nit.** + +### 2. Task Quality + +Are the tasks well-defined and independently executable? + +- Does every task have **acceptance criteria**? (Testable, not vague) +- Does every task have **verification steps**? +- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split) +- Are **dependencies** between tasks explicitly stated? +- Are **files likely touched** listed? +- Is each task a **single, self-contained change**? (Not "implement the whole feature") +- Could a skilled implementer pick up any task and execute it without asking clarifying questions? + +### 3. Architecture & Sequencing + +Is the plan structured so implementation flows correctly? + +- Does implementation order follow the **dependency graph** (foundations first)? +- Are tasks **vertically sliced** (feature paths) rather than horizontally layered? +- Does each task leave the system in a **working state**? +- Are there **checkpoints** between major phases? +- Are **high-risk tasks early** (fail fast)? +- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans) + +### 4. Risk Coverage + +Are the hard parts acknowledged and mitigated? + +- Are **edge cases** identified? +- Are **breaking changes** or **migration concerns** noted? +- Are **security implications** considered? +- Are **performance implications** considered? +- Are **external dependencies** or integration risks flagged? +- Is there a plan for **rollback** if something goes wrong? +- Are **data integrity** risks addressed (what happens if a migration fails mid-way)? + +### 5. Actionability + +Can an implementer actually execute this? + +- Are **file paths** specific (not "update the relevant files")? +- Are **function/method names** mentioned where applicable? +- Are **verification commands** copy-pasteable (not "run the linter")? +- Are **test commands** project-specific (not generic)? +- Is the **code shape** described where the implementation isn't obvious? +- Are **conventions** referenced (naming, patterns, existing utilities to reuse)? +- Does the plan reference **existing code** the implementer should read first? + +### 6. Proposed Code Quality *(when the plan includes implementation details)* + +If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-and-quality` criteria: + +- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)? +- **Readability:** Are proposed names descriptive and consistent with project conventions? +- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)? +- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design? +- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design? + +**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis. + +## Structural Remedies + +When you flag a structural problem in a plan, propose the fix — not just the problem: + +- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable. +- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task. +- **Wrong sequencing:** Identify the dependency and propose the correct order. +- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks). +- **Vague verification:** Replace "run tests" with the actual project command. +- **Horizontal slicing:** Restructure into vertical feature paths. +- **Missing risk section:** Draft the risks you can identify from the plan content. + +Prefer the remedy that makes the plan immediately actionable over one that just flags the gap. + +## Plan Sizing + +Plans should be scoped to a single deliverable: + +``` +1–5 tasks → Good. A focused feature or bug fix. +6–10 tasks → Acceptable for a moderate feature. +11–15 tasks → Large. Consider splitting into phases. +15+ tasks → Too large. Split into multiple plans. +``` + +**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan. + +## Categorize Findings + +Label every comment with its severity so the author knows what's required vs optional: + +| Prefix | Meaning | Author Action | +|--------|---------|---------------| +| *(no prefix)* | Required change | Must address before implementation starts | +| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach | +| **Nit:** | Minor, optional | Author may ignore — wording, formatting | +| **Optional:** / **Consider:** | Suggestion | Worth considering but not required | +| **FYI** | Informational only | No action needed — context for future reference | + +**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review. + +## Review Process + +### Step 1: Understand the Goal + +Before evaluating structure, understand intent: + +``` +- What is this plan trying to accomplish? +- What problem does it solve? +- What does "done" look like? +``` + +### Step 2: Check Completeness First + +Scan for missing sections before diving into content: + +``` +- Context present? +- Affected modules listed? +- Architecture decisions documented? +- Risks acknowledged? +- Testing strategy defined? +- Verification commands explicit? +``` + +### Step 3: Review Task Quality + +Walk through each task: + +``` +For each task: +1. Can I tell exactly what to build? +2. Are acceptance criteria specific and testable? +3. Is the size reasonable (not XL)? +4. Are dependencies clear? +5. Would I know which files to touch? +``` + +### Step 4: Validate Sequencing + +Check the dependency graph: + +``` +- Are foundations built first? +- Does each task leave the system working? +- Are checkpoints placed correctly? +- Are high-risk items early? +- Is it vertically sliced? +``` + +### Step 5: Assess Actionability + +Put yourself in the implementer's shoes: + +``` +- Could I pick up task 1 and start coding without asking any questions? +- Are the verification commands copy-pasteable? +- Are file paths and function names specific? +- Is existing code referenced where I'd need to read it? +``` + +### Step 6: Verify the Verification Story + +Check that the plan can actually confirm it worked: + +``` +- What tests should pass after implementation? +- What build/compile commands are relevant? +- What manual checks are needed? +- How do we know the feature works end-to-end? +``` + +### Step 7: Evaluate Proposed Code Quality *(if applicable)* + +If the plan includes code snippets, types, or API designs: + +``` +- Load code-review-and-quality skill for criteria +- Check proposed signatures for edge cases +- Verify naming follows project conventions +- Confirm abstractions follow existing patterns +- Scan for security vectors in proposed APIs +- Check for performance issues in proposed data structures +``` + +## Review Checklist + +```markdown +## Review: [Plan title] + +### Completeness +- [ ] Context explains the problem and goal +- [ ] Affected modules are listed with paths +- [ ] Architecture decisions have rationale +- [ ] Testing strategy is defined +- [ ] Verification commands are explicit and project-specific +- [ ] Open questions are listed + +### Task Quality +- [ ] Every task has acceptance criteria +- [ ] Every task has verification steps +- [ ] Tasks are sized XS–M (L acceptable, XL must be split) +- [ ] Task dependencies are stated +- [ ] Files likely touched are listed + +### Architecture & Sequencing +- [ ] Order follows dependency graph (foundations first) +- [ ] Vertically sliced (not horizontal layers) +- [ ] Each task leaves system working +- [ ] Checkpoints exist between phases +- [ ] High-risk tasks are early + +### Risk Coverage +- [ ] Edge cases identified +- [ ] Breaking changes / migrations noted +- [ ] Security implications considered +- [ ] Performance implications considered +- [ ] Rollback strategy exists (if applicable) + +### Actionability +- [ ] File paths are specific +- [ ] Verification commands are copy-pasteable +- [ ] Existing code to read is referenced +- [ ] Conventions and patterns are noted + +### Proposed Code Quality *(if plan includes implementation details)* +- [ ] Proposed types/signatures handle edge cases +- [ ] Proposed names follow project conventions +- [ ] Proposed abstractions follow existing patterns +- [ ] No security vectors in proposed APIs +- [ ] No performance issues in proposed structures + +### Verdict +- [ ] **Approve** — Ready to implement +- [ ] **Request changes** — Gaps must be addressed +``` + +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. | +| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. | +| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. | +| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. | +| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. | +| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. | +| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. | +| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. | + +## Red Flags + +- No acceptance criteria on any task +- Tasks that say "implement the feature" without specifics +- No verification steps anywhere in the plan +- All tasks are XL-sized +- No checkpoints between phases +- Dependency order isn't considered (e.g., API handler before domain model) +- No testing strategy +- Verification commands are generic ("run tests") instead of project-specific +- Plan has 20+ tasks (scope too large for one plan) +- No risk section on a plan with migrations, breaking changes, or security implications +- Horizontal slicing (all domain, then all services, then all API) +- File paths are vague ("update the relevant files") +- Missing open questions section despite stated unknowns +- Proposed code ignores project conventions or existing patterns +- Proposed types use gratuitous `any`/`unknown`/optional without justification +- Proposed APIs don't validate input at boundaries + +## See Also + +- For producing plans, use the `planner` skill +- For reviewing implemented code, use `code-review-and-quality` — also the criteria source for axis 6 +- For security-specific concerns, see `security-and-hardening` +- For testing strategy guidance, see `testing` From b6c4cb48d76f3819e7844f44f05b61237b838f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 18 Aug 2026 18:13:47 +0200 Subject: [PATCH 144/298] :bug: Fix not being able to select right or center-aligned text in v3 (#11258) --- render-wasm/src/render/text_editor.rs | 6 +- render-wasm/src/shapes/text.rs | 112 +++++++++++++------------- 2 files changed, 61 insertions(+), 57 deletions(-) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 169f4cb735..8154981e99 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -183,13 +183,15 @@ fn calculate_cursor_rect( if !rects.is_empty() { let r = &rects[0].rect; (r.right(), r.top(), r.width(), r.height()) - } else { + } else if let Some(line) = laid_out_para.get_line_metrics().last() { ( - laid_out_para.longest_line(), + line.left as f32 + line.width as f32, 0.0, 1.0, laid_out_para.height(), ) + } else { + (0.0, 0.0, 1.0, laid_out_para.height()) } } else { let utf16_pos = para.char_offset_to_utf16(char_pos); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 47befc08f3..3bc29e96fd 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -706,17 +706,36 @@ impl TextContent { pub fn paragraph_builder_group_from_text( &self, use_shadow: Option, + ) -> Vec { + self.paragraph_builders(use_shadow, false, None) + } + + /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). + /// Used as a clip mask for inner stroke rendering. + pub fn paragraph_builder_group_opaque(&self) -> Vec { + self.paragraph_builders(None, true, None) + } + + fn paragraph_builders( + &self, + use_shadow: Option, + opaque: bool, + align_override: Option, ) -> Vec { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); for paragraph in self.paragraphs() { - let paragraph_style = paragraph.paragraph_to_style(); + let mut paragraph_style = paragraph.paragraph_to_style(); + if let Some(align) = align_override { + paragraph_style.set_text_align(align); + } let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { - let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); + let remove_alpha = + opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); let text_style = span.to_style( &self.bounds(), fallback_fonts, @@ -739,65 +758,40 @@ impl TextContent { paragraph_group } - /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). - /// Used as a clip mask for inner stroke rendering. - pub fn paragraph_builder_group_opaque(&self) -> Vec { - let fonts = get_font_collection(); - let fallback_fonts = get_fallback_fonts(); - let mut paragraph_group = Vec::new(); - - for paragraph in self.paragraphs() { - let paragraph_style = paragraph.paragraph_to_style(); - let mut builder = ParagraphBuilder::new(¶graph_style, fonts); - let mut has_text = false; - for span in paragraph.children() { - let text_style = span.to_style( - &self.bounds(), - fallback_fonts, - true, // always opaque - paragraph.line_height(), - ); - let text: String = span.apply_text_transform(); - if !text.is_empty() { - has_text = true; - } - builder.push_style(&text_style); - add_text_with_tabs(&mut builder, &text, span.font_size); - } - if !has_text { - builder.add_text(" "); - } - paragraph_group.push(vec![builder]); - } - - paragraph_group - } - /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { - let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. + let mut measure_builders = + self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left)); let normalized_line_height = - calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); + calculate_normalized_line_height(&mut measure_builders, f32::MAX); - let paragraphs = - build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); + let measure_paragraphs = + build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); - let (width, height) = - paragraphs - .iter() - .flatten() - .fold((0.0, 0.0), |(auto_width, auto_height), paragraph| { - ( - f32::max(paragraph.longest_line(), auto_width), - auto_height + paragraph.height(), - ) - }); + let width = measure_paragraphs + .iter() + .flatten() + .fold(0.0_f32, |auto_width, paragraph| { + f32::max(paragraph.longest_line(), auto_width) + }) + .ceil(); + + // Re-layout at that width with the real alignment. + let mut paragraph_builders = self.paragraph_builder_group_from_text(None); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let height = paragraphs + .iter() + .flatten() + .fold(0.0_f32, |auto_height, paragraph| { + auto_height + paragraph.height() + }); let size = TextContentSize::new_with_normalized_line_height( - width.ceil(), + width, height.ceil(), - width.ceil(), + width, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) @@ -891,14 +885,22 @@ impl TextContent { pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout.cached_extrect.set(None); + // Bump the content version so update_layout can't early-return: auto-width + // shapes always match their container and clearing the cache above doesn't + // flip needs_update(), so a late font resolution would otherwise be skipped. + self.content_version = self.content_version.wrapping_add(1); } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + // Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout. + let layout_matches_container = self.grow_type() == GrowType::AutoWidth + || self + .layout_width + .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); + if !self.layout.needs_update() && self.layout_version == self.content_version - && self - .layout_width - .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON) + && layout_matches_container { return self.size; } From 4339d8d244c193b1c75903b1803ca4d41c543e41 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 18:37:58 +0200 Subject: [PATCH 145/298] :paperclip: Update serena documentation about backend storage --- .serena/memories/backend/core.md | 4 +- .../http-storage-filedata-subtleties.md | 7 +- .serena/memories/backend/storage.md | 83 +++++++++++++++++++ .serena/memories/prod-infra/core.md | 4 +- 4 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 .serena/memories/backend/storage.md diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 708d30fc5d..7b085856d1 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -5,7 +5,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Focused memories - RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties` -- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties` +- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`. +- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`. - Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains` - Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`. @@ -107,4 +108,3 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. J * **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. * **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. * **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. - diff --git a/.serena/memories/backend/http-storage-filedata-subtleties.md b/.serena/memories/backend/http-storage-filedata-subtleties.md index e9c0962371..188ece7277 100644 --- a/.serena/memories/backend/http-storage-filedata-subtleties.md +++ b/.serena/memories/backend/http-storage-filedata-subtleties.md @@ -14,10 +14,7 @@ ## Storage and media -- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`. -- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes. -- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows. -- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code. +- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`. - SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing. - Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8. - Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error. @@ -28,4 +25,4 @@ - File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data. - `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob. - Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written. -- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. \ No newline at end of file +- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders. diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md new file mode 100644 index 0000000000..b0d81330f6 --- /dev/null +++ b/.serena/memories/backend/storage.md @@ -0,0 +1,83 @@ +# Backend Storage + +## Abstraction + +- `app.storage` stores binary objects. +- Each object has a `storage_object` database row. +- The row stores the UUID, size, backend, timestamps, and Transit metadata. +- The backend stores the binary content. +- Supported backends are `:fs` and `:s3`. +- FS uses one root directory and a UUID-derived path. +- S3 uses one configured bucket and an optional prefix. +- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory. +- FS and S3 use the same UUID-derived object path. The bucket does not change the path. +- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend. +- Deprecated asset-storage config keys remain supported for migration. +- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases. + +## Object Lifecycle + +- `put-object!` creates the database row before it writes backend content. +- Backend content is written only when the row is new. +- A failed backend write can leave an unreferenced database row. +- Callers often set `:touched-at` so garbage collection can remove such rows. +- `get-object` excludes rows with `deleted_at`. +- Existing object values can remain readable until physical deletion. +- `:expired-at` blocks reads after the expiration time. +- `del-object!` sets `deleted_at`. It does not remove backend content. +- `storage-gc-deleted` removes the database row and backend content after the deletion delay. +- `storage-gc-touched` finds references before it sets `deleted_at`. +- `objects-gc` removes deleted domain rows and touches their storage object IDs. +- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. + +## Deduplication + +- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata. +- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`. +- The lookup does not include file ID, profile ID, team ID, or organization ID. +- Objects can therefore share content across users and files within one bucket. +- Deleted objects are not reused. +- `tempfile` objects never use deduplication, even when the caller requests it. +- Use `sto/wrap-with-hash` when the caller already calculated the content hash. + +## Bucket Rules + +| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup | +| --- | --- | --- | --- | --- | +| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. | +| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. | +| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. | +| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. | +| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. | +| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. | +| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. | +| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. | +| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. | +| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. | + +- The valid bucket set lives in `app.storage/valid-buckets`. +- `file-media-object` is the default bucket for old rows without bucket metadata. +- Do not assign a new bucket without adding its access and cleanup behavior. +- The touched-object collector raises an internal error for an unknown bucket. +- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`. +- It does not support `file-data-fragment` or `file-change`. + +## Access Rules + +- `app.http.assets` decides direct object authentication from the bucket. +- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`. +- Other valid buckets require a session or access-token profile ID. +- File-media routes also require file read permission. +- Non-public direct responses set `content-disposition: attachment`. +- FS responses use `x-accel-redirect` for the configured asset path. +- S3 responses use a presigned URL and an HTTP redirect. + +## File Data + +- `file-data-backend` accepts `legacy-db`, `db`, or `storage`. +- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`. +- `db` stores encoded data in `file_data.data`. +- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table. +- The `file_data.metadata.storage-ref-id` value points to the storage object. +- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row. +- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata. diff --git a/.serena/memories/prod-infra/core.md b/.serena/memories/prod-infra/core.md index e86eb69a32..1ec5af0308 100644 --- a/.serena/memories/prod-infra/core.md +++ b/.serena/memories/prod-infra/core.md @@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose - **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends. - **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue::`. `PENPOT_REDIS_URI`. -- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`. +- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`. - **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task). - **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`. @@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact ## See also - Devenv composition and the ws0-only worker placement: `mem:devenv/core`. -- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`. +- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`. From ddd32670b356048cede9f30c713b15435b506102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 18 Aug 2026 12:40:40 +0200 Subject: [PATCH 146/298] :books: Remove architectural constraints related to MCP Server HA --- docs/technical-guide/configuration.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 618b945df6..1541f1d3b8 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -438,7 +438,7 @@ with this flag enabled, the Penpot configuration will disable as well the librar The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed. In this section, we mention the key factors to consider when replicating a Penpot installation: -The components that can be replicated are the `frontend`, the `backend`, and the `exporter`. +The components that can be replicated are the `frontend`, the `backend`, the `exporter` and the `mcp`. Replication management depends on the infrastructure, whether it's a load balancer or a Kubernetes deployment with HPA. In a high-availability (HA) scenario, managing the state outside of replicas is crucial. This affects the following components: @@ -447,12 +447,6 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. - -__Since version 2.15.0__ - -Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. -If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. - ## Backend This section enumerates the backend only configuration variables. From d826c7ac137d3d754f844bf676c2d782047529c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 18 Aug 2026 12:40:40 +0200 Subject: [PATCH 147/298] :books: Remove architectural constraints related to MCP Server HA --- docs/technical-guide/configuration.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 618b945df6..1541f1d3b8 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -438,7 +438,7 @@ with this flag enabled, the Penpot configuration will disable as well the librar The mechanisms for installing Penpot in HA depend largely on how each infrastructure is managed. In this section, we mention the key factors to consider when replicating a Penpot installation: -The components that can be replicated are the `frontend`, the `backend`, and the `exporter`. +The components that can be replicated are the `frontend`, the `backend`, the `exporter` and the `mcp`. Replication management depends on the infrastructure, whether it's a load balancer or a Kubernetes deployment with HPA. In a high-availability (HA) scenario, managing the state outside of replicas is crucial. This affects the following components: @@ -447,12 +447,6 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. - -__Since version 2.15.0__ - -Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. -If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. - ## Backend This section enumerates the backend only configuration variables. From ddc98bdd47157a9803efead01888f8a845e41544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 19 Aug 2026 07:58:44 +0200 Subject: [PATCH 148/298] :sparkles: Add sso events (#11265) --- backend/src/app/auth/oidc.clj | 85 ++++++++++++++++++- backend/src/app/loggers/audit.clj | 18 ++++ backend/src/app/rpc/commands/nitrate.clj | 17 +++- backend/test/backend_tests/auth_oidc_test.clj | 56 ++++++++++++ .../test/backend_tests/rpc_nitrate_test.clj | 44 +++++++++- .../backend_tests/tasks_telemetry_test.clj | 13 +++ 6 files changed, 226 insertions(+), 7 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index c636aeba24..6cb23fb9de 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -771,6 +771,82 @@ ;; ORG SSO HELPERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- organization-sso-oauth-failure-reason + [error] + (case (d/name error) + "access_denied" "access-denied" + ("temporarily_unavailable" "server_error") "provider-unavailable" + ("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration" + "provider-error")) + +(defn- organization-sso-exception-failure-reason + [cause] + (let [data (ex-data cause) + status (or (:response-status data) + (:response-status-code data) + (:http-status data)) + network-error? + (loop [current cause] + (cond + (nil? current) + false + + (or (instance? java.net.ConnectException current) + (instance? java.net.UnknownHostException current) + (instance? java.net.http.HttpTimeoutException current) + (instance? javax.net.ssl.SSLException current)) + true + + (identical? current (ex-cause current)) + false + + :else + (recur (ex-cause current))))] + (if (or network-error? + (and (number? status) (<= 500 status 599))) + "provider-unavailable" + (case (:code data) + :unable-to-fetch-access-token "token-exchange-failed" + :unable-to-retrieve-user-info "user-info-failed" + :incomplete-user-info "incomplete-user-info" + :invalid-sso-config "invalid-configuration" + :unable-to-fetch-sso-jwks "provider-unavailable" + :unable-to-auth "access-denied" + "unexpected-error")))) + +(defn- submit-organization-sso-auth-event + [cfg request profile-id organization-id name & {:keys [failure-reason]}] + (audit/submit cfg {:type "action" + :name name + :profile-id profile-id + :ip-addr (inet/parse-request request) + :props (d/without-nils + {:organization-id organization-id + :failure-reason failure-reason}) + :context (audit/prepare-context-from-request request)})) + +(defn submit-organization-sso-auth-started-event + [cfg request profile-id organization-id] + (submit-organization-sso-auth-event + cfg request profile-id organization-id "organization-sso-auth-started")) + +(defn submit-organization-sso-auth-failed-event + [cfg request profile-id organization-id cause] + (submit-organization-sso-auth-event + cfg request profile-id organization-id "organization-sso-auth-failed" + :failure-reason (organization-sso-exception-failure-reason cause))) + +(defn- submit-organization-sso-oauth-failed-event + [cfg request state-token error] + (try + (let [state (tokens/verify cfg {:token state-token :iss "oidc"})] + (when (:dest-url state) + (submit-organization-sso-auth-event + cfg request (some-> (session/get-session request) :profile-id) + (:organization-id state) "organization-sso-auth-failed" + :failure-reason (organization-sso-oauth-failure-reason error)))) + (catch Throwable _ nil))) + (defn- non-blank-uri [value] (when-not (str/blank? value) value)) @@ -910,6 +986,8 @@ (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] (session/update-session (::session/manager cfg) (assoc session :props props)))) + (submit-organization-sso-auth-event + cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded") (redirect-response dest-url)) (catch Throwable cause (let [{:keys [code]} (ex-data cause)] @@ -922,6 +1000,9 @@ (l/err :hint "unexpected error on organization sso callback" :organization-id (:organization-id state) :cause cause)))) + (submit-organization-sso-auth-failed-event + cfg request (some-> (session/get-session request) :profile-id) + (:organization-id state) cause) (let [organization-id (:organization-id state) organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))] (redirect-with-organization-sso-error @@ -932,7 +1013,9 @@ (defn- callback-handler [cfg {:keys [params] :as request}] (if-let [error (get params :error)] - (redirect-with-error "unable-to-auth" error) + (do + (submit-organization-sso-oauth-failed-event cfg request (:state params) error) + (redirect-with-error "unable-to-auth" error)) (try (let [code (get params :code) state (get params :state) diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index f68209255b..6ded7befaf 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -36,6 +36,16 @@ (def ^:private filter-auth-events #{"login-with-oidc" "login-with-password" "register-profile" "update-profile"}) +(def ^:private organization-sso-failure-reasons + #{"access-denied" + "provider-unavailable" + "invalid-configuration" + "provider-error" + "token-exchange-failed" + "user-info-failed" + "incomplete-user-info" + "unexpected-error"}) + (def ^:private safe-backend-context-keys #{:version :initiator @@ -297,6 +307,14 @@ (defn filter-telemetry-props [{:keys [source name props type] :as params}] (cond + (and (= source "backend") + (= name "organization-sso-auth-failed")) + (let [props' (into {} xf:filter-telemetry-props props) + props' (cond-> props' + (contains? organization-sso-failure-reasons (:failure-reason props)) + (assoc :failure-reason (:failure-reason props)))] + (assoc params :props props')) + (or (and (= source "frontend") (= type "identify")) (and (= source "backend") diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index c48834662e..a476ce0dbf 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -698,10 +698,19 @@ (if authorized {:authorized true :reason :sso-satisfied} (if (oidc/organization-sso-discovery-uri sso) - {:authorized false - :redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso - :dest-url url - :organization-id organization-id)} + (try + (let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri + cfg sso + :dest-url url + :organization-id organization-id) + organization-id (or organization-id (:organization-id sso))] + (oidc/submit-organization-sso-auth-started-event + cfg request profile-id organization-id) + {:authorized false :redirect-uri redirect-uri}) + (catch Throwable cause + (oidc/submit-organization-sso-auth-failed-event + cfg request profile-id (or organization-id (:organization-id sso)) cause) + (throw cause))) {:authorized false :redirect-uri nil})))) {:authorized true :reason :sso-satisfied})) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 62f04fd546..b99de502c4 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -385,6 +385,9 @@ (def ^:private test-profile-id #uuid "11111111-1111-1111-1111-111111111111") +(def ^:private test-organization-id + #uuid "22222222-2222-2222-2222-222222222222") + (def ^:private test-profile {:id test-profile-id :is-active true @@ -519,6 +522,59 @@ (t/is (= 302 (::yres/status result))) (t/is (.contains loc "error=unable-to-auth"))))))) +(t/deftest organization-sso-callback-success-emits-succeeded + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom [])] + (with-redefs [app.nitrate/call (constantly {:active true}) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (constantly {}) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request)] + (t/is (= "https://penpot.example.com/#/workspace" (redirect-location result))) + (t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events))) + (t/is (= test-organization-id (get-in (first @events) [:props :organization-id]))))))) + +(t/deftest organization-sso-callback-error-emits-failed + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (default-request cfg :state state) + events (atom [])] + (with-redefs [app.nitrate/call (fn [_cfg method _params] + (case method + :get-organization-sso {:active true} + :get-organization-summary {:name "Organization"})) + app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"}) + app.auth.oidc/get-info (fn [& _] + (ex/raise :type :internal + :code :unable-to-retrieve-user-info)) + app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (#'oidc/callback-handler cfg request) + (t/is (= ["organization-sso-auth-failed"] (mapv :name @events))) + (t/is (= {:organization-id test-organization-id + :failure-reason "user-info-failed"} + (:props (first @events))))))) + +(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace" + :organization-id test-organization-id}) + request (assoc-in (default-request cfg :state state) [:params :error] "access_denied") + events (atom [])] + (binding [cf/config {:public-uri "http://localhost:3449"}] + (with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))] + (let [result (#'oidc/callback-handler cfg request) + loc (redirect-location result)] + (t/is (.contains loc "error=unable-to-auth")) + (t/is (.contains loc "hint=access_denied")) + (t/is (= ["organization-sso-auth-failed"] (mapv :name @events))) + (t/is (= {:organization-id test-organization-id + :failure-reason "access-denied"} + (:props (first @events))))))))) + (t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check (t/testing "organization SSO provider must use SSRF protection" (let [captured-params (atom nil)] diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index d2ce0043bf..90b746e2a1 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -148,6 +148,8 @@ team (th/create-team* 1 {:profile-id (:id team-owner)}) organization-id (uuid/random) redirect-uri "https://idp.example.com/authorize" + redirect-options (atom nil) + started-event (atom nil) params (with-meta {::th/type :check-nitrate-sso ::rpc/profile-id (:id team-owner) @@ -161,12 +163,50 @@ organization-id (:id team-owner)) oidc/build-organization-sso-auth-redirect-uri - (constantly redirect-uri)] + (fn [_cfg _sso & options] + (reset! redirect-options (apply hash-map options)) + redirect-uri) + oidc/submit-organization-sso-auth-started-event + (fn [_cfg _request profile-id received-organization-id] + (reset! started-event {:profile-id profile-id + :organization-id received-organization-id}))] (let [out (th/command! params)] (t/is (th/success? out)) (t/is (= {:authorized false :redirect-uri redirect-uri} - (:result out)))))))) + (:result out))) + (t/is (= #{:dest-url :organization-id} (set (keys @redirect-options)))) + (t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options)))) + (t/is (nil? (:organization-id @redirect-options))) + (t/is (= {:profile-id (:id team-owner) + :organization-id organization-id} + @started-event))))))) + +(t/deftest check-nitrate-sso-reports-redirect-failure + (let [profile (th/create-profile* 1 {:is-active true}) + organization-id (uuid/random) + cause (ex-info "provider unavailable" {:response-status-code 503}) + reported (atom nil) + params (with-meta + {::th/type :check-nitrate-sso + ::rpc/profile-id (:id profile) + :organization-id organization-id + :url "https://penpot.example.com/#/workspace"} + {::http/request {}})] + (binding [cf/flags (conj cf/flags :admin-console)] + (with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id) + oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause)) + oidc/submit-organization-sso-auth-failed-event + (fn [_cfg _request profile-id received-organization-id received-cause] + (reset! reported {:profile-id profile-id + :organization-id received-organization-id + :cause received-cause}))] + (let [out (th/command! params)] + (t/is (not (th/success? out))) + (t/is (= {:profile-id (:id profile) + :organization-id organization-id + :cause cause} + @reported))))))) (t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner (let [team-owner (th/create-profile* 1 {:is-active true}) diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index 07b8f7c7f6..e3f57647df 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -710,6 +710,19 @@ (t/is (not (contains? (:props result) :route))) (t/is (not (contains? (:props result) :label))))) +(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason + (let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props) + organization-id (uuid/next) + result (ftp {:source "backend" + :name "organization-sso-auth-failed" + :type "action" + :props {:organization-id organization-id + :failure-reason "access-denied" + :unsafe-label "should-be-stripped"}})] + (t/is (= {:organization-id organization-id + :failure-reason "access-denied"} + (:props result))))) + (t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids ;; Frontend navigate events keep specific routing keys: :route, ;; :file-id, :team-id, :page-id. These ids are strings because From 8da13b5fa1819d092fbdf216cee597c974f83930 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Wed, 19 Aug 2026 11:31:52 +0200 Subject: [PATCH 149/298] :wrench: Add CI for temporary environment --- .github/workflows/build-tmp-tokens.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/build-tmp-tokens.yml diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml new file mode 100644 index 0000000000..3c21981664 --- /dev/null +++ b/.github/workflows/build-tmp-tokens.yml @@ -0,0 +1,20 @@ +name: _TMP TOKENS + +on: + workflow_dispatch: + schedule: + - cron: '46 5-20 * * 1-5' + +jobs: + build-bundle: + uses: ./.github/workflows/build-bundle.yml + secrets: inherit + with: + gh_ref: "hiru-tokens-in-libs" + + build-docker: + needs: build-bundle + uses: ./.github/workflows/build-docker.yml + secrets: inherit + with: + gh_ref: "hiru-tokens-in-libs" From 54aaebee1e028713b7a369e780499c16d2bfc8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 19 Aug 2026 11:50:28 +0200 Subject: [PATCH 150/298] :zap: Improve shape attrs parsing performance (#11259) * :zap: Memoize shape-attr->token-attrs and hoist per-type attrs in get-attrs* * :zap: Skip redundant token merges for token-less shapes in get-attrs* * :zap: Freeze group descendant attrs in design panel during transforms --- common/src/app/common/types/token.cljc | 21 ++++++-- .../sidebar/options/shapes/group.cljs | 30 ++++++++--- .../sidebar/options/shapes/multiple.cljs | 52 +++++++++++++------ 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 10cedd5c19..852d2529d5 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -423,11 +423,8 @@ :stroke-width :strokes token-attr)) -(defn shape-attr->token-attrs - "Returns the token-attr affected when a given attribute in a shape is changed. - The sub-attr is for attributes that may have multiple values, like strokes - (may be width or color) and layout padding & margin (may have 4 edges)." - ([shape-attr] (shape-attr->token-attrs shape-attr nil)) +(defn- shape-attr->token-attrs* + ([shape-attr] (shape-attr->token-attrs* shape-attr nil)) ([shape-attr changed-sub-attr] (cond (= :fills shape-attr) @@ -468,6 +465,20 @@ (number-keys shape-attr) #{shape-attr} (axis-keys shape-attr) #{shape-attr}))) +(def ^:private shape-attr->token-attrs-1 + (memoize shape-attr->token-attrs*)) + +(defn shape-attr->token-attrs + "Returns the token-attr affected when a given attribute in a shape is changed. + The sub-attr is for attributes that may have multiple values, like strokes + (may be width or color) and layout padding & margin (may have 4 edges)." + ([shape-attr] + (shape-attr->token-attrs-1 shape-attr)) + ([shape-attr changed-sub-attr] + (if (nil? changed-sub-attr) + (shape-attr->token-attrs-1 shape-attr) + (shape-attr->token-attrs* shape-attr changed-sub-attr)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS for token attributes by shape type ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs index d0ae918bfe..8d4149dc7b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs @@ -93,20 +93,38 @@ [constraint-ids constraint-values] (get-attrs shapes objects :constraint) - [fill-ids fill-values fill-tokens] - (get-attrs shapes objects :fill) - [shadow-ids] (get-attrs shapes objects :shadow) [blur-ids blur-values] (get-attrs shapes objects :blur) + transform + (mf/deref refs/current-transform) + + ;; A transform cannot change the descendants read here. + descendant-attrs-ref + (mf/use-ref nil) + + descendant-attrs + (let [cached (mf/ref-val descendant-attrs-ref)] + (if (and (some? transform) (some? cached)) + cached + (let [attrs {:fill (get-attrs shapes objects :fill) + :stroke (get-attrs shapes objects :stroke) + :text (get-attrs shapes objects :text) + :colors (vals objects)}] + (mf/set-ref-val! descendant-attrs-ref attrs) + attrs))) + + [fill-ids fill-values fill-tokens] + (get descendant-attrs :fill) + [stroke-ids stroke-values stroke-tokens] - (get-attrs shapes objects :stroke) + (get descendant-attrs :stroke) [text-ids text-values text-tokens] - (get-attrs shapes objects :text) + (get descendant-attrs :text) [layout-item-ids layout-item-values] (get-attrs shapes objects :layout-item)] @@ -164,7 +182,7 @@ [:> color-selection-menu* {:type type - :shapes (vals objects) + :shapes (get descendant-attrs :colors) :file-id file-id :libraries libraries}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index f20dc82caa..8a0fca8b6d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -268,6 +268,21 @@ applies (some of them ignore some attributes)" [shapes objects attr-group] (let [attrs (group->attrs attr-group) + + type->editable-attrs + (memoize (fn [type] + (if-let [editable? (get editable-attrs type)] + (filterv editable? attrs) + []))) + + type->nil-values + (memoize (fn [type] (into {} (map (fn [attr] [attr nil])) (type->editable-attrs type)))) + + type->token-attrs + (memoize (fn [type] + (into [] (comp (mapcat tt/shape-attr->token-attrs) (distinct)) + (type->editable-attrs type)))) + merge-attrs (fn [v1 v2] (cond @@ -289,24 +304,31 @@ (= existing new-val) acc :else (assoc acc t-attr :multiple)))) - merge-shape-attr - (fn [acc applied-tokens shape-attr] - "Merges all token attributes derived from a single shape attribute - into the accumulator map using `merge-attr`." - (let [token-attrs (tt/shape-attr->token-attrs shape-attr)] - (reduce #(merge-attr %1 applied-tokens %2) acc token-attrs))) + ;; Merging an empty `applied-tokens` into an accumulator that a previous + ;; empty merge already produced is a fixed point, so long runs of + ;; token-less shapes of the same type only pay for the first one. + stable-token-acc (volatile! nil) merge-token-values - (fn [acc shape-attrs applied-tokens] - "Merges token values across all shape attributes. - For each shape attribute, its corresponding token attributes are merged - into the accumulator." - (reduce #(merge-shape-attr %1 applied-tokens %2) acc shape-attrs)) + (fn [acc token-attrs applied-tokens] + "Merges token values across all token attributes derived from the shape's + editable attributes." + (let [no-tokens? (empty? applied-tokens) + stable (deref stable-token-acc)] + (if (and no-tokens? + (some? stable) + (identical? (nth stable 0) token-attrs) + (identical? (nth stable 1) acc)) + acc + (let [result (reduce #(merge-attr %1 applied-tokens %2) acc token-attrs)] + (when no-tokens? + (vreset! stable-token-acc [token-attrs result])) + result)))) extract-attrs (fn [[ids values token-acc] {:keys [id type applied-tokens] :as shape}] (let [read-mode (get-in type->read-mode [type attr-group]) - editable-attrs (filter (get editable-attrs (:type shape)) attrs)] + editable-attrs (type->editable-attrs type)] (case read-mode :ignore [ids values] @@ -315,14 +337,14 @@ (let [;; Get the editable attrs from the shape, ensuring that all attributes ;; are present, with value nil if they are not present in the shape. shape-values (merge - (into {} (map #(vector % nil)) editable-attrs) + (type->nil-values type) (cond (= attr-group :measure) (select-measure-keys shape) :else (select-keys shape editable-attrs))) shape-values (cond-> shape-values (= attr-group :layer) (update :hidden #(if (nil? %) false %))) - new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] + new-token-acc (merge-token-values token-acc (type->token-attrs type) applied-tokens)] [(conj ids id) (merge-attrs values shape-values) new-token-acc]) @@ -338,7 +360,7 @@ (merge-attrs shape-attrs) (merge-attrs content-attrs)) - new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] + new-token-acc (merge-token-values token-acc (type->token-attrs type) applied-tokens)] [(conj ids id) new-values new-token-acc]) From 4d90fe9126b1ee4ed6676ff9e81c114dcf6192c3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 12:20:37 +0200 Subject: [PATCH 151/298] :sparkles: Add advisories access helper to gh tool --- .serena/memories/scripts/gh.md | 25 +++++++ scripts/gh.py | 121 ++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md index ed6adfa8f1..58f73e997d 100644 --- a/.serena/memories/scripts/gh.md +++ b/.serena/memories/scripts/gh.md @@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI. - Finding issues with no milestone. - Fetching PR details by number or by milestone. - Comparing milestone issues against CHANGES.md to find missing entries. +- Listing or inspecting GitHub Security Advisories (GHSA). ## Prerequisites @@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all **Output**: JSON array to stdout; progress to stderr. +### `advisories` + +List or inspect GitHub Security Advisories for the repository. + +```bash +# List all advisories (summary view) +python3 scripts/gh.py advisories + +# Filter by severity +python3 scripts/gh.py advisories --severity critical + +# Filter by state +python3 scripts/gh.py advisories --state triage + +# Get full detail for a single advisory +python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 +``` + +**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url. + +**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps. + +**Output**: JSON to stdout; progress to stderr. + ## Key principles - All output is JSON — pipe into `jq` or other tools for further processing. diff --git a/scripts/gh.py b/scripts/gh.py index 7017389d52..c7e1f87aca 100755 --- a/scripts/gh.py +++ b/scripts/gh.py @@ -5,8 +5,9 @@ gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations. Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI. Subcommands: - issues List issues in a milestone (or unassigned with milestone=none) - prs Fetch details for one or more PRs (by number or milestone) + issues List issues in a milestone (or unassigned with milestone=none) + prs Fetch details for one or more PRs (by number or milestone) + advisories List or inspect GitHub security advisories Usage: python3 scripts/gh.py issues (default: state=closed) @@ -23,6 +24,9 @@ Usage: cat prs.txt | python3 scripts/gh.py prs --stdin python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged) python3 scripts/gh.py prs --milestone "2.16.0" --state all + python3 scripts/gh.py advisories (list all advisories) + python3 scripts/gh.py advisories --severity critical (filter by severity) + python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail) Prerequisites: - gh CLI authenticated (gh auth status) @@ -63,6 +67,16 @@ def run_gh_graphql(query: str, variables: dict) -> Any: return body["data"] +def run_gh_rest(path: str) -> Any: + """Run a REST API call via ``gh api``.""" + cmd = ["gh", "api", path] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"gh error: {result.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(result.stdout) + + # ───────────────────────────────────────────── # Shared: milestone lookup # ───────────────────────────────────────────── @@ -581,6 +595,93 @@ def cmd_prs(args: argparse.Namespace) -> None: print(json.dumps(all_results, indent=2)) +# ───────────────────────────────────────────── +# Subcommand: advisories +# ───────────────────────────────────────────── + + +def fetch_advisories() -> list[dict]: + """Fetch all security advisories for the repository via REST API.""" + return run_gh_rest(f"repos/{REPO}/security-advisories") + + +def fetch_advisory(ghsa_id: str) -> dict: + """Fetch a single security advisory by GHSA ID.""" + return run_gh_rest(f"repos/{REPO}/security-advisories/{ghsa_id}") + + +def format_advisory_summary(adv: dict) -> dict: + """Extract a summary view of an advisory.""" + return { + "ghsa_id": adv["ghsa_id"], + "cve_id": adv.get("cve_id"), + "severity": adv.get("severity"), + "cvss_score": (adv.get("cvss") or {}).get("score"), + "state": adv.get("state"), + "summary": adv.get("summary"), + "cwes": [c["cwe_id"] for c in adv.get("cwes", [])], + "published_at": adv.get("published_at"), + "closed_at": adv.get("closed_at"), + "url": adv.get("html_url"), + } + + +def format_advisory_detail(adv: dict) -> dict: + """Extract full detail view of an advisory.""" + summary = format_advisory_summary(adv) + summary["description"] = adv.get("description") + summary["vulnerabilities"] = [ + { + "package": v.get("package", {}).get("name"), + "vulnerable_version_range": v.get("vulnerable_version_range"), + "patched_versions": v.get("patched_versions"), + } + for v in adv.get("vulnerabilities", []) + ] + summary["credits"] = [ + {"login": c.get("user", {}).get("login"), "type": c.get("type")} + for c in adv.get("credits_detailed", []) + ] + summary["created_at"] = adv.get("created_at") + summary["updated_at"] = adv.get("updated_at") + summary["withdrawn_at"] = adv.get("withdrawn_at") + return summary + + +def cmd_advisories(args: argparse.Namespace) -> None: + """Handle the ``advisories`` subcommand.""" + + # ── Single advisory detail ────────────────────────────── + if args.ghsa_id: + ghsa_id = args.ghsa_id.upper() + if not ghsa_id.startswith("GHSA-"): + ghsa_id = f"GHSA-{ghsa_id}" + print(f"Fetching advisory {ghsa_id}...", file=sys.stderr) + adv = fetch_advisory(ghsa_id) + print(json.dumps(format_advisory_detail(adv), indent=2)) + return + + # ── List all advisories ───────────────────────────────── + print("Fetching security advisories...", file=sys.stderr) + advisories = fetch_advisories() + print(f"Fetched {len(advisories)} advisories", file=sys.stderr) + + results = [format_advisory_summary(adv) for adv in advisories] + + # Apply filters + if args.severity: + sev = args.severity.lower() + results = [r for r in results if (r.get("severity") or "").lower() == sev] + print(f"After severity filter ({sev}): {len(results)} advisories", file=sys.stderr) + + if args.state: + st = args.state.lower() + results = [r for r in results if (r.get("state") or "").lower() == st] + print(f"After state filter ({st}): {len(results)} advisories", file=sys.stderr) + + print(json.dumps(results, indent=2)) + + # ───────────────────────────────────────────── # CLI entrypoint # ───────────────────────────────────────────── @@ -645,6 +746,22 @@ def main() -> None: ) p_prs.set_defaults(func=cmd_prs) + # --- advisories --- + p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories") + p_adv.add_argument( + "ghsa_id", nargs="?", + help="GHSA ID to fetch (e.g. 'GHSA-xvj6-fh9w-gjw7'); omit to list all" + ) + p_adv.add_argument( + "--severity", choices=["critical", "high", "medium", "low"], + help="Filter by severity level" + ) + p_adv.add_argument( + "--state", choices=["triage", "draft", "published", "closed", "withdrawn"], + help="Filter by advisory state" + ) + p_adv.set_defaults(func=cmd_advisories) + args = parser.parse_args() args.func(args) From 5080a90f760df217d79dea7180f621d2b52b8ade Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 19 Aug 2026 12:33:33 +0200 Subject: [PATCH 152/298] :lipstick: Change nitrate activation code texts (#11237) --- .../nitrate_code_activation_modal.cljs | 21 ++++++++++++------- .../src/app/main/ui/nitrate/nitrate_form.cljs | 6 +++--- frontend/translations/en.po | 13 +++++++----- frontend/translations/es.po | 13 +++++++----- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 0fc1b86b03..876852198e 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -111,11 +111,16 @@ :value (tr "nitrate.code-activation.submit") :on-click on-accept}]] [:div {:class (stl/css :footer-text)} - (tr "nitrate.code-activation.footer-before") - [:a {:class (stl/css :link) - :on-click on-download-request-click} - (tr "nitrate.code-activation.footer-link")] - (tr "nitrate.code-activation.footer-after") " " - [:a {:class (stl/css :link) - :href "mailto:sales@nitrate.com"} - "sales@nitrate.com"]]]]])) + [:div {:class (stl/css :code-label)} (tr "nitrate.code-activation.footer-title")] + [:div + + [:a {:class (stl/css :link) + :on-click on-download-request-click} + (tr "nitrate.code-activation.footer-download")]] + [:div + (tr "nitrate.code-activation.footer-after") " " + [:a {:class (stl/css :link) + :href "mailto:sales@nitrate.com"} + "sales@nitrate.com"] + " " + (tr "nitrate.code-activation.footer-before")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 3ad31a1977..5b983ef888 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -102,9 +102,9 @@ (tr "nitrate.form.cancel-anytime")]]] [:p {:class (stl/css :modal-text-medium)} - (tr "nitrate.form.subscribe-with-code") " " [:a {:class (stl/css :link) - :on-click on-activate-click} - (tr "nitrate.form.enter-code")]] + [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.subscribe-with-code")]] [:p {:class (stl/css :modal-text-medium)} [:a {:class (stl/css :link) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f2b57e315f..3175d5a1c3 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4407,14 +4407,17 @@ msgid "nitrate.modal-success.title" msgstr "Welcome to Enterprise!" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104 -msgid "nitrate.code-activation.footer-before" -msgstr "Need a code? Download your " +msgid "nitrate.code-activation.footer-title" +msgstr "Need a code?" -msgid "nitrate.code-activation.footer-link" -msgstr "activation code request" +msgid "nitrate.code-activation.footer-download" +msgstr "Download request" msgid "nitrate.code-activation.footer-after" -msgstr " and contact us:" +msgstr "Send the file to" + +msgid "nitrate.code-activation.footer-before" +msgstr "and we will send you your code." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86 msgid "nitrate.code-activation.input-label" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index eacd139708..4044119275 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4278,14 +4278,17 @@ msgid "nitrate.modal-success.title" msgstr "¡Bienvenido a Enterprise!" #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104 -msgid "nitrate.code-activation.footer-before" -msgstr "¿Necesitas un código? Descarga tu " +msgid "nitrate.code-activation.footer-title" +msgstr "¿Necesitas un código?" -msgid "nitrate.code-activation.footer-link" -msgstr "solicitud de código de activación" +msgid "nitrate.code-activation.footer-download" +msgstr " Descargar solicitud" msgid "nitrate.code-activation.footer-after" -msgstr " y contáctanos:" +msgstr "Mánda el fichero a" + +msgid "nitrate.code-activation.footer-before" +msgstr "y te enviaremos tu código." #: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86 msgid "nitrate.code-activation.input-label" From 188669745896e07ca8dc1f7c1dc5b194053d4b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 19 Aug 2026 12:49:21 +0200 Subject: [PATCH 153/298] :bug: Add mock to fix WASM render regression tests (#11268) --- frontend/playwright/ui/render-wasm-specs/shapes.spec.js | 6 ++++++ frontend/playwright/ui/render-wasm-specs/texts.spec.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 74b83d1940..a85b70887e 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -7,6 +7,12 @@ test.beforeEach(async ({ page }) => { "enable-feature-render-wasm", "enable-render-wasm-dpr", ]); + // Opening a wasm workspace backfills :position-data for text shapes, persisting the file. + await WasmWorkspacePage.mockRPC( + page, + "update-file?id=*", + "text-editor/update-file.json", + ); }); test("Renders a file with basic shapes, boards and groups", async ({ diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index f660356d91..4122d7e307 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -7,6 +7,12 @@ test.beforeEach(async ({ page }) => { "enable-feature-render-wasm", "enable-render-wasm-dpr", ]); + // Opening a wasm workspace backfills :position-data for text shapes, persisting the file. + await WasmWorkspacePage.mockRPC( + page, + "update-file?id=*", + "text-editor/update-file.json", + ); }); async function mockGetEmojiFont(workspace) { From fda6d56139353be2e2290c96983de509b6f5c038 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 13:00:01 +0200 Subject: [PATCH 154/298] :books: Update AGENTS.md file --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e542f93eb2..d4d1e238e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,13 @@ Skipping this step is the #1 cause of incorrect or incomplete work. --- +## Auto-triggers + +- **Security advisory URL pasted** — When the user pastes a URL matching + `github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID + from the URL and run `python3 scripts/gh.py advisories ` to fetch + full advisory details before proceeding. + ## Writing Rules Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100. @@ -119,4 +126,5 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. - `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`. +- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`. From 60d87a6342f5355f4ec44c1a40af28356f1f3618 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 13:29:04 +0200 Subject: [PATCH 155/298] :bug: Fix linear gradients in SVG text exports (#11272) * :bug: Use gradient type instead of export type in SVG renderer data->gradient-def was comparing the render `type` parameter (:svg, :png, :pdf) against "linear" to decide between linearGradient and radialGradient elements. Since the export type is never "linear", the comparison always fell through to radialGradient, causing all linear gradients to be exported as radial in SVG output. Read the gradient type from the data map instead: (get-in data ["gradient" "type"]) Closes #5972 * :bug: Add SVG gradient export regression test Extract SVG gradient definition generation from the renderer so it can be tested directly. Add exporter test build wiring and cover both linear and radial gradient output. AI-assisted-by: gpt-5.6-luna * :sparkles: Standardize exporter testing workflow Align exporter scripts with the frontend testing pattern. Add a dedicated GitHub Actions workflow and document the canonical exporter commands in Serena memories. AI-assisted-by: gpt-5.6-luna * :sparkles: Add focused exporter test execution Mirror frontend test-runner behavior for focused namespaces and test vars. Support --focus, --log-level, and --help, and document the commands. AI-assisted-by: gpt-5.6-luna * :bug: Replace shell exec with execFile in exporter Replace child_process.exec with execFile to eliminate shell interpretation. Add hex color validation in exporter and frontend to reject malformed input before command construction. This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated OS command injection vulnerability where malicious fill-color values could execute arbitrary commands in the exporter container. Defense in depth: - Layer 1: execFile passes arguments directly without shell parsing - Layer 2: Exporter validates colors with strict hex regex - Layer 3: Frontend filters invalid colors before DOM emission All three independent reporters' attack vectors are addressed: - Quote breakout (lyhtheori) - Command substitution (B1gN0Se) - Path traversal (KimiSecurityTeam) AI-assisted-by: qwen3.7-plus * :bug: Use existing hex-color-string? and fix test path mismatch Address code review feedback: - Replace duplicated hex-color-rx and valid-hex-color? with existing hex-color-string? from app.common.types.color - Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned AI-assisted-by: qwen3.7-plus --------- Co-authored-by: Sumit Ridhal --- .github/workflows/tests-exporter.yml | 58 ++++++ .serena/memories/exporter/core.md | 5 +- .serena/memories/exporter/testing.md | 16 ++ exporter/package.json | 9 +- exporter/scripts/test | 7 + exporter/scripts/test-quiet.js | 29 +++ exporter/shadow-cljs.edn | 10 +- exporter/src/app/handlers/export_frames.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 38 ++-- exporter/src/app/renderer/svg_gradient.cljs | 32 ++++ exporter/src/app/util/shell.cljs | 16 +- .../exporter_tests/renderer_svg_test.cljs | 25 +++ exporter/test/exporter_tests/runner.cljs | 172 ++++++++++++++++++ exporter/test/exporter_tests/shell_test.cljs | 70 +++++++ .../src/app/main/ui/shapes/text/fo_text.cljs | 12 +- scripts/ci | 10 +- 17 files changed, 462 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/tests-exporter.yml create mode 100644 .serena/memories/exporter/testing.md create mode 100755 exporter/scripts/test create mode 100644 exporter/scripts/test-quiet.js create mode 100644 exporter/src/app/renderer/svg_gradient.cljs create mode 100644 exporter/test/exporter_tests/renderer_svg_test.cljs create mode 100644 exporter/test/exporter_tests/runner.cljs create mode 100644 exporter/test/exporter_tests/shell_test.cljs diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml new file mode 100644 index 0000000000..1ed37d95c8 --- /dev/null +++ b/.github/workflows/tests-exporter.yml @@ -0,0 +1,58 @@ +name: "CI: Exporter" + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'exporter/**' + - 'common/**' + + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + + paths: + - 'exporter/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-exporter: + if: ${{ !github.event.pull_request.draft }} + name: "Exporter Tests" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Lint + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run check-fmt:clj + pnpm run lint:clj + + - name: Tests + working-directory: ./exporter + run: | + ./scripts/test diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 3bcf784f49..9b7078045b 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,9 +5,10 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. -- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. +- Exporter test conventions and CI: `mem:exporter/testing`. ## HTTP and browser pool @@ -31,4 +32,4 @@ - WebP is produced by taking a PNG screenshot and converting it with ImageMagick. - SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths. - PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers. -- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. \ No newline at end of file +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md new file mode 100644 index 0000000000..189c1e852c --- /dev/null +++ b/.serena/memories/exporter/testing.md @@ -0,0 +1,16 @@ +# Exporter Testing + +- READ `mem:testing` first. +- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`. +- Register every test namespace in `exporter-tests.runner`. +- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests. +- From `exporter/`: `pnpm run test` builds and runs tests with full output. +- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output. +- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`. +- For iterative focused runs, build once and reuse the compiled bundle. +- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`. +- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`. +- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`). +- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs. +- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting. +- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting. diff --git a/exporter/package.json b/exporter/package.json index ae3eefb04c..680eafdab7 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -35,8 +35,11 @@ "build:app": "clojure -M:dev:shadow-cljs release main", "build:wasm": "../render-wasm/build export", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt": "cljfmt fix --parallel=true src/", - "check-fmt": "cljfmt check --parallel=true src/", - "lint": "clj-kondo --parallel --lint src/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "lint:clj": "clj-kondo --parallel --lint src/ test/", + "build:test": "clojure -M:dev:shadow-cljs compile test", + "test": "pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/exporter/scripts/test b/exporter/scripts/test new file mode 100755 index 0000000000..6402c5afd1 --- /dev/null +++ b/exporter/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -ex +corepack enable; +corepack install; +pnpm install; +pnpm run test; diff --git a/exporter/scripts/test-quiet.js b/exporter/scripts/test-quiet.js new file mode 100644 index 0000000000..b1be0dd682 --- /dev/null +++ b/exporter/scripts/test-quiet.js @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; + +const BUILD_STEPS = [ + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index ae963cf311..076e6a10f6 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -31,4 +31,12 @@ :pseudo-names true :pretty-print true :anon-fn-naming-policy :off - :source-map-detail-level :all}}}}} + :source-map-detail-level :all}}} + + :test + {:target :esm + :output-dir "target/tests" + :runtime :node + :js-options {:js-provider :import} + :modules + {:test {:init-fn exporter-tests.runner/-main}}}}} diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index cf263d8e00..658456059d 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -117,7 +117,7 @@ [file-id paths] (p/let [prefix (str/concat "penpot.pdfunite." file-id ".") path (sh/tempfile :prefix prefix :suffix ".pdf")] - (sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path)) + (apply sh/run-cmd! "pdfunite" (conj (vec paths) path)) path)) (defn- move-file diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index c2720eb025..e04c60076b 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -38,7 +38,7 @@ :webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")] ;; playwright only supports jpg and png, we need to convert it afterwards (bw/screenshot node {:omit-background? true :type :png :path png-path}) - (sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path)))) + (sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path)))) (on-object (assoc object :path path)))) (render [uri page] diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index c9fee2f764..0db4bc0cf8 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -10,9 +10,12 @@ ["xml-js" :as xml] [app.browser :as bw] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.types.color :as ctc] [app.common.uri :as u] [app.config :as cf] + [app.renderer.svg-gradient :as svg-gradient] [app.util.mime :as mime] [app.util.shell :as sh] [clojure.walk :as walk] @@ -125,19 +128,23 @@ (letfn [(convert-to-ppm [pngpath] (let [ppmpath (str/concat pngpath "origin.ppm")] (l/trace :fn :convert-to-ppm :path ppmpath) - (-> (sh/run-cmd! (str "convert " pngpath " " ppmpath)) + (-> (sh/run-cmd! "convert" pngpath ppmpath) (p/then (constantly ppmpath))))) (trace-color-mask [pbmpath] (l/trace :fn :trace-color-mask :pbmpath pbmpath) (let [svgpath (str/concat pbmpath ".svg")] - (-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) + (-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath) (p/then (constantly svgpath))))) (generate-color-layer [ppmpath color] + (when-not (ctc/hex-color-string? color) + (ex/raise :type :validation + :code :invalid-color + :hint (str "invalid hex color: " color))) (l/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")] - (-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath)) + (-> (sh/run-cmd! "ppmcolormask" color ppmpath) (p/then (fn [stdout] (-> (sh/write-file! pbmpath stdout) (p/then (constantly pbmpath))))) @@ -166,33 +173,11 @@ :else (update node "attributes" assoc "fill" color)))) - (get-stops [data] - (->> (get-in data ["gradient" "stops"]) - (mapv (fn [stop-data] - {"type" "element" - "name" "stop" - "attributes" {"offset" (get stop-data "offset") - "stop-color" (get stop-data "color") - "stop-opacity" (get stop-data "opacity")}})))) - - (data->gradient-def [id [color data]] - (let [id (str "gradient-" id "-" (subs color 1))] - (if (= type "linear") - {"type" "element" - "name" "linearGradient" - "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} - "elements" (get-stops data)} - - {"type" "element" - "name" "radialGradient" - "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} - "elements" (get-stops data)}))) - (get-gradients [id mapping] (->> mapping (filter (fn [[_color data]] (= (get data "type") "gradient"))) - (mapv (partial data->gradient-def id)))) + (mapv (partial svg-gradient/data->gradient-def id)))) (join-color-layers [{:keys [id x y width height mapping] :as node} layers] (l/trace :fn :join-color-layers :mapping mapping) @@ -369,4 +354,3 @@ (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) - diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs new file mode 100644 index 0000000000..2efaca2c0d --- /dev/null +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -0,0 +1,32 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.renderer.svg-gradient) + +(defn- get-stops + [data] + (->> (get-in data ["gradient" "stops"]) + (mapv (fn [stop-data] + {"type" "element" + "name" "stop" + "attributes" {"offset" (get stop-data "offset") + "stop-color" (get stop-data "color") + "stop-opacity" (get stop-data "opacity")}})))) + +(defn data->gradient-def + [id [color data]] + (let [id (str "gradient-" id "-" (subs color 1)) + gradient-type (get-in data ["gradient" "type"])] + (if (= gradient-type "linear") + {"type" "element" + "name" "linearGradient" + "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} + "elements" (get-stops data)} + + {"type" "element" + "name" "radialGradient" + "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} + "elements" (get-stops data)}))) diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 60dc1bd6b8..8331888b00 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -94,14 +94,14 @@ (.readFile fs/promises fpath)) (defn run-cmd! - [cmd] + [cmd & args] (p/create (fn [resolve reject] - (l/trace :fn :run-cmd :cmd cmd) - (proc/exec cmd #js {:encoding "buffer"} - (fn [error stdout _stderr] - ;; (l/trace :fn :run-cmd :stdout stdout) - (if error - (reject error) - (resolve stdout))))))) + (l/trace :fn :run-cmd :cmd cmd :args args) + (proc/execFile cmd (clj->js args) #js {:encoding "buffer"} + (fn [error stdout _stderr] + ;; (l/trace :fn :run-cmd :stdout stdout) + (if error + (reject error) + (resolve stdout))))))) diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs new file mode 100644 index 0000000000..d680b344ab --- /dev/null +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -0,0 +1,25 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.renderer-svg-test + (:require + [app.renderer.svg-gradient :as svg-gradient] + [cljs.test :refer [deftest is testing]])) + +(def gradient-stops + [{"color" "#000000" "offset" 0 "opacity" 1} + {"color" "#ffffff" "offset" 1 "opacity" 1}]) + +(deftest creates-the-correct-gradient-element + (doseq [[gradient-type element-name] + [["linear" "linearGradient"] + ["radial" "radialGradient"]]] + (testing gradient-type + (let [gradient-data {"type" "gradient" + "gradient" {"type" gradient-type + "stops" gradient-stops}} + result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] + (is (= element-name (get result "name"))))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs new file mode 100644 index 0000000000..a0aa4481e0 --- /dev/null +++ b/exporter/test/exporter_tests/runner.cljs @@ -0,0 +1,172 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.runner + (:require + [app.common.logging :as l] + [cljs.test :as t] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [exporter-tests.renderer-svg-test] + [exporter-tests.shell-test] + [goog.object :as gobj])) + +(enable-console-print!) + +(def test-namespaces + ['exporter-tests.renderer-svg-test + 'exporter-tests.shell-test]) + +(assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljs") + +(defmethod t/report [:cljs.test/default :begin-test-var] + [m] + (let [v (:var m)] + (println (str " ▸ " (:ns (meta v)) "/" (:name (meta v)))))) + +(defmethod t/report [:cljs.test/default :end-run-tests] + [result] + (.exit js/process (if (cljs.test/successful? result) 0 1))) + +(def ^:private log-levels + #{:trace :debug :info :warn :error}) + +(def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + +(defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + ;; `pnpm run test -- --focus ...` forwards the separator to the node + ;; process, so drop one leading `--` before handing args to tools.cli. + (cond-> args + (= "--" (first args)) rest))) + +(defn- usage + [summary] + (str "Usage: node target/tests/test.js [options]\n\n" + "Options:\n" + summary "\n\n" + "Build first with: pnpm run build:test\n\n" + "Focus examples:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n" + "Log level example:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn")) + +(defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + +(defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + +(defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + +(defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + +(defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + +(defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + +(defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + +(defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/set-env! env) + (t/run-block + (concat (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-current-env)))) + (fn [] + (t/report @summary) + (t/report (assoc @summary :type :end-run-tests)))])))) + +(defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + +(defn -main + [] + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (l/setup! {:app (or (:log-level options) :warn)}) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))))) diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs new file mode 100644 index 0000000000..e232a4ed67 --- /dev/null +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.shell-test + "Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter. + These tests prove that: + 1. execFile does NOT interpret shell metacharacters (safe execution) + 2. Malicious colors fail validation regex + 3. The injection does NOT execute commands (no RCE)" + (:require + ["node:child_process" :as proc] + ["node:fs" :as fs] + [cljs.test :as t :include-macros true])) + +(def ^:private hex-color-rx + #"^#(?:[0-9a-fA-F]{3}){1,2}$") + +(defn- valid-hex-color? + [color] + (and (string? color) + (some? (re-matches hex-color-rx color)))) + +(t/deftest execfile-does-not-interpret-shell-metacharacters + (t/testing "Proves execFile passes arguments literally (no shell interpretation)" + (t/async done + (let [cmd "echo" + args #js ["$(echo PWNED)"]] + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [error stdout _stderr] + (if error + (do + (t/is false (str "unexpected error: " (.-message error))) + (done)) + (let [output (.toString stdout "utf8")] + (t/is (= "$(echo PWNED)\n" output) + "execFile passes $(...) literally, no shell interpretation") + (done))))))))) + +(t/deftest malicious-color-fails-validation + (t/testing "Proves malicious colors are rejected by validation" + (let [malicious "#000000$(echo PWNED)" + valid-color "#000000" + short-valid "#abc"] + (t/is (not (valid-hex-color? malicious)) + "malicious color with $(...) fails validation") + (t/is (valid-hex-color? valid-color) + "valid 6-digit hex color passes validation") + (t/is (valid-hex-color? short-valid) + "valid 3-digit hex color passes validation")))) + +(t/deftest execfile-does-not-execute-injected-commands + (t/testing "Proves execFile does NOT execute injected commands (no RCE)" + (t/async done + (let [marker "/tmp/penpot-exporter-rce-test" + malicious (str "#000000$(touch " marker ")") + cmd "echo" + args #js [malicious]] + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [_error _stdout _stderr] + ;; Command completes (or fails), but no injection occurs + (t/is (not (fs/existsSync marker)) + "no RCE: marker file was NOT created") + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (done))))))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..9a78b482e3 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -79,15 +79,21 @@ {:type :gradient :gradient fill-color-gradient} - (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + (and (string? fill-color) + (cc/hex-color-string? fill-color) + (some? fill-opacity) + (not= fill-opacity 1)) {:type :transparent :hex fill-color :opacity fill-opacity} - (string? fill-color) + (and (string? fill-color) + (cc/hex-color-string? fill-color)) {:type :solid :hex fill-color - :map-to fill-color})) + :map-to fill-color} + + :else nil)) (defn- retrieve-colors "Given a text shape returns a triple with the values: diff --git a/scripts/ci b/scripts/ci index 026bdb7d02..f78bbd0795 100755 --- a/scripts/ci +++ b/scripts/ci @@ -26,7 +26,7 @@ declare -A LINT_CMD=( [backend]="pnpm run lint:clj" [common]="pnpm run lint:clj" [render-wasm]="./lint" - [exporter]="pnpm run lint" + [exporter]="pnpm run lint:clj" [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" @@ -37,7 +37,7 @@ declare -A TEST_CMD=( [backend]="clojure -M:dev:test" [common]="clojure -M:dev:test && pnpm run test:quiet" [render-wasm]="./test" - [exporter]="" + [exporter]="pnpm run test:quiet" [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" @@ -48,7 +48,7 @@ declare -A FMT_CHECK_CMD=( [backend]="pnpm run check-fmt" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [render-wasm]="cargo fmt --check" - [exporter]="pnpm run check-fmt" + [exporter]="pnpm run check-fmt:clj" [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" [library]="pnpm run check-fmt" @@ -59,7 +59,7 @@ declare -A FMT_FIX_CMD=( [backend]="pnpm run fmt" [common]="pnpm run fmt:clj && pnpm run fmt:js" [render-wasm]="cargo fmt" - [exporter]="pnpm run fmt" + [exporter]="pnpm run fmt:clj" [mcp]="pnpm run fmt" [plugins]="pnpm run format" [library]="pnpm run fmt" @@ -70,7 +70,7 @@ declare -A PAREN_REPAIR_CMD=( [backend]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [common]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [render-wasm]="" - [exporter]="find src -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" + [exporter]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [mcp]="" [plugins]="" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" From aa3bc1ae984577f0354d4270d2546e15985f4bcf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 13:29:04 +0200 Subject: [PATCH 156/298] :bug: Fix linear gradients in SVG text exports (#11272) * :bug: Use gradient type instead of export type in SVG renderer data->gradient-def was comparing the render `type` parameter (:svg, :png, :pdf) against "linear" to decide between linearGradient and radialGradient elements. Since the export type is never "linear", the comparison always fell through to radialGradient, causing all linear gradients to be exported as radial in SVG output. Read the gradient type from the data map instead: (get-in data ["gradient" "type"]) Closes #5972 * :bug: Add SVG gradient export regression test Extract SVG gradient definition generation from the renderer so it can be tested directly. Add exporter test build wiring and cover both linear and radial gradient output. AI-assisted-by: gpt-5.6-luna * :sparkles: Standardize exporter testing workflow Align exporter scripts with the frontend testing pattern. Add a dedicated GitHub Actions workflow and document the canonical exporter commands in Serena memories. AI-assisted-by: gpt-5.6-luna * :sparkles: Add focused exporter test execution Mirror frontend test-runner behavior for focused namespaces and test vars. Support --focus, --log-level, and --help, and document the commands. AI-assisted-by: gpt-5.6-luna * :bug: Replace shell exec with execFile in exporter Replace child_process.exec with execFile to eliminate shell interpretation. Add hex color validation in exporter and frontend to reject malformed input before command construction. This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated OS command injection vulnerability where malicious fill-color values could execute arbitrary commands in the exporter container. Defense in depth: - Layer 1: execFile passes arguments directly without shell parsing - Layer 2: Exporter validates colors with strict hex regex - Layer 3: Frontend filters invalid colors before DOM emission All three independent reporters' attack vectors are addressed: - Quote breakout (lyhtheori) - Command substitution (B1gN0Se) - Path traversal (KimiSecurityTeam) AI-assisted-by: qwen3.7-plus * :bug: Use existing hex-color-string? and fix test path mismatch Address code review feedback: - Replace duplicated hex-color-rx and valid-hex-color? with existing hex-color-string? from app.common.types.color - Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned AI-assisted-by: qwen3.7-plus --------- Co-authored-by: Sumit Ridhal --- .github/workflows/tests-exporter.yml | 58 ++++++ .serena/memories/exporter/core.md | 5 +- .serena/memories/exporter/testing.md | 16 ++ exporter/package.json | 9 +- exporter/scripts/test | 7 + exporter/scripts/test-quiet.js | 29 +++ exporter/shadow-cljs.edn | 10 +- exporter/src/app/handlers/export_frames.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 38 ++-- exporter/src/app/renderer/svg_gradient.cljs | 32 ++++ exporter/src/app/util/shell.cljs | 16 +- .../exporter_tests/renderer_svg_test.cljs | 25 +++ exporter/test/exporter_tests/runner.cljs | 172 ++++++++++++++++++ exporter/test/exporter_tests/shell_test.cljs | 70 +++++++ .../src/app/main/ui/shapes/text/fo_text.cljs | 12 +- scripts/ci | 10 +- 17 files changed, 462 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/tests-exporter.yml create mode 100644 .serena/memories/exporter/testing.md create mode 100755 exporter/scripts/test create mode 100644 exporter/scripts/test-quiet.js create mode 100644 exporter/src/app/renderer/svg_gradient.cljs create mode 100644 exporter/test/exporter_tests/renderer_svg_test.cljs create mode 100644 exporter/test/exporter_tests/runner.cljs create mode 100644 exporter/test/exporter_tests/shell_test.cljs diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml new file mode 100644 index 0000000000..1ed37d95c8 --- /dev/null +++ b/.github/workflows/tests-exporter.yml @@ -0,0 +1,58 @@ +name: "CI: Exporter" + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'exporter/**' + - 'common/**' + + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + + paths: + - 'exporter/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-exporter: + if: ${{ !github.event.pull_request.draft }} + name: "Exporter Tests" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Lint + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run check-fmt:clj + pnpm run lint:clj + + - name: Tests + working-directory: ./exporter + run: | + ./scripts/test diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 3bcf784f49..9b7078045b 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,9 +5,10 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. -- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. +- Exporter test conventions and CI: `mem:exporter/testing`. ## HTTP and browser pool @@ -31,4 +32,4 @@ - WebP is produced by taking a PNG screenshot and converting it with ImageMagick. - SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths. - PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers. -- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. \ No newline at end of file +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md new file mode 100644 index 0000000000..189c1e852c --- /dev/null +++ b/.serena/memories/exporter/testing.md @@ -0,0 +1,16 @@ +# Exporter Testing + +- READ `mem:testing` first. +- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`. +- Register every test namespace in `exporter-tests.runner`. +- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests. +- From `exporter/`: `pnpm run test` builds and runs tests with full output. +- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output. +- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`. +- For iterative focused runs, build once and reuse the compiled bundle. +- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`. +- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`. +- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`). +- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs. +- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting. +- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting. diff --git a/exporter/package.json b/exporter/package.json index 83518eabee..e52fb9fd12 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,8 +34,11 @@ "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt": "cljfmt fix --parallel=true src/", - "check-fmt": "cljfmt check --parallel=true src/", - "lint": "clj-kondo --parallel --lint src/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "lint:clj": "clj-kondo --parallel --lint src/ test/", + "build:test": "clojure -M:dev:shadow-cljs compile test", + "test": "pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/exporter/scripts/test b/exporter/scripts/test new file mode 100755 index 0000000000..6402c5afd1 --- /dev/null +++ b/exporter/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -ex +corepack enable; +corepack install; +pnpm install; +pnpm run test; diff --git a/exporter/scripts/test-quiet.js b/exporter/scripts/test-quiet.js new file mode 100644 index 0000000000..b1be0dd682 --- /dev/null +++ b/exporter/scripts/test-quiet.js @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; + +const BUILD_STEPS = [ + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index ae963cf311..076e6a10f6 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -31,4 +31,12 @@ :pseudo-names true :pretty-print true :anon-fn-naming-policy :off - :source-map-detail-level :all}}}}} + :source-map-detail-level :all}}} + + :test + {:target :esm + :output-dir "target/tests" + :runtime :node + :js-options {:js-provider :import} + :modules + {:test {:init-fn exporter-tests.runner/-main}}}}} diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index cf263d8e00..658456059d 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -117,7 +117,7 @@ [file-id paths] (p/let [prefix (str/concat "penpot.pdfunite." file-id ".") path (sh/tempfile :prefix prefix :suffix ".pdf")] - (sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path)) + (apply sh/run-cmd! "pdfunite" (conj (vec paths) path)) path)) (defn- move-file diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index c2720eb025..e04c60076b 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -38,7 +38,7 @@ :webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")] ;; playwright only supports jpg and png, we need to convert it afterwards (bw/screenshot node {:omit-background? true :type :png :path png-path}) - (sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path)))) + (sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path)))) (on-object (assoc object :path path)))) (render [uri page] diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index c9fee2f764..0db4bc0cf8 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -10,9 +10,12 @@ ["xml-js" :as xml] [app.browser :as bw] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.types.color :as ctc] [app.common.uri :as u] [app.config :as cf] + [app.renderer.svg-gradient :as svg-gradient] [app.util.mime :as mime] [app.util.shell :as sh] [clojure.walk :as walk] @@ -125,19 +128,23 @@ (letfn [(convert-to-ppm [pngpath] (let [ppmpath (str/concat pngpath "origin.ppm")] (l/trace :fn :convert-to-ppm :path ppmpath) - (-> (sh/run-cmd! (str "convert " pngpath " " ppmpath)) + (-> (sh/run-cmd! "convert" pngpath ppmpath) (p/then (constantly ppmpath))))) (trace-color-mask [pbmpath] (l/trace :fn :trace-color-mask :pbmpath pbmpath) (let [svgpath (str/concat pbmpath ".svg")] - (-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) + (-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath) (p/then (constantly svgpath))))) (generate-color-layer [ppmpath color] + (when-not (ctc/hex-color-string? color) + (ex/raise :type :validation + :code :invalid-color + :hint (str "invalid hex color: " color))) (l/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")] - (-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath)) + (-> (sh/run-cmd! "ppmcolormask" color ppmpath) (p/then (fn [stdout] (-> (sh/write-file! pbmpath stdout) (p/then (constantly pbmpath))))) @@ -166,33 +173,11 @@ :else (update node "attributes" assoc "fill" color)))) - (get-stops [data] - (->> (get-in data ["gradient" "stops"]) - (mapv (fn [stop-data] - {"type" "element" - "name" "stop" - "attributes" {"offset" (get stop-data "offset") - "stop-color" (get stop-data "color") - "stop-opacity" (get stop-data "opacity")}})))) - - (data->gradient-def [id [color data]] - (let [id (str "gradient-" id "-" (subs color 1))] - (if (= type "linear") - {"type" "element" - "name" "linearGradient" - "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} - "elements" (get-stops data)} - - {"type" "element" - "name" "radialGradient" - "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} - "elements" (get-stops data)}))) - (get-gradients [id mapping] (->> mapping (filter (fn [[_color data]] (= (get data "type") "gradient"))) - (mapv (partial data->gradient-def id)))) + (mapv (partial svg-gradient/data->gradient-def id)))) (join-color-layers [{:keys [id x y width height mapping] :as node} layers] (l/trace :fn :join-color-layers :mapping mapping) @@ -369,4 +354,3 @@ (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) - diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs new file mode 100644 index 0000000000..2efaca2c0d --- /dev/null +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -0,0 +1,32 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.renderer.svg-gradient) + +(defn- get-stops + [data] + (->> (get-in data ["gradient" "stops"]) + (mapv (fn [stop-data] + {"type" "element" + "name" "stop" + "attributes" {"offset" (get stop-data "offset") + "stop-color" (get stop-data "color") + "stop-opacity" (get stop-data "opacity")}})))) + +(defn data->gradient-def + [id [color data]] + (let [id (str "gradient-" id "-" (subs color 1)) + gradient-type (get-in data ["gradient" "type"])] + (if (= gradient-type "linear") + {"type" "element" + "name" "linearGradient" + "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} + "elements" (get-stops data)} + + {"type" "element" + "name" "radialGradient" + "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} + "elements" (get-stops data)}))) diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 60dc1bd6b8..8331888b00 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -94,14 +94,14 @@ (.readFile fs/promises fpath)) (defn run-cmd! - [cmd] + [cmd & args] (p/create (fn [resolve reject] - (l/trace :fn :run-cmd :cmd cmd) - (proc/exec cmd #js {:encoding "buffer"} - (fn [error stdout _stderr] - ;; (l/trace :fn :run-cmd :stdout stdout) - (if error - (reject error) - (resolve stdout))))))) + (l/trace :fn :run-cmd :cmd cmd :args args) + (proc/execFile cmd (clj->js args) #js {:encoding "buffer"} + (fn [error stdout _stderr] + ;; (l/trace :fn :run-cmd :stdout stdout) + (if error + (reject error) + (resolve stdout))))))) diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs new file mode 100644 index 0000000000..d680b344ab --- /dev/null +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -0,0 +1,25 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.renderer-svg-test + (:require + [app.renderer.svg-gradient :as svg-gradient] + [cljs.test :refer [deftest is testing]])) + +(def gradient-stops + [{"color" "#000000" "offset" 0 "opacity" 1} + {"color" "#ffffff" "offset" 1 "opacity" 1}]) + +(deftest creates-the-correct-gradient-element + (doseq [[gradient-type element-name] + [["linear" "linearGradient"] + ["radial" "radialGradient"]]] + (testing gradient-type + (let [gradient-data {"type" "gradient" + "gradient" {"type" gradient-type + "stops" gradient-stops}} + result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] + (is (= element-name (get result "name"))))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs new file mode 100644 index 0000000000..a0aa4481e0 --- /dev/null +++ b/exporter/test/exporter_tests/runner.cljs @@ -0,0 +1,172 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.runner + (:require + [app.common.logging :as l] + [cljs.test :as t] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [exporter-tests.renderer-svg-test] + [exporter-tests.shell-test] + [goog.object :as gobj])) + +(enable-console-print!) + +(def test-namespaces + ['exporter-tests.renderer-svg-test + 'exporter-tests.shell-test]) + +(assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljs") + +(defmethod t/report [:cljs.test/default :begin-test-var] + [m] + (let [v (:var m)] + (println (str " ▸ " (:ns (meta v)) "/" (:name (meta v)))))) + +(defmethod t/report [:cljs.test/default :end-run-tests] + [result] + (.exit js/process (if (cljs.test/successful? result) 0 1))) + +(def ^:private log-levels + #{:trace :debug :info :warn :error}) + +(def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + +(defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + ;; `pnpm run test -- --focus ...` forwards the separator to the node + ;; process, so drop one leading `--` before handing args to tools.cli. + (cond-> args + (= "--" (first args)) rest))) + +(defn- usage + [summary] + (str "Usage: node target/tests/test.js [options]\n\n" + "Options:\n" + summary "\n\n" + "Build first with: pnpm run build:test\n\n" + "Focus examples:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n" + "Log level example:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn")) + +(defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + +(defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + +(defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + +(defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + +(defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + +(defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + +(defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + +(defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/set-env! env) + (t/run-block + (concat (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-current-env)))) + (fn [] + (t/report @summary) + (t/report (assoc @summary :type :end-run-tests)))])))) + +(defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + +(defn -main + [] + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (l/setup! {:app (or (:log-level options) :warn)}) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))))) diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs new file mode 100644 index 0000000000..e232a4ed67 --- /dev/null +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.shell-test + "Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter. + These tests prove that: + 1. execFile does NOT interpret shell metacharacters (safe execution) + 2. Malicious colors fail validation regex + 3. The injection does NOT execute commands (no RCE)" + (:require + ["node:child_process" :as proc] + ["node:fs" :as fs] + [cljs.test :as t :include-macros true])) + +(def ^:private hex-color-rx + #"^#(?:[0-9a-fA-F]{3}){1,2}$") + +(defn- valid-hex-color? + [color] + (and (string? color) + (some? (re-matches hex-color-rx color)))) + +(t/deftest execfile-does-not-interpret-shell-metacharacters + (t/testing "Proves execFile passes arguments literally (no shell interpretation)" + (t/async done + (let [cmd "echo" + args #js ["$(echo PWNED)"]] + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [error stdout _stderr] + (if error + (do + (t/is false (str "unexpected error: " (.-message error))) + (done)) + (let [output (.toString stdout "utf8")] + (t/is (= "$(echo PWNED)\n" output) + "execFile passes $(...) literally, no shell interpretation") + (done))))))))) + +(t/deftest malicious-color-fails-validation + (t/testing "Proves malicious colors are rejected by validation" + (let [malicious "#000000$(echo PWNED)" + valid-color "#000000" + short-valid "#abc"] + (t/is (not (valid-hex-color? malicious)) + "malicious color with $(...) fails validation") + (t/is (valid-hex-color? valid-color) + "valid 6-digit hex color passes validation") + (t/is (valid-hex-color? short-valid) + "valid 3-digit hex color passes validation")))) + +(t/deftest execfile-does-not-execute-injected-commands + (t/testing "Proves execFile does NOT execute injected commands (no RCE)" + (t/async done + (let [marker "/tmp/penpot-exporter-rce-test" + malicious (str "#000000$(touch " marker ")") + cmd "echo" + args #js [malicious]] + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [_error _stdout _stderr] + ;; Command completes (or fails), but no injection occurs + (t/is (not (fs/existsSync marker)) + "no RCE: marker file was NOT created") + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (done))))))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..9a78b482e3 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -79,15 +79,21 @@ {:type :gradient :gradient fill-color-gradient} - (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + (and (string? fill-color) + (cc/hex-color-string? fill-color) + (some? fill-opacity) + (not= fill-opacity 1)) {:type :transparent :hex fill-color :opacity fill-opacity} - (string? fill-color) + (and (string? fill-color) + (cc/hex-color-string? fill-color)) {:type :solid :hex fill-color - :map-to fill-color})) + :map-to fill-color} + + :else nil)) (defn- retrieve-colors "Given a text shape returns a triple with the values: diff --git a/scripts/ci b/scripts/ci index 026bdb7d02..f78bbd0795 100755 --- a/scripts/ci +++ b/scripts/ci @@ -26,7 +26,7 @@ declare -A LINT_CMD=( [backend]="pnpm run lint:clj" [common]="pnpm run lint:clj" [render-wasm]="./lint" - [exporter]="pnpm run lint" + [exporter]="pnpm run lint:clj" [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" @@ -37,7 +37,7 @@ declare -A TEST_CMD=( [backend]="clojure -M:dev:test" [common]="clojure -M:dev:test && pnpm run test:quiet" [render-wasm]="./test" - [exporter]="" + [exporter]="pnpm run test:quiet" [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" @@ -48,7 +48,7 @@ declare -A FMT_CHECK_CMD=( [backend]="pnpm run check-fmt" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [render-wasm]="cargo fmt --check" - [exporter]="pnpm run check-fmt" + [exporter]="pnpm run check-fmt:clj" [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" [library]="pnpm run check-fmt" @@ -59,7 +59,7 @@ declare -A FMT_FIX_CMD=( [backend]="pnpm run fmt" [common]="pnpm run fmt:clj && pnpm run fmt:js" [render-wasm]="cargo fmt" - [exporter]="pnpm run fmt" + [exporter]="pnpm run fmt:clj" [mcp]="pnpm run fmt" [plugins]="pnpm run format" [library]="pnpm run fmt" @@ -70,7 +70,7 @@ declare -A PAREN_REPAIR_CMD=( [backend]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [common]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [render-wasm]="" - [exporter]="find src -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" + [exporter]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" [mcp]="" [plugins]="" [library]="find src test -name '*.clj' -o -name '*.cljs' -o -name '*.cljc' | xargs $PROJECT_ROOT/scripts/paren-repair" From 4da649919740568d4a41bdd7063c98da068868f4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 13:29:04 +0200 Subject: [PATCH 157/298] :bug: Fix linear gradients in SVG text exports (#11272) * :bug: Use gradient type instead of export type in SVG renderer data->gradient-def was comparing the render `type` parameter (:svg, :png, :pdf) against "linear" to decide between linearGradient and radialGradient elements. Since the export type is never "linear", the comparison always fell through to radialGradient, causing all linear gradients to be exported as radial in SVG output. Read the gradient type from the data map instead: (get-in data ["gradient" "type"]) Closes #5972 * :bug: Add SVG gradient export regression test Extract SVG gradient definition generation from the renderer so it can be tested directly. Add exporter test build wiring and cover both linear and radial gradient output. AI-assisted-by: gpt-5.6-luna * :sparkles: Standardize exporter testing workflow Align exporter scripts with the frontend testing pattern. Add a dedicated GitHub Actions workflow and document the canonical exporter commands in Serena memories. AI-assisted-by: gpt-5.6-luna * :sparkles: Add focused exporter test execution Mirror frontend test-runner behavior for focused namespaces and test vars. Support --focus, --log-level, and --help, and document the commands. AI-assisted-by: gpt-5.6-luna * :bug: Replace shell exec with execFile in exporter Replace child_process.exec with execFile to eliminate shell interpretation. Add hex color validation in exporter and frontend to reject malformed input before command construction. This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated OS command injection vulnerability where malicious fill-color values could execute arbitrary commands in the exporter container. Defense in depth: - Layer 1: execFile passes arguments directly without shell parsing - Layer 2: Exporter validates colors with strict hex regex - Layer 3: Frontend filters invalid colors before DOM emission All three independent reporters' attack vectors are addressed: - Quote breakout (lyhtheori) - Command substitution (B1gN0Se) - Path traversal (KimiSecurityTeam) AI-assisted-by: qwen3.7-plus * :bug: Use existing hex-color-string? and fix test path mismatch Address code review feedback: - Replace duplicated hex-color-rx and valid-hex-color? with existing hex-color-string? from app.common.types.color - Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned AI-assisted-by: qwen3.7-plus --------- Co-authored-by: Sumit Ridhal --- .github/workflows/tests-exporter.yml | 58 ++++++ .serena/memories/exporter/core.md | 5 +- .serena/memories/exporter/testing.md | 16 ++ exporter/package.json | 9 +- exporter/scripts/test | 7 + exporter/scripts/test-quiet.js | 29 +++ exporter/shadow-cljs.edn | 10 +- exporter/src/app/handlers/export_frames.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 38 ++-- exporter/src/app/renderer/svg_gradient.cljs | 32 ++++ exporter/src/app/util/shell.cljs | 16 +- .../exporter_tests/renderer_svg_test.cljs | 25 +++ exporter/test/exporter_tests/runner.cljs | 172 ++++++++++++++++++ exporter/test/exporter_tests/shell_test.cljs | 70 +++++++ .../src/app/main/ui/shapes/text/fo_text.cljs | 12 +- scripts/ci | 8 +- 17 files changed, 461 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/tests-exporter.yml create mode 100644 .serena/memories/exporter/testing.md create mode 100755 exporter/scripts/test create mode 100644 exporter/scripts/test-quiet.js create mode 100644 exporter/src/app/renderer/svg_gradient.cljs create mode 100644 exporter/test/exporter_tests/renderer_svg_test.cljs create mode 100644 exporter/test/exporter_tests/runner.cljs create mode 100644 exporter/test/exporter_tests/shell_test.cljs diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml new file mode 100644 index 0000000000..1ed37d95c8 --- /dev/null +++ b/.github/workflows/tests-exporter.yml @@ -0,0 +1,58 @@ +name: "CI: Exporter" + +defaults: + run: + shell: bash + +on: + pull_request: + paths: + - 'exporter/**' + - 'common/**' + + types: + - opened + - synchronize + - ready_for_review + + push: + branches: + - develop + - staging + + paths: + - 'exporter/**' + - 'common/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-exporter: + if: ${{ !github.event.pull_request.draft }} + name: "Exporter Tests" + runs-on: penpot-runner-02 + container: + image: penpotapp/devenv:latest + volumes: + - /var/cache/github-runner/m2:/root/.m2 + - /var/cache/github-runner/gitlib:/root/.gitlibs + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Lint + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run check-fmt:clj + pnpm run lint:clj + + - name: Tests + working-directory: ./exporter + run: | + ./scripts/test diff --git a/.serena/memories/exporter/core.md b/.serena/memories/exporter/core.md index 3bcf784f49..9b7078045b 100644 --- a/.serena/memories/exporter/core.md +++ b/.serena/memories/exporter/core.md @@ -5,9 +5,10 @@ ## Layout and commands - Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`. -- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`. +- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or `pnpm run test:quiet`; lint `pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`. - Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`. - Cross-cutting testing principles and anti-patterns: `mem:testing`. +- Exporter test conventions and CI: `mem:exporter/testing`. ## HTTP and browser pool @@ -31,4 +32,4 @@ - WebP is produced by taking a PNG screenshot and converting it with ImageMagick. - SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths. - PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers. -- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. \ No newline at end of file +- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter ` and Bearer auth. diff --git a/.serena/memories/exporter/testing.md b/.serena/memories/exporter/testing.md new file mode 100644 index 0000000000..189c1e852c --- /dev/null +++ b/.serena/memories/exporter/testing.md @@ -0,0 +1,16 @@ +# Exporter Testing + +- READ `mem:testing` first. +- Tests use `cljs.test` and live under `exporter/test/exporter_tests/`. +- Register every test namespace in `exporter-tests.runner`. +- From `exporter/`: `pnpm run build:test` builds the Node test bundle without running tests. +- From `exporter/`: `pnpm run test` builds and runs tests with full output. +- From `exporter/`: `pnpm run test:quiet` builds and runs tests with reduced build output. +- After `build:test`, reuse the compiled bundle with `node target/tests/test.js`. +- For iterative focused runs, build once and reuse the compiled bundle. +- Focus a test namespace with `node target/tests/test.js --focus exporter-tests.renderer-svg-test`. +- Focus a test var with `node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element`. +- Set app log level by appending `--log-level warn` (or `trace|debug|info|warn|error`). +- `test:quiet` accepts forwarded options but rebuilds the bundle; prefer the direct runner after `build:test` for focused runs. +- From `exporter/`: `pnpm run check-fmt:clj` checks ClojureScript formatting. +- From `exporter/`: `pnpm run lint:clj` runs ClojureScript linting. diff --git a/exporter/package.json b/exporter/package.json index fce5a1fad9..142709a363 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,8 +34,11 @@ "watch": "pnpm run watch:app", "build:app": "clojure -M:dev:shadow-cljs release main", "build": "pnpm run clear:shadow-cache && pnpm run build:app", - "fmt": "cljfmt fix --parallel=true src/", - "check-fmt": "cljfmt check --parallel=true src/", - "lint": "clj-kondo --parallel --lint src/" + "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "lint:clj": "clj-kondo --parallel --lint src/ test/", + "build:test": "clojure -M:dev:shadow-cljs compile test", + "test": "pnpm run build:test && node target/tests/test.js", + "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/exporter/scripts/test b/exporter/scripts/test new file mode 100755 index 0000000000..6402c5afd1 --- /dev/null +++ b/exporter/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -ex +corepack enable; +corepack install; +pnpm install; +pnpm run test; diff --git a/exporter/scripts/test-quiet.js b/exporter/scripts/test-quiet.js new file mode 100644 index 0000000000..b1be0dd682 --- /dev/null +++ b/exporter/scripts/test-quiet.js @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; + +const BUILD_STEPS = [ + { label: "Building test bundle", cmd: "pnpm", args: ["run", "build:test"] }, +]; + +const progress = (msg) => process.stderr.write(`${msg}\n`); + +for (const step of BUILD_STEPS) { + progress(`${step.label}...`); + const result = spawnSync(step.cmd, step.args, { + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + progress(`${step.label} failed`); + if (result.stdout?.length) process.stdout.write(result.stdout); + if (result.stderr?.length) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } +} + +progress("Running tests..."); +const result = spawnSync( + "node", + ["target/tests/test.js", ...process.argv.slice(2)], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/exporter/shadow-cljs.edn b/exporter/shadow-cljs.edn index ae963cf311..076e6a10f6 100644 --- a/exporter/shadow-cljs.edn +++ b/exporter/shadow-cljs.edn @@ -31,4 +31,12 @@ :pseudo-names true :pretty-print true :anon-fn-naming-policy :off - :source-map-detail-level :all}}}}} + :source-map-detail-level :all}}} + + :test + {:target :esm + :output-dir "target/tests" + :runtime :node + :js-options {:js-provider :import} + :modules + {:test {:init-fn exporter-tests.runner/-main}}}}} diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index cf263d8e00..658456059d 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -117,7 +117,7 @@ [file-id paths] (p/let [prefix (str/concat "penpot.pdfunite." file-id ".") path (sh/tempfile :prefix prefix :suffix ".pdf")] - (sh/run-cmd! (str "pdfunite " (str/join " " paths) " " path)) + (apply sh/run-cmd! "pdfunite" (conj (vec paths) path)) path)) (defn- move-file diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index c2720eb025..e04c60076b 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -38,7 +38,7 @@ :webp (p/let [png-path (sh/tempfile :prefix "penpot.tmp.bitmap." :suffix ".png")] ;; playwright only supports jpg and png, we need to convert it afterwards (bw/screenshot node {:omit-background? true :type :png :path png-path}) - (sh/run-cmd! (str "convert " png-path " -quality 100 WEBP:" path)))) + (sh/run-cmd! "convert" png-path "-quality" "100" (str "WEBP:" path)))) (on-object (assoc object :path path)))) (render [uri page] diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index c9fee2f764..0db4bc0cf8 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -10,9 +10,12 @@ ["xml-js" :as xml] [app.browser :as bw] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.types.color :as ctc] [app.common.uri :as u] [app.config :as cf] + [app.renderer.svg-gradient :as svg-gradient] [app.util.mime :as mime] [app.util.shell :as sh] [clojure.walk :as walk] @@ -125,19 +128,23 @@ (letfn [(convert-to-ppm [pngpath] (let [ppmpath (str/concat pngpath "origin.ppm")] (l/trace :fn :convert-to-ppm :path ppmpath) - (-> (sh/run-cmd! (str "convert " pngpath " " ppmpath)) + (-> (sh/run-cmd! "convert" pngpath ppmpath) (p/then (constantly ppmpath))))) (trace-color-mask [pbmpath] (l/trace :fn :trace-color-mask :pbmpath pbmpath) (let [svgpath (str/concat pbmpath ".svg")] - (-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) + (-> (sh/run-cmd! "potrace" "--flat" "-b" "svg" pbmpath "-o" svgpath) (p/then (constantly svgpath))))) (generate-color-layer [ppmpath color] + (when-not (ctc/hex-color-string? color) + (ex/raise :type :validation + :code :invalid-color + :hint (str "invalid hex color: " color))) (l/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (let [pbmpath (str/concat ppmpath ".mask-" (subs color 1) ".pbm")] - (-> (sh/run-cmd! (str/format "ppmcolormask \"%s\" %s" color ppmpath)) + (-> (sh/run-cmd! "ppmcolormask" color ppmpath) (p/then (fn [stdout] (-> (sh/write-file! pbmpath stdout) (p/then (constantly pbmpath))))) @@ -166,33 +173,11 @@ :else (update node "attributes" assoc "fill" color)))) - (get-stops [data] - (->> (get-in data ["gradient" "stops"]) - (mapv (fn [stop-data] - {"type" "element" - "name" "stop" - "attributes" {"offset" (get stop-data "offset") - "stop-color" (get stop-data "color") - "stop-opacity" (get stop-data "opacity")}})))) - - (data->gradient-def [id [color data]] - (let [id (str "gradient-" id "-" (subs color 1))] - (if (= type "linear") - {"type" "element" - "name" "linearGradient" - "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} - "elements" (get-stops data)} - - {"type" "element" - "name" "radialGradient" - "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} - "elements" (get-stops data)}))) - (get-gradients [id mapping] (->> mapping (filter (fn [[_color data]] (= (get data "type") "gradient"))) - (mapv (partial data->gradient-def id)))) + (mapv (partial svg-gradient/data->gradient-def id)))) (join-color-layers [{:keys [id x y width height mapping] :as node} layers] (l/trace :fn :join-color-layers :mapping mapping) @@ -369,4 +354,3 @@ (assoc :query (u/map->query-string params)))] (bw/exec! (prepare-options uri) (partial render uri))))) - diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs new file mode 100644 index 0000000000..2efaca2c0d --- /dev/null +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -0,0 +1,32 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.renderer.svg-gradient) + +(defn- get-stops + [data] + (->> (get-in data ["gradient" "stops"]) + (mapv (fn [stop-data] + {"type" "element" + "name" "stop" + "attributes" {"offset" (get stop-data "offset") + "stop-color" (get stop-data "color") + "stop-opacity" (get stop-data "opacity")}})))) + +(defn data->gradient-def + [id [color data]] + (let [id (str "gradient-" id "-" (subs color 1)) + gradient-type (get-in data ["gradient" "type"])] + (if (= gradient-type "linear") + {"type" "element" + "name" "linearGradient" + "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} + "elements" (get-stops data)} + + {"type" "element" + "name" "radialGradient" + "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} + "elements" (get-stops data)}))) diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 60dc1bd6b8..8331888b00 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -94,14 +94,14 @@ (.readFile fs/promises fpath)) (defn run-cmd! - [cmd] + [cmd & args] (p/create (fn [resolve reject] - (l/trace :fn :run-cmd :cmd cmd) - (proc/exec cmd #js {:encoding "buffer"} - (fn [error stdout _stderr] - ;; (l/trace :fn :run-cmd :stdout stdout) - (if error - (reject error) - (resolve stdout))))))) + (l/trace :fn :run-cmd :cmd cmd :args args) + (proc/execFile cmd (clj->js args) #js {:encoding "buffer"} + (fn [error stdout _stderr] + ;; (l/trace :fn :run-cmd :stdout stdout) + (if error + (reject error) + (resolve stdout))))))) diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs new file mode 100644 index 0000000000..d680b344ab --- /dev/null +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -0,0 +1,25 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.renderer-svg-test + (:require + [app.renderer.svg-gradient :as svg-gradient] + [cljs.test :refer [deftest is testing]])) + +(def gradient-stops + [{"color" "#000000" "offset" 0 "opacity" 1} + {"color" "#ffffff" "offset" 1 "opacity" 1}]) + +(deftest creates-the-correct-gradient-element + (doseq [[gradient-type element-name] + [["linear" "linearGradient"] + ["radial" "radialGradient"]]] + (testing gradient-type + (let [gradient-data {"type" "gradient" + "gradient" {"type" gradient-type + "stops" gradient-stops}} + result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] + (is (= element-name (get result "name"))))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs new file mode 100644 index 0000000000..a0aa4481e0 --- /dev/null +++ b/exporter/test/exporter_tests/runner.cljs @@ -0,0 +1,172 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.runner + (:require + [app.common.logging :as l] + [cljs.test :as t] + [clojure.string :as str] + [clojure.tools.cli :refer [parse-opts]] + [exporter-tests.renderer-svg-test] + [exporter-tests.shell-test] + [goog.object :as gobj])) + +(enable-console-print!) + +(def test-namespaces + ['exporter-tests.renderer-svg-test + 'exporter-tests.shell-test]) + +(assert (every? find-ns-obj test-namespaces) + "test-namespaces contains a namespace that isn't required in runner.cljs") + +(defmethod t/report [:cljs.test/default :begin-test-var] + [m] + (let [v (:var m)] + (println (str " ▸ " (:ns (meta v)) "/" (:name (meta v)))))) + +(defmethod t/report [:cljs.test/default :end-run-tests] + [result] + (.exit js/process (if (cljs.test/successful? result) 0 1))) + +(def ^:private log-levels + #{:trace :debug :info :warn :error}) + +(def cli-options + [["-f" "--focus FOCUS" "Run one test namespace or one test var, e.g. exporter-tests.renderer-svg-test/creates-the-correct-gradient-element"] + ["-l" "--log-level LEVEL" "Set app logger level: trace|debug|info|warn|error" + :parse-fn keyword + :validate [log-levels "must be one of trace, debug, info, warn, error"]] + ["-h" "--help"]]) + +(defn- argv + [] + (let [args (->> (.-argv js/process) + (array-seq) + (drop 2))] + ;; `pnpm run test -- --focus ...` forwards the separator to the node + ;; process, so drop one leading `--` before handing args to tools.cli. + (cond-> args + (= "--" (first args)) rest))) + +(defn- usage + [summary] + (str "Usage: node target/tests/test.js [options]\n\n" + "Options:\n" + summary "\n\n" + "Build first with: pnpm run build:test\n\n" + "Focus examples:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element\n\n" + "Log level example:\n" + " node target/tests/test.js --focus exporter-tests.renderer-svg-test --log-level warn")) + +(defn- fail! + [message] + (js/console.error message) + (.exit js/process 1)) + +(defn- parse-focus + [focus] + (let [[ns-name test-name & extra] (str/split focus #"/")] + (cond + (or (str/blank? ns-name) (seq extra)) + (fail! (str "Invalid --focus value: " focus)) + + (some? test-name) + {:ns (symbol ns-name) :test test-name} + + :else + {:ns (symbol ns-name)}))) + +(defn- fixture-value + [ns-obj fixture-name] + (let [value (gobj/get ns-obj (munge fixture-name))] + (when-not (undefined? value) + value))) + +(defn- ns-test-vars + [ns-sym] + (when-let [ns-obj (find-ns-obj ns-sym)] + (->> (js-keys ns-obj) + (keep (fn [key] + (some-> (gobj/get ns-obj key) + (.-cljs$lang$var)))) + (filter (comp :test meta)) + (sort-by (comp :line meta))))) + +(defn- ns-fixtures + [ns-sym vars] + (when-let [ns-obj (find-ns-obj ns-sym)] + (let [ns-key (or (some-> vars first meta :ns) ns-sym) + once-fixtures (fixture-value ns-obj "cljs-test-once-fixtures") + each-fixtures (fixture-value ns-obj "cljs-test-each-fixtures")] + {:once (when once-fixtures {ns-key once-fixtures}) + :each (when each-fixtures {ns-key each-fixtures})}))) + +(defn- selected-tests + [{:keys [ns test]}] + (when-not (some #{ns} test-namespaces) + (fail! (str "Unknown test namespace: " ns))) + (let [vars (vec (ns-test-vars ns))] + (when (empty? vars) + (fail! (str "No tests found in namespace: " ns))) + (if test + (let [test-sym (symbol test) + test-var (some #(when (= test-sym (:name (meta %))) %) vars)] + (if test-var + {:vars [test-var] + :fixtures (ns-fixtures ns [test-var])} + (fail! (str "Unknown test var: " ns "/" test)))) + {:vars vars + :fixtures (ns-fixtures ns vars)}))) + +(defn- merge-fixtures + [fixtures] + {:once (apply merge (keep :once fixtures)) + :each (apply merge (keep :each fixtures))}) + +(defn- run-test-vars! + [tests] + (let [vars (vec (mapcat :vars tests)) + fixtures (merge-fixtures (map :fixtures tests)) + env (assoc (t/empty-env) + :once-fixtures (:once fixtures) + :each-fixtures (:each fixtures)) + summary (volatile! {:test 0 :pass 0 :fail 0 :error 0 :type :summary})] + (t/set-env! env) + (t/run-block + (concat (t/test-vars-block vars) + [(fn [] + (vswap! summary + (partial merge-with +) + (:report-counters (t/get-current-env)))) + (fn [] + (t/report @summary) + (t/report (assoc @summary :type :end-run-tests)))])))) + +(defn- run-focused-test! + [focus] + (run-test-vars! [(selected-tests (parse-focus focus))])) + +(defn -main + [] + (let [{:keys [options errors summary]} (parse-opts (argv) cli-options)] + (cond + (seq errors) + (fail! (str/join "\n" errors)) + + (:help options) + (do + (println (usage summary)) + (.exit js/process 0)) + + :else + (do + (l/setup! {:app (or (:log-level options) :warn)}) + (if (:focus options) + (run-focused-test! (:focus options)) + (run-test-vars! (map #(selected-tests {:ns %}) test-namespaces))))))) diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs new file mode 100644 index 0000000000..e232a4ed67 --- /dev/null +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.shell-test + "Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter. + These tests prove that: + 1. execFile does NOT interpret shell metacharacters (safe execution) + 2. Malicious colors fail validation regex + 3. The injection does NOT execute commands (no RCE)" + (:require + ["node:child_process" :as proc] + ["node:fs" :as fs] + [cljs.test :as t :include-macros true])) + +(def ^:private hex-color-rx + #"^#(?:[0-9a-fA-F]{3}){1,2}$") + +(defn- valid-hex-color? + [color] + (and (string? color) + (some? (re-matches hex-color-rx color)))) + +(t/deftest execfile-does-not-interpret-shell-metacharacters + (t/testing "Proves execFile passes arguments literally (no shell interpretation)" + (t/async done + (let [cmd "echo" + args #js ["$(echo PWNED)"]] + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [error stdout _stderr] + (if error + (do + (t/is false (str "unexpected error: " (.-message error))) + (done)) + (let [output (.toString stdout "utf8")] + (t/is (= "$(echo PWNED)\n" output) + "execFile passes $(...) literally, no shell interpretation") + (done))))))))) + +(t/deftest malicious-color-fails-validation + (t/testing "Proves malicious colors are rejected by validation" + (let [malicious "#000000$(echo PWNED)" + valid-color "#000000" + short-valid "#abc"] + (t/is (not (valid-hex-color? malicious)) + "malicious color with $(...) fails validation") + (t/is (valid-hex-color? valid-color) + "valid 6-digit hex color passes validation") + (t/is (valid-hex-color? short-valid) + "valid 3-digit hex color passes validation")))) + +(t/deftest execfile-does-not-execute-injected-commands + (t/testing "Proves execFile does NOT execute injected commands (no RCE)" + (t/async done + (let [marker "/tmp/penpot-exporter-rce-test" + malicious (str "#000000$(touch " marker ")") + cmd "echo" + args #js [malicious]] + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (proc/execFile cmd args #js {:encoding "buffer"} + (fn [_error _stdout _stderr] + ;; Command completes (or fails), but no injection occurs + (t/is (not (fs/existsSync marker)) + "no RCE: marker file was NOT created") + (when (fs/existsSync marker) + (fs/unlinkSync marker)) + (done))))))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 5bb224b673..9a78b482e3 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -79,15 +79,21 @@ {:type :gradient :gradient fill-color-gradient} - (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + (and (string? fill-color) + (cc/hex-color-string? fill-color) + (some? fill-opacity) + (not= fill-opacity 1)) {:type :transparent :hex fill-color :opacity fill-opacity} - (string? fill-color) + (and (string? fill-color) + (cc/hex-color-string? fill-color)) {:type :solid :hex fill-color - :map-to fill-color})) + :map-to fill-color} + + :else nil)) (defn- retrieve-colors "Given a text shape returns a triple with the values: diff --git a/scripts/ci b/scripts/ci index df693a9cd0..d13b7bff1a 100755 --- a/scripts/ci +++ b/scripts/ci @@ -26,7 +26,7 @@ declare -A LINT_CMD=( [backend]="pnpm run lint" [common]="pnpm run lint:clj" [render-wasm]="./lint" - [exporter]="pnpm run lint" + [exporter]="pnpm run lint:clj" [mcp]="" [plugins]="pnpm run lint" [library]="pnpm run lint" @@ -37,7 +37,7 @@ declare -A TEST_CMD=( [backend]="clojure -M:dev:test" [common]="clojure -M:dev:test && pnpm run test:quiet" [render-wasm]="./test" - [exporter]="" + [exporter]="pnpm run test:quiet" [mcp]="pnpm run test" [plugins]="pnpm run test" [library]="pnpm run test" @@ -48,7 +48,7 @@ declare -A FMT_CHECK_CMD=( [backend]="pnpm run check-fmt" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [render-wasm]="cargo fmt --check" - [exporter]="pnpm run check-fmt" + [exporter]="pnpm run check-fmt:clj" [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" [library]="pnpm run check-fmt" @@ -59,7 +59,7 @@ declare -A FMT_FIX_CMD=( [backend]="pnpm run fmt" [common]="pnpm run fmt:clj && pnpm run fmt:js" [render-wasm]="cargo fmt" - [exporter]="pnpm run fmt" + [exporter]="pnpm run fmt:clj" [mcp]="pnpm run fmt" [plugins]="pnpm run format" [library]="pnpm run fmt" From c378ec9218c3e3845528a7bcc62dfa6ca32ef87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 19 Aug 2026 14:23:40 +0200 Subject: [PATCH 158/298] :bug: Avoid swallowing fatal errors in organization sso telemetry (#11279) --- backend/src/app/auth/oidc.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 6cb23fb9de..164edafd76 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -845,7 +845,7 @@ cfg request (some-> (session/get-session request) :profile-id) (:organization-id state) "organization-sso-auth-failed" :failure-reason (organization-sso-oauth-failure-reason error)))) - (catch Throwable _ nil))) + (catch Exception _ nil))) (defn- non-blank-uri [value] From a91c796b0e4ea5c282eab94ccfdde1b456e0ba2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 19 Aug 2026 16:19:15 +0200 Subject: [PATCH 159/298] :bug: Fix missing zip export on tempfile types (#11292) --- common/src/app/common/media.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 3d67bc75b6..a5a74e6c75 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -23,7 +23,7 @@ "image/svg+xml"}) (def tempfile-types - (conj image-types "application/pdf")) + (conj image-types "application/pdf" "application/zip")) (defn format->extension [format] From ed588d450050fae44b62697b0d495a0b6edb4dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Wed, 19 Aug 2026 17:27:50 +0200 Subject: [PATCH 160/298] :zap: Disable ReduceOpsTaskSplitting Skia flag (#11280) --- render-wasm/src/render/gpu_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 0425a92f26..5e104efbff 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -22,7 +22,7 @@ impl GpuState { // We tweak some options to enhance performance. let mut context_options = ContextOptions::default(); - // context_options.reduce_ops_task_splitting = Enable::Yes; + context_options.reduce_ops_task_splitting = Enable::No; context_options.skip_gl_error_checks = Enable::Yes; // context_options.runtime_program_cache_size = 1024; // context_options.allow_multiple_glyph_cache_textures = Enable::Yes; From c200a4d777c04f3f9a745728d8fc02a7219de274 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 18:21:55 +0200 Subject: [PATCH 161/298] :bug: Fix HTML escaping in notification pill detail section (#11275) The notification pill component now properly respects the `is-html` flag when rendering the detail section, matching the behavior of the children section. Token import error messages now escape HTML characters in user-provided values like token names and type names before displaying them in notifications. AI-assisted-by: qwen3.7-plus --- frontend/src/app/main/data/workspace/tokens/errors.cljs | 5 +++-- .../src/app/main/data/workspace/tokens/import_export.cljs | 6 ++++-- .../main/ui/ds/notifications/shared/notification_pill.cljs | 6 ++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index e5716f07be..8ca54af835 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -6,6 +6,7 @@ (ns app.main.data.workspace.tokens.errors (:require + [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str])) @@ -25,12 +26,12 @@ :error.import/invalid-token-name {:error/code :error.import/invalid-token-name :error/fn #(tr "errors.tokens.invalid-json-token-name") - :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" %)} + :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" (dom/escape-html %))} :error.import/style-dictionary-reference-errors {:error/code :error.import/style-dictionary-reference-errors :error/fn #(str (tr "errors.tokens.import-error") "\n\n" (first %)) - :error/detail #(str/join "\n\n" (rest %))} + :error/detail #(str/join "\n\n" (map dom/escape-html (rest %)))} :error.import/style-dictionary-unknown-error {:error/code :error.import/style-dictionary-reference-errors diff --git a/frontend/src/app/main/data/workspace/tokens/import_export.cljs b/frontend/src/app/main/data/workspace/tokens/import_export.cljs index 5dded5bbb3..5c7c9151b7 100644 --- a/frontend/src/app/main/data/workspace/tokens/import_export.cljs +++ b/frontend/src/app/main/data/workspace/tokens/import_export.cljs @@ -16,6 +16,7 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.tokens.errors :as wte] [app.main.store :as st] + [app.util.dom :as dom] [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cuerdas.core :as str])) @@ -54,14 +55,15 @@ (l/wrn :hint "unsupported token types found during import" :tokens (str/join ", " (map (fn [[path type]] (str path " (" type ")")) unknown-tokens))) (ntf/show {:content (i18n/tr "workspace.tokens.unknown-token-type-message") + :is-html true :detail (->> (for [[token-type token-paths] type->tokens] (str (i18n/tr "workspace.tokens.unknown-token-type-section" - token-type + (dom/escape-html token-type) (i18n/tr "labels.warning-count" (i18n/c (count token-paths)))) "
      " (->> token-paths (sort) - (map #(str "
    • " % "
    • ")) + (map #(str "
    • " (dom/escape-html %) "
    • ")) (str/join "")) "
    ")) (str/join "")) diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs index 0a774f33ac..c14696c988 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs @@ -59,5 +59,7 @@ (when detail [:details {:class (stl/css :error-detail)} [:summary {:class (stl/css :error-detail-summary)} (tr "workspace.notification-pill.detail")] - [:div {:class (stl/css :error-detail-content) - :dangerouslySetInnerHTML #js {:__html detail}}]])])) + (if is-html + [:div {:class (stl/css :error-detail-content) + :dangerouslySetInnerHTML #js {:__html detail}}] + [:div {:class (stl/css :error-detail-content)} detail])])])) From 209aea83658f209c4189b531eeda0f3a638a0294 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 19 Aug 2026 18:26:35 +0200 Subject: [PATCH 162/298] :bug: Add proper ownership check on managing/deleting shared link on a file (#11290) * :bug: Add ownership check to share-link deletion The delete-share-link RPC command only verified file-level edit permission but did not check if the caller owned the share-link. This allowed any file editor to delete share-links created by other users, disrupting collaborative workflows. The fix adds an ownership check that allows deletion only by: - The share-link creator (owner-id matches profile-id) - File admins (is-admin permission) - File owners (is-owner permission) Implemented using TDD: - RED: Test demonstrates IDOR vulnerability (editor can delete) - GREEN: Ownership check prevents unauthorized deletion - All existing tests continue to pass Closes #11289 AI-assisted-by: qwen3.7-plus * :bug: Add test coverage for share-link deletion escape hatches Address code review feedback for PR #11290: - Add test for editor deleting their own share-link - Add test for admin deleting editor's share-link - Add test for owner deleting editor's share-link - Remove redundant :is-owner check (already included in :is-admin) - Add clarifying comment about :is-admin including :is-owner Closes #11289 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/files_share.clj | 13 ++ backend/test/backend_tests/rpc_file_test.clj | 127 +++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index 9a8326d06d..0e8c184ede 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -7,6 +7,8 @@ (ns app.rpc.commands.files-share "Share link related rpc mutation methods." (:require + [app.binfile.common :as bfc] + [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.uuid :as uuid] [app.db :as db] @@ -66,5 +68,16 @@ [{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}] (let [slink (db/get-by-id conn :share-link id)] (files/check-edition-permissions! conn profile-id (:file-id slink)) + + ;; Verify caller owns this specific share-link, OR has admin access. + ;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions), + ;; so we only need to check :is-admin here. + (let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))] + (when-not (or (= (:owner-id slink) profile-id) + (:is-admin perms)) + (ex/raise :type :authorization + :code :not-share-link-owner + :hint "You can only delete share-links you created"))) + (db/delete! conn :share-link {:id id}) nil)) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index d1ec0eb233..27e881dc45 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2467,3 +2467,130 @@ err (:error out)] (t/is (th/ex-info? err)) (t/is (th/ex-of-type? err :not-found)))) + +(t/deftest share-link-deletion-idor + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + admin (th/create-profile* 3 {:is-active true}) + proj-id (:default-project-id owner) + team-id (:default-team-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + ;; Invite editor to the team with edit permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id editor) + :role :editor}) + + ;; Invite admin to the team with admin permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id admin) + :role :admin}) + + ;; Owner creates a share-link + slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{(get-in file [:data :pages 0])} + :who-comment "team" + :who-inspect "all"}) + slink-id (get-in slink [:result :id])] + + (t/testing "owner can delete their own share-link" + (let [out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id owner) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "editor CANNOT delete owner's share-link (IDOR)" + ;; Recreate the share-link for this test + (let [slink2 (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink2-id (get-in slink2 [:result :id]) + + ;; Editor tries to delete owner's share-link + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id editor) + :id slink2-id}) + err (:error out) + edata (ex-data err)] + + ;; Should be denied with authorization error + (t/is (th/ex-info? err)) + (t/is (= :authorization (:type edata))) + + ;; Verify the share-link still exists + (let [check (th/command! {::th/type :get-view-only-bundle + ::rpc/profile-id (:id owner) + :file-id (:id file)}) + share-links (:share-links (:result check))] + (t/is (some #(= slink2-id (:id %)) share-links))))))) + +(t/deftest share-link-deletion-escape-hatches + (let [owner (th/create-profile* 1 {:is-active true}) + editor (th/create-profile* 2 {:is-active true}) + admin (th/create-profile* 3 {:is-active true}) + proj-id (:default-project-id owner) + team-id (:default-team-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + ;; Invite editor to the team with edit permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id editor) + :role :editor}) + + ;; Invite admin to the team with admin permissions + _ (th/create-team-role* {:team-id team-id + :profile-id (:id admin) + :role :admin})] + + (t/testing "editor CAN delete their own share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id editor) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "admin CAN delete editor's share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id admin) + :id slink-id})] + (t/is (nil? (:error out))))) + + (t/testing "owner CAN delete editor's share-link" + (let [slink (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id editor) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + slink-id (get-in slink [:result :id]) + + out (th/command! {::th/type :delete-share-link + ::rpc/profile-id (:id owner) + :id slink-id})] + (t/is (nil? (:error out))))))) From 9f6878d118facf71c0999014db06916e3fa6cbd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 20 Aug 2026 13:19:35 +0200 Subject: [PATCH 163/298] :bug: Fix disabled invitation explanatory title (#11295) --- frontend/src/app/main/ui/dashboard/team.cljs | 26 ++++++++++++++++---- frontend/src/app/main/ui/dashboard/team.scss | 4 +++ frontend/translations/en.po | 6 +++++ frontend/translations/es.po | 6 +++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 027238e4b8..730cf08f66 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -39,6 +39,7 @@ [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.forms :as fc] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] @@ -139,11 +140,26 @@ [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} (when (and (or invitations-section? members-section?) (not-empty invitations)) - [:> button* {:variant "secondary" - :on-click on-invite-member - :disabled (not can-invite?) - :data-testid "invite-member"} - (tr "dashboard.invite-profile")])]])) + (let [organization (:organization team) + owners-only-invites? (and (contains? cfg/flags :admin-console) + organization + (= (get-in organization [:permissions :send-invitations]) "owners")) + title-text (if owners-only-invites? + (tr "dashboard.invite-profile-disabled.owners-only" (:name organization)) + (tr "dashboard.invite-profile-disabled")) + invite-button (mf/html + [:> button* {:class (stl/css :invite-button) + :variant "secondary" + :on-click on-invite-member + :disabled (not can-invite?) + :data-testid "invite-member"} + (tr "dashboard.invite-profile")])] + (if can-invite? + invite-button + [:> tooltip* {:content title-text + :id "invite-member-disabled-tooltip" + :tab-index 0} + invite-button])))]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INVITATIONS MODAL diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 14366781c4..3408a4697e 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -1062,6 +1062,10 @@ } } +.invite-button:disabled { + --button-disabled-bg-color: var(--color-background-tertiary); +} + a { color: var(--modal-link-foreground-color); } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3175d5a1c3..828353e3c9 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10352,3 +10352,9 @@ msgstr "Sign-in with your organization's identity provider didn't complete. The msgid "labels.sso-error.retry" msgstr "Try again" + +msgid "dashboard.invite-profile-disabled" +msgstr "You don't have permission to invite people to this team" + +msgid "dashboard.invite-profile-disabled.owners-only" +msgstr "Only team owners can invite within %s" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4044119275..53cdbdc91e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9997,3 +9997,9 @@ msgstr "El inicio de sesión con el proveedor de identidad de tu organización n msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" + +msgid "dashboard.invite-profile-disabled" +msgstr "No tienes permiso para invitar a personas a este equipo" + +msgid "dashboard.invite-profile-disabled.owners-only" +msgstr "Solo los propietarios del equipo pueden invitar dentro de %s" \ No newline at end of file From f29a94058af9b6b66309d430e7c822b1e1996052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 20 Aug 2026 14:45:43 +0200 Subject: [PATCH 164/298] :bug: Fix not quitting v3 editor with Esc + Undo transactions (#11293) * :bug: Fix Esc key not quitting editor v3 * :bug: Fix undo transactions being split in editor v3 --- .../ui/workspace/shapes/text/v3_editor.cljs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 86cce0327d..58377e9c1c 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -11,13 +11,16 @@ [app.common.data.macros :as dm] [app.common.types.text :as txt] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as dw] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.css-cursors :as cur] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as text-editor] [app.util.dom :as dom] + [app.util.keyboard :as kbd] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -294,12 +297,7 @@ (and ctrl? (= (str/lower key) "a"))) (text-editor/clear-pending-caret-styles!)) (cond - ;; Escape: finalize and stop - (= key "Escape") - (do - (dom/prevent-default event) - (when-let [node (mf/ref-val contenteditable-ref)] - (.blur node))) + ;; NOTE: Escape is handled in a document key-up listener (see effect below). ;; Ctrl+A: select all (key is "a" or "A" depending on platform) (and ctrl? (= (str/lower key) "a")) @@ -516,6 +514,18 @@ "--editor-container-height" (dm/str height "px") "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")}] + ;; Exit on Escape via a document key-up listener (like v2). On key-down the trailing + ;; key-up is read as a non-editing Escape and deselects the shape. + (mf/use-effect + (mf/deps) + (fn [] + (let [on-key-up (fn [event] + (when (kbd/esc? event) + (dom/stop-propagation event) + (st/emit! (dw/clear-edition-mode))))] + (.addEventListener js/document "keyup" on-key-up) + #(.removeEventListener js/document "keyup" on-key-up)))) + ;; Register the native `beforeinput` listener. React's synthetic ;; `onBeforeInput` does not expose `getTargetRanges()`, even with ;; nativeEvent (it's fully synthetic, composed of other two events). @@ -533,6 +543,9 @@ (mf/use-effect (mf/deps contenteditable-ref) (fn [] + ;; Group the whole editing session (edits, reflow resizes, finalize) into a single + ;; undo entry. Nested transactions (e.g. style shortcuts) are ref-counted and fold in. + (st/emit! (dwu/start-undo-transaction shape-id :timeout nil)) (when-let [node (mf/ref-val contenteditable-ref)] ;; Focus and select all text on mount (this will trigger on-focus) (.focus node) @@ -543,6 +556,7 @@ ;; it was not being reliable (timing issues, Firefox issues…) (fn [] (on-blur) + (st/emit! (dwu/commit-undo-transaction shape-id)) (text-editor/text-editor-dispose) (wasm.api/request-render-preserving-target "text-editor-dispose")))) From ca72213cbbbea9e53cedd781ec20ae778b193983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 20 Aug 2026 21:58:16 +0200 Subject: [PATCH 165/298] :construction_worker: Change the runner's label to a more descriptive one --- .github/workflows/build-bundle.yml | 6 +++--- .github/workflows/build-docker-devenv.yml | 2 +- .github/workflows/build-docker.yml | 8 ++++---- .github/workflows/plugins-deploy-package.yml | 2 +- .github/workflows/tests-backend.yml | 2 +- .github/workflows/tests-common.yml | 2 +- .github/workflows/tests-composable-suite.yml | 2 +- .github/workflows/tests-frontend.yml | 2 +- .github/workflows/tests-integration.yml | 6 +++--- .github/workflows/tests-library.yml | 2 +- .github/workflows/tests-mcp.yml | 2 +- .github/workflows/tests-plugin-api-suite.yml | 4 ++-- .github/workflows/tests-plugins.yml | 2 +- .github/workflows/tests-wasm.yml | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index e785f2c84e..3ab7900134 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -25,7 +25,7 @@ jobs: # ── 1. Decide whether there is anything to build ─────────────────────── check: name: Check current bundle - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 10 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} @@ -75,7 +75,7 @@ jobs: # ── 2. Build and upload, only when needed ────────────────────────────── build: name: Build and Upload Penpot Bundle - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 90 needs: check if: needs.check.outputs.exists == 'false' @@ -116,7 +116,7 @@ jobs: # ── 3. Single failure notification for the whole workflow ───────────── notify: name: Notify failure - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner timeout-minutes: 5 needs: [check, build] if: failure() diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index b0340d329b..53de6376ce 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -6,7 +6,7 @@ on: jobs: build-and-push: name: Build and push DevEnv Docker image - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner steps: - name: Set common environment variables diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0d03490194..6fffaa4acd 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -32,7 +32,7 @@ jobs: # ── 1. Resolve the build key and check the whole set at once ─────────── prepare: name: Prepare - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 15 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} @@ -107,7 +107,7 @@ jobs: # ── 2. One build per image, in parallel, only when needed ────────────── build: name: Build ${{ matrix.image }} - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 60 needs: prepare if: needs.prepare.outputs.exists == 'false' @@ -220,7 +220,7 @@ jobs: # the S3 marker guarantees the branch tags were already moved. promote: name: Promote image set - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 10 needs: [prepare, build] @@ -267,7 +267,7 @@ jobs: # ── 4. Single failure notification for the whole workflow ───────────── notify: name: Notify failure - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 5 needs: [prepare, build, promote] if: failure() diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index cbc8e109cd..2666957893 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -34,7 +34,7 @@ permissions: jobs: deploy: - runs-on: penpot-runner-01 + runs-on: penpot-standar-runner steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index d7a3377772..02f839c062 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -32,7 +32,7 @@ jobs: test-backend: if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index 5996c82742..3164fe5089 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -30,7 +30,7 @@ jobs: test-common: if: ${{ !github.event.pull_request.draft }} name: "Common Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-composable-suite.yml b/.github/workflows/tests-composable-suite.yml index 1ad48c0edf..3b1d68be21 100644 --- a/.github/workflows/tests-composable-suite.yml +++ b/.github/workflows/tests-composable-suite.yml @@ -38,7 +38,7 @@ jobs: composable-test-suite: if: ${{ !github.event.pull_request.draft }} name: "Run composable test suite (mocked backend)" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index 14011a5110..58ba335e38 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -34,7 +34,7 @@ jobs: test-frontend: if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index 1a3e210427..81d67c2d11 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -61,7 +61,7 @@ jobs: build-integration: if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 30 container: image: penpotapp/devenv:latest @@ -101,7 +101,7 @@ jobs: test-integration: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests (${{ matrix.shard }})" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }} needs: build-integration @@ -187,7 +187,7 @@ jobs: merge-reports: if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }} name: "Merge Integration Reports" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner timeout-minutes: 15 needs: test-integration diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index 4c84965f4c..a5b565a892 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -32,7 +32,7 @@ jobs: test-library: if: ${{ !github.event.pull_request.draft }} name: "Library Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 317d82dca2..f51c56b8a3 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -28,7 +28,7 @@ jobs: test-mcp: if: ${{ !github.event.pull_request.draft }} name: "Test MCP" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: penpotapp/devenv:latest steps: diff --git a/.github/workflows/tests-plugin-api-suite.yml b/.github/workflows/tests-plugin-api-suite.yml index e589c5414c..de6e2080bb 100644 --- a/.github/workflows/tests-plugin-api-suite.yml +++ b/.github/workflows/tests-plugin-api-suite.yml @@ -53,7 +53,7 @@ jobs: api-test-suite-mocked: if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }} name: "Run Plugin API Test Suite (mocked)" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: @@ -95,7 +95,7 @@ jobs: # api-test-suite-live: # if: ${{ github.event_name == 'workflow_dispatch' }} # name: Run Plugin API Test Suite (live) - # runs-on: penpot-runner-02 + # runs-on: penpot-extended-runner # container: # image: penpotapp/devenv:latest # diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 1415c73db7..b1bbdd0992 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -30,7 +30,7 @@ jobs: test-plugins: if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index 424d4f908f..9f26175554 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -30,7 +30,7 @@ jobs: test-render-wasm: if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" - runs-on: penpot-runner-02 + runs-on: penpot-extended-runner container: image: penpotapp/devenv:latest volumes: From 689d506788415fd67573754bc68c17395580fa2c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 20 Aug 2026 11:12:44 +0200 Subject: [PATCH 166/298] :zap: Render eligible frame drop shadows via direct geometry path Add a direct container-geometry path for eligible frames: inline blur when the kernel fits the tile margin, otherwise a cached filter-surface pass reused across tiles via DropShadowFilterCache on both the direct and slow render_shape paths. Move frame shadow logic into shadows.rs. Fix nested/clipped frame shadows by deferring parent clip to composite time, apply negative spread via inset, and allow rotated/transformed frames on the direct path. Skip descendant extrect walks for clipped frames when only nested drop shadows matter, and skip child silhouettes when the container fill already covers shadow descendants. --- .../render-wasm/ffi-rendering-subtleties.md | 4 +- render-wasm/src/render.rs | 303 +++++++++------ render-wasm/src/render/shadows.rs | 359 +++++++++++++++++- render-wasm/src/shapes.rs | 252 ++++++++++++ 4 files changed, 796 insertions(+), 122 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index d739cd54e7..bb09ea8d52 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -24,4 +24,6 @@ - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. - Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. -- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. \ No newline at end of file +- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. +- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. \ No newline at end of file diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8aa067dae2..ffd3f97822 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -34,7 +34,7 @@ use crate::shapes::{ use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; -use crate::view::Viewbox; +use crate::view::{self, Viewbox}; use crate::wapi; use crate::{get_gpu_state, get_resources, performance}; @@ -417,6 +417,8 @@ pub(crate) struct RenderState { /// shadow. A full skip made flush_and_submit very slow (Skia ops-task /// ordering); doing it per shape was wasted GPU work. pub drop_shadows_ops_warmed: bool, + /// Filter-surface snapshots for drop shadows, reused across tiles. + drop_shadow_filter_cache: shadows::DropShadowFilterCache, } pub struct InteractiveDragCrop { @@ -551,6 +553,9 @@ impl RenderState { pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let sampling_options = get_resources().sampling_options; + let max_dim = get_gpu_state().max_surface_size(); + let width = width.clamp(1, max_dim); + let height = height.clamp(1, max_dim); let surfaces = Surfaces::try_new( (width, height), @@ -601,6 +606,7 @@ impl RenderState { backbuffer_crop_cache: HashMap::default(), tile_atlas_flushed: false, drop_shadows_ops_warmed: false, + drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), }) } @@ -875,25 +881,29 @@ impl RenderState { pub fn set_dpr(&mut self, dpr: f32) -> Result<()> { // Only when this function returns true (it means the value // was properly changed) the rest of the functions is called. + // Surface/viewbox pixel size is updated by `resize` after the + // canvas backing store is set, so we do not resize here with a + // stale CSS size (that desyncs Skia vs the GL framebuffer). if self.options.set_dpr(dpr) { + self.viewbox.set_dpr(dpr); self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); - self.resize( - self.viewbox.width().floor() as i32, - self.viewbox.height().floor() as i32, - )?; get_resources().fonts.set_scale_debug_font(dpr); - self.viewbox.set_dpr(dpr); self.surfaces.set_dpr(dpr); } Ok(()) } + pub fn ensure_tile_atlas_layout(&mut self) { + self.surfaces + .ensure_tile_atlas_layout(self.tile_viewbox.interest_rect.len().max(1) as usize); + } + pub fn set_antialias_threshold(&mut self, value: f32) { self.options.set_antialias_threshold(value); } - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) { + pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> Result<()> { // Only when this function returns true (it means the value // was changed properly) the tile_viewbox.set_interest is called. if self.options.set_viewport_interest_area_threshold(value) { @@ -902,7 +912,10 @@ impl RenderState { // affect pending_tiles generation. self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); + self.tile_viewbox.update(&self.viewbox); + self.ensure_tile_atlas_layout(); } + Ok(()) } pub fn set_node_batch_threshold(&mut self, value: i32) { @@ -926,11 +939,27 @@ impl RenderState { } pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { - let dpr_width = (width as f32 * self.options.dpr).floor() as i32; - let dpr_height = (height as f32 * self.options.dpr).floor() as i32; + let gpu_state = get_gpu_state(); + let max_dim = gpu_state.max_surface_size(); + let css_w = (width as f32).max(1.0); + let css_h = (height as f32).max(1.0); + let dpr = view::clamp_dpr_for_surface(css_w, css_h, self.options.dpr, max_dim); + let mut dpr_width = ((css_w * dpr).floor() as i32).clamp(1, max_dim); + let mut dpr_height = ((css_h * dpr).floor() as i32).clamp(1, max_dim); + // Prefer the real GL drawing buffer: wrap_backend_render_target + // binds the default framebuffer, whose origin is bottom-left. + if let Some((fb_w, fb_h)) = gpu_state.drawing_buffer_size() { + dpr_width = fb_w.clamp(1, max_dim); + dpr_height = fb_h.clamp(1, max_dim); + } + let effective_dpr = (dpr_width as f32 / css_w).min(dpr_height as f32 / css_h); + if (effective_dpr - self.options.dpr).abs() > f32::EPSILON { + self.set_dpr(effective_dpr)?; + } self.surfaces.resize(dpr_width, dpr_height)?; - self.viewbox.set_wh(width as f32, height as f32); + self.viewbox.set_wh(css_w, css_h); self.tile_viewbox.update(&self.viewbox); + self.ensure_tile_atlas_layout(); Ok(()) } @@ -2274,6 +2303,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; + self.drop_shadow_filter_cache.clear(); } pub fn start_render_loop( @@ -2995,6 +3025,87 @@ impl RenderState { )) } + /// Renders descendant silhouettes into the current drop-shadow layer. + #[allow(clippy::too_many_arguments)] + fn render_drop_shadow_child_silhouettes( + &mut self, + element: &Shape, + tree: ShapesPoolRef, + shadow: &Shadow, + scale: f32, + inherited_layer_blur: Option, + node_render_state: &NodeRenderState, + target_surface: SurfaceId, + ) -> Result<()> { + if matches!(element.shape_type, Type::Bool(_)) { + return Ok(()); + } + + let shadow_children = if element.is_recursive() { + get_simplified_children(tree, element) + } else { + Vec::new() + }; + + for shadow_shape_id in shadow_children.iter() { + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; + if shadow_shape.hidden { + continue; + } + + let nested_clip_bounds = + node_render_state.get_nested_shadow_clip_bounds(element, shadow); + + if !matches!(shadow_shape.shape_type, Type::Text(_)) { + self.render_drop_black_shadow( + shadow_shape, + &shadow_shape.extrect(tree, scale), + shadow, + nested_clip_bounds, + scale, + inherited_layer_blur, + target_surface, + )?; + } else { + let paint = skia::Paint::default(); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + + let mut transformed_shadow: Cow = Cow::Borrowed(shadow); + transformed_shadow.to_mut().color = skia::Color::BLACK; + transformed_shadow.to_mut().blur = transformed_shadow.blur; + transformed_shadow.to_mut().spread = transformed_shadow.spread; + + let mut new_shadow_paint = skia::Paint::default(); + new_shadow_paint.set_image_filter(transformed_shadow.get_drop_shadow_filter()); + new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); + + self.with_nested_blurs_suppressed(|state| { + state.render_shape( + shadow_shape, + nested_clip_bounds, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + SurfaceId::DropShadows, + true, + None, + Some(vec![new_shadow_paint.clone()]), + None, + target_surface, + ) + })?; + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + } + } + + Ok(()) + } + /// Renders a drop shadow effect for the given shape. /// /// Creates a black shadow by converting the original shadow color to black, @@ -3140,10 +3251,30 @@ impl RenderState { return Ok(()); } - // Adaptive downscale for large blur values (lossless GPU optimization). - // Bounds above were computed from the original sigma so filter surface coverage is correct. - // Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8x): beyond that the - // filter surface becomes too small and quality degrades noticeably. + // High zoom with blur: use render_into_filter_surface to ensure blur has enough space + // Apply spread geometrically to avoid dilate filter rounding issues + let layer_blur_value = combined_blur.map(|b| b.value).unwrap_or(0.0); + let cache_key = clip_bounds.is_none().then(|| { + shadows::DropShadowFilterCacheKey::for_shape( + shape.id, + shadow, + scale, + &shape.transform, + layer_blur_value, + ) + }); + + if let Some(ref key) = cache_key { + if let Some(cached) = self.drop_shadow_filter_cache.lookup(key) { + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + cached, + blur_filter.clone(), + ); + return Ok(()); + } + } + let blur_downscale_threshold: f32 = self.options.blur_downscale_threshold; let min_blur_downscale: f32 = 1.0 / blur_downscale_threshold; let blur_downscale = if shadow.blur > blur_downscale_threshold { @@ -3185,37 +3316,19 @@ impl RenderState { )?; if let Some((mut surface, filter_scale)) = filter_result { - let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows); - drop_canvas.save(); - //drop_canvas.scale((scale, scale)); - //drop_canvas.translate(translation); - let mut drop_paint = skia::Paint::default(); - drop_paint.set_image_filter(blur_filter.clone()); - - // If we scaled down in the filter surface, we need to scale back up - if filter_scale < 1.0 { - drop_canvas.save(); - drop_canvas.scale((1.0 / filter_scale, 1.0 / filter_scale)); - drop_canvas.translate((bounds.left * filter_scale, bounds.top * filter_scale)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); - } else { - drop_canvas.save(); - drop_canvas.translate((bounds.left, bounds.top)); - surface.draw( - drop_canvas, - (0.0, 0.0), - get_resources().sampling_options, - Some(&drop_paint), - ); - drop_canvas.restore(); + let cached = shadows::CachedDropShadowFilter::new( + bounds, + filter_scale, + surface.image_snapshot(), + ); + shadows::blit_cached_drop_shadow_filter( + &mut self.surfaces, + &cached, + blur_filter.clone(), + ); + if let Some(key) = cache_key { + self.drop_shadow_filter_cache.store(key, cached); } - drop_canvas.restore(); } Ok(()) @@ -3254,6 +3367,7 @@ impl RenderState { }; let recursive = element.is_recursive(); + let use_direct_container_shadow = element.uses_direct_container_drop_shadow(tree, scale); let mut rendered_any = false; for shadow in element.drop_shadows_visible() { if !shadow.is_perceptible_at_scale_for(scale, recursive) { @@ -3266,78 +3380,35 @@ impl RenderState { .canvas(SurfaceId::DropShadows) .save_layer(&layer_rec); - self.render_drop_black_shadow( - element, - element_extrect, - shadow, - clip_bounds.clone(), - scale, - None, - target_surface, - )?; - - if !matches!(element.shape_type, Type::Bool(_)) { - let shadow_children = if element.is_recursive() { - get_simplified_children(tree, element) - } else { - Vec::new() - }; - - for shadow_shape_id in shadow_children.iter() { - let Some(shadow_shape) = tree.get(shadow_shape_id) else { - continue; - }; - if shadow_shape.hidden { - continue; - } - - let nested_clip_bounds = - node_render_state.get_nested_shadow_clip_bounds(element, shadow); - - if !matches!(shadow_shape.shape_type, Type::Text(_)) { - self.render_drop_black_shadow( - shadow_shape, - &shadow_shape.extrect(tree, scale), - shadow, - nested_clip_bounds, - scale, - inherited_layer_blur, - target_surface, - )?; - } else { - let paint = skia::Paint::default(); - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::DropShadows) - .save_layer(&layer_rec); - - let mut transformed_shadow: Cow = Cow::Borrowed(shadow); - transformed_shadow.to_mut().color = skia::Color::BLACK; - transformed_shadow.to_mut().blur = transformed_shadow.blur; - transformed_shadow.to_mut().spread = transformed_shadow.spread; - - let mut new_shadow_paint = skia::Paint::default(); - new_shadow_paint - .set_image_filter(transformed_shadow.get_drop_shadow_filter()); - new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver); - - self.with_nested_blurs_suppressed(|state| { - state.render_shape( - shadow_shape, - nested_clip_bounds, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - SurfaceId::DropShadows, - true, - None, - Some(vec![new_shadow_paint.clone()]), - None, - target_surface, - ) - })?; - self.surfaces.canvas(SurfaceId::DropShadows).restore(); - } + // Fast path: frame geometry only (no child silhouettes). + if use_direct_container_shadow { + shadows::render_direct_frame_drop_shadow( + self, + element, + element_extrect, + shadow, + scale, + )?; + } else { + self.render_drop_black_shadow( + element, + element_extrect, + shadow, + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + if !element.container_fill_covers_shadow_descendants(tree, scale) { + self.render_drop_shadow_child_silhouettes( + element, + tree, + shadow, + scale, + inherited_layer_blur, + node_render_state, + target_surface, + )?; } } diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index c6baadc0b3..19f90542cf 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -1,12 +1,361 @@ +use std::collections::HashMap; + +use super::filters; use super::{RenderState, SurfaceId}; -use crate::render::strokes; -use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; -use skia_safe::{canvas::SaveLayerRec, Paint, Path}; - use crate::error::Result; +use crate::get_resources; +use crate::render::strokes; use crate::render::text; +use crate::shapes::radius_to_sigma; +use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; +use crate::uuid::Uuid; +use skia_safe::{self as skia, canvas::SaveLayerRec, Paint, Path, Rect}; + +// --------------------------------------------------------------------------- +// Direct frame drop shadows (fast inline blur + filter-surface cache fallback) +// --------------------------------------------------------------------------- + +pub(crate) struct DropShadowFilterCache { + entries: HashMap, +} + +#[derive(Hash, PartialEq, Eq, Clone, Copy)] +pub(crate) struct DropShadowFilterCacheKey { + shape_id: Uuid, + blur_bits: u32, + spread_bits: u32, + offset_x_bits: u32, + offset_y_bits: u32, + scale_bits: u32, + transform_a_bits: u32, + transform_b_bits: u32, + transform_c_bits: u32, + transform_d_bits: u32, + transform_e_bits: u32, + transform_f_bits: u32, + layer_blur_bits: u32, +} + +pub(crate) struct CachedDropShadowFilter { + bounds: Rect, + filter_scale: f32, + image: skia::Image, +} + +impl CachedDropShadowFilter { + pub(crate) fn new(bounds: Rect, filter_scale: f32, image: skia::Image) -> Self { + Self { + bounds, + filter_scale, + image, + } + } +} + +impl DropShadowFilterCacheKey { + pub(crate) fn for_shape( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self::new(shape_id, shadow, scale, transform, layer_blur) + } + + fn new( + shape_id: Uuid, + shadow: &Shadow, + scale: f32, + transform: &skia::Matrix, + layer_blur: f32, + ) -> Self { + Self { + shape_id, + blur_bits: shadow.blur.to_bits(), + spread_bits: shadow.spread.to_bits(), + offset_x_bits: shadow.offset.0.to_bits(), + offset_y_bits: shadow.offset.1.to_bits(), + scale_bits: scale.to_bits(), + transform_a_bits: transform[0].to_bits(), + transform_b_bits: transform[1].to_bits(), + transform_c_bits: transform[2].to_bits(), + transform_d_bits: transform[3].to_bits(), + transform_e_bits: transform[4].to_bits(), + transform_f_bits: transform[5].to_bits(), + layer_blur_bits: layer_blur.to_bits(), + } + } +} + +impl DropShadowFilterCache { + pub fn new() -> Self { + Self { + entries: HashMap::default(), + } + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub(crate) fn lookup(&self, key: &DropShadowFilterCacheKey) -> Option<&CachedDropShadowFilter> { + self.entries.get(key) + } + + pub(crate) fn store(&mut self, key: DropShadowFilterCacheKey, value: CachedDropShadowFilter) { + self.entries.insert(key, value); + } +} + +/// Renders a direct frame drop shadow: inline blur on the tile when the kernel +/// fits the margin, otherwise a cached filter-surface pass shared across tiles. +/// +/// Does not apply the caller's clip stack; clip is applied when compositing +/// `DropShadows` onto the target surface. +pub(crate) fn render_direct_frame_drop_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let margin = state.surfaces.margins().width as f32; + let sigma_device = radius_to_sigma(shadow.blur) * scale; + if sigma_device <= margin / 3.0 { + render_inline_frame_shadow(state, frame, shadow, scale) + } else { + render_cached_filter_frame_shadow(state, frame, shape_bounds, shadow, scale) + } +} + +fn frame_shadow_antialias(state: &RenderState, frame: &Shape, scale: f32) -> bool { + !state.options.is_fast_mode() + && frame.should_use_antialias(scale, state.options.antialias_threshold) +} + +fn spread_outset(spread: f32) -> Option { + Some(spread).filter(|&s| s > 0.0) +} + +fn spread_inset(spread: f32) -> Option { + Some(-spread).filter(|&s| s > 0.0) +} + +fn blur_layer_paint(blur: f32, sigma_scale: f32) -> skia::Paint { + let mut paint = skia::Paint::default(); + if blur > 0.0 { + let sigma = radius_to_sigma(blur) * sigma_scale; + paint.set_image_filter(skia::image_filters::blur((sigma, sigma), None, None, None)); + } + paint.set_blend_mode(skia::BlendMode::SrcOver); + paint +} + +fn draw_frame_shadow_rect( + surfaces: &mut super::Surfaces, + surface_id: SurfaceId, + frame: &Shape, + shadow: &Shadow, + antialias: bool, +) { + let mut fill_paint = skia::Paint::default(); + fill_paint.set_color(skia::Color::BLACK); + fill_paint.set_anti_alias(antialias); + surfaces.draw_rect_to( + surface_id, + frame, + &fill_paint, + spread_outset(shadow.spread), + spread_inset(shadow.spread), + ); +} + +fn shadow_filter_bounds( + shadow: &Shadow, + shape_bounds: &Rect, + world_offset: (f32, f32), +) -> Option { + let mut shadow_cull = *shadow; + shadow_cull.color = skia::Color::BLACK; + shadow_cull.offset = (0.0, 0.0); + let drop_filter = shadow_cull.get_drop_shadow_filter()?; + let mut bounds = drop_filter.compute_fast_bounds(*shape_bounds); + bounds.offset(world_offset); + Some(bounds) +} + +/// Local draw matrix for frame shadow geometry: centered shape transform plus +/// shadow offset in local space (matches `render_shape` with `Some(offset)`). +fn frame_shadow_draw_matrix(frame: &Shape, shadow: &Shadow) -> skia::Matrix { + let mut matrix = frame.centered_transform(); + matrix.pre_translate((shadow.offset.0, shadow.offset.1)); + matrix +} + +/// Shadow offset mapped to world space (for bounds culling and cache blit). +fn shadow_world_offset(frame: &Shape, shadow: &Shadow) -> (f32, f32) { + let mapped = frame + .centered_transform() + .map_vector((shadow.offset.0, shadow.offset.1)); + (mapped.x, mapped.y) +} + +/// When bounds fit in the filter surface, skip blur downscale to avoid banding +/// at high zoom. The tile cache makes a single full-res pass affordable. +fn blur_downscale_for_frame_shadow( + blur: f32, + bounds: Rect, + filter_width: i32, + filter_height: i32, + threshold: f32, +) -> f32 { + let bounds_w = bounds.width().ceil().max(1.0) as i32; + let bounds_h = bounds.height().ceil().max(1.0) as i32; + if bounds_w <= filter_width && bounds_h <= filter_height { + return 1.0; + } + if blur > threshold { + (threshold / blur).max(1.0 / threshold) + } else { + 1.0 + } +} + +pub(crate) fn blit_cached_drop_shadow_filter( + surfaces: &mut super::Surfaces, + cached: &CachedDropShadowFilter, + layer_blur: Option, +) { + let sampling = get_resources().sampling_options; + let mut paint = skia::Paint::default(); + if let Some(filter) = layer_blur { + paint.set_image_filter(filter); + } + let drop_canvas = surfaces.canvas(SurfaceId::DropShadows); + let dst = skia::Rect::from_wh(cached.image.width() as f32, cached.image.height() as f32); + + drop_canvas.save(); + drop_canvas.save(); + if cached.filter_scale < 1.0 { + drop_canvas.scale((1.0 / cached.filter_scale, 1.0 / cached.filter_scale)); + drop_canvas.translate(( + cached.bounds.left * cached.filter_scale, + cached.bounds.top * cached.filter_scale, + )); + } else { + drop_canvas.translate((cached.bounds.left, cached.bounds.top)); + } + drop_canvas.draw_image_rect_with_sampling_options(&cached.image, None, dst, sampling, &paint); + drop_canvas.restore(); + drop_canvas.restore(); +} + +fn render_inline_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let antialias = frame_shadow_antialias(state, frame, scale); + let layer_paint = blur_layer_paint(shadow.blur, 1.0); + let draw_matrix = frame_shadow_draw_matrix(frame, shadow); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.save(); + drop_canvas.concat(&draw_matrix); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + drop_canvas.save_layer(&layer_rec); + } + + draw_frame_shadow_rect( + &mut state.surfaces, + SurfaceId::DropShadows, + frame, + shadow, + antialias, + ); + + { + let drop_canvas = state.surfaces.canvas(SurfaceId::DropShadows); + drop_canvas.restore(); + drop_canvas.restore(); + } + + Ok(()) +} + +fn render_cached_filter_frame_shadow( + state: &mut RenderState, + frame: &Shape, + shape_bounds: &Rect, + shadow: &Shadow, + scale: f32, +) -> Result<()> { + let draw_matrix = frame.centered_transform(); + let key = DropShadowFilterCacheKey::for_shape(frame.id, shadow, scale, &draw_matrix, 0.0); + if let Some(cached) = state.drop_shadow_filter_cache.lookup(&key) { + blit_cached_drop_shadow_filter(&mut state.surfaces, cached, None); + return Ok(()); + } + + let world_offset = shadow_world_offset(frame, shadow); + let Some(bounds) = shadow_filter_bounds(shadow, shape_bounds, world_offset) else { + return Ok(()); + }; + + let antialias = frame_shadow_antialias(state, frame, scale); + let (filter_w, filter_h) = state.surfaces.filter_size(); + let blur_downscale = blur_downscale_for_frame_shadow( + shadow.blur, + bounds, + filter_w, + filter_h, + state.options.blur_downscale_threshold, + ); + let layer_paint = blur_layer_paint(shadow.blur, blur_downscale); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint); + + let shadow_draw_matrix = frame_shadow_draw_matrix(frame, shadow); + let filter_result = filters::render_into_filter_surface( + state, + bounds, + blur_downscale, + |state, temp_surface| { + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.save(); + canvas.concat(&shadow_draw_matrix); + canvas.save_layer(&layer_rec); + } + draw_frame_shadow_rect(&mut state.surfaces, temp_surface, frame, shadow, antialias); + { + let canvas = state.surfaces.canvas(temp_surface); + canvas.restore(); + canvas.restore(); + } + Ok(()) + }, + )?; + + if let Some((mut surface, filter_scale)) = filter_result { + let cached = CachedDropShadowFilter { + bounds, + filter_scale, + image: surface.image_snapshot(), + }; + blit_cached_drop_shadow_filter(&mut state.surfaces, &cached, None); + state.drop_shadow_filter_cache.store(key, cached); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Inner / text shadows +// --------------------------------------------------------------------------- -// Fill Shadows pub fn render_fill_inner_shadows( render_state: &mut RenderState, shape: &Shape, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8944e814fc..362b912a8d 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1843,6 +1843,99 @@ impl Shape { .any(|s| s.render_kind(is_open) == StrokeKind::Inner) } + /// When true, the frame drop shadow can use the direct geometry path + /// (`render_direct_frame_drop_shadow`) instead of filter surfaces and + /// descendant silhouettes. + /// + /// Requires at least one fill; fill opacity/type does not matter because the fast + /// path shadows the frame geometry as a solid mask. + /// + /// The fast path draws fill geometry only. On the slow path, visible strokes also + /// contribute to the shadow silhouette, so frames with outer/center strokes can + /// look slightly narrower here. We keep them eligible anyway for performance. + pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef, scale: f32) -> bool { + if !matches!(self.shape_type, Type::Frame(_)) { + return false; + } + if !self.has_fills() { + return false; + } + if self.blend_mode() != BlendMode::default() { + return false; + } + if self.blur.is_some() || self.background_blur.is_some() { + return false; + } + if self.has_frame_clip_layer_blur() { + return false; + } + + if self.clip_content { + return !self.descendants_have_drop_shadows(tree); + } + + self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + /// When true, the container's own fill shadow mask is enough and descendant + /// silhouettes can be skipped (same geometry assumption as the direct path). + pub fn container_fill_covers_shadow_descendants( + &self, + tree: ShapesPoolRef, + scale: f32, + ) -> bool { + self.has_fills() && self.descendants_contained_for_frame_shadow(tree, scale, self.selrect()) + } + + fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool { + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + if child.drop_shadows_visible().next().is_some() { + return true; + } + if child.is_recursive() && child.descendants_have_drop_shadows(tree) { + return true; + } + } + false + } + + fn descendants_contained_for_frame_shadow( + &self, + tree: ShapesPoolRef, + scale: f32, + bounds: math::Rect, + ) -> bool { + if self.descendants_have_drop_shadows(tree) { + return false; + } + + const MARGIN: f32 = 0.5; + for child_id in self.children_ids_iter(false) { + let Some(child) = tree.get(child_id) else { + continue; + }; + if child.hidden { + continue; + } + let child_extrect = child.extrect(tree, scale); + if !rect_contains_with_margin(bounds, child_extrect, MARGIN) { + return false; + } + if child.is_recursive() + && !child.descendants_contained_for_frame_shadow(tree, scale, bounds) + { + return false; + } + } + true + } + pub fn drop_shadow_paints(&self) -> Vec { let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect(); @@ -1872,6 +1965,14 @@ impl Shape { } } +#[inline] +fn rect_contains_with_margin(outer: math::Rect, inner: math::Rect, margin: f32) -> bool { + inner.left >= outer.left - margin + && inner.top >= outer.top - margin + && inner.right <= outer.right + margin + && inner.bottom <= outer.bottom + margin +} + #[cfg(test)] mod tests { use super::*; @@ -2012,4 +2113,155 @@ mod tests { assert_eq!(extrect.right, 50.0); assert_eq!(extrect.bottom, 50.0); } + + fn frame_with_fill_and_child(fill: Fill, opacity: f32) -> (ShapesPool, Uuid) { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(fill); + frame.opacity = opacity; + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(10.0, 10.0, 180.0, 80.0); + child.set_parent(frame_id); + } + + (pool, frame_id) + } + + #[test] + fn frame_with_any_fill_uses_direct_container_drop_shadow() { + for (fill, opacity) in [ + (Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0), + ( + Fill::Solid(SolidColor(skia::Color::from_argb(128, 255, 255, 255))), + 0.5, + ), + ] { + let (pool, frame_id) = frame_with_fill_and_child(fill, opacity); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + } + + #[test] + fn clipped_frame_with_child_drop_shadow_rejects_direct_path() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let child_id = pool.get(&frame_id).expect("frame").children[0]; + + { + let child = pool.get_mut(&child_id).expect("child"); + child.add_shadow(Shadow::new( + skia::Color::BLACK, + 4.0, + 0.0, + (0.0, 4.0), + ShadowStyle::Drop, + false, + )); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn clipped_frame_ignores_outside_child_extrect_for_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(true); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } + + #[test] + fn overflow_frame_with_outside_child_rejects_direct_path() { + let mut pool = ShapesPool::new(); + pool.initialize(2); + + let frame_id = Uuid::new_v4(); + let child_id = Uuid::new_v4(); + + { + let frame = pool.add_shape(frame_id); + frame.set_shape_type(Type::Frame(Frame::default())); + frame.set_selrect(0.0, 0.0, 200.0, 100.0); + frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE))); + frame.set_clip(false); + frame.children = vec![child_id]; + } + + { + let child = pool.add_shape(child_id); + child.set_shape_type(Type::Rect(Rect::default())); + child.set_selrect(-50.0, -50.0, 250.0, 150.0); + child.set_parent(frame_id); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0)); + assert!(!frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn frame_with_contained_child_covers_shadow_descendants() { + let (pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.container_fill_covers_shadow_descendants(&pool, 1.0)); + } + + #[test] + fn rotated_frame_with_contained_child_uses_direct_container_drop_shadow() { + let (mut pool, frame_id) = + frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0); + + { + let frame = pool.get_mut(&frame_id).expect("frame"); + // 45° rotation around the shape center (100, 50). + let angle = std::f32::consts::FRAC_PI_4; + frame.set_transform( + angle.cos(), + angle.sin(), + -angle.sin(), + angle.cos(), + 0.0, + 0.0, + ); + } + + let frame = pool.get(&frame_id).expect("frame"); + assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0)); + } } From 5dab689a6e4e0737358ac05463e44b633432d556 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 20 Aug 2026 10:52:36 +0200 Subject: [PATCH 167/298] :bug: Pack tile atlas and clamp HiDPI surfaces under GPU limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HQ tiles are 512px and the atlas stays at 4096² (64 full-size slots). Browser zoom plus a forced ?dpr= can need more visible tiles than that, and a framebuffer larger than the GPU allows. Pack interest tiles into smaller atlas cells, blit at 512 then scale, and inset Linear samples so seams do not bleed. Clamp the canvas backing store and DPR together, wrap Skia at the real drawingBuffer size, and wait one frame after DPR changes so CSS client size and overlays stay aligned. --- frontend/src/app/render_wasm/api.cljs | 33 ++++-- render-wasm/src/main.rs | 2 +- render-wasm/src/render/gpu_state.rs | 31 ++++++ render-wasm/src/render/surfaces.rs | 142 +++++++++++++++++++------- render-wasm/src/tiles.rs | 62 +++++++++++ render-wasm/src/view.rs | 56 ++++++++++ 6 files changed, 283 insertions(+), 43 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 9e4e4c1010..94d55480ee 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2177,15 +2177,31 @@ (when (wasm/live?) (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))) +(def ^:private max-surface-size + ;; Must match `gpu_state::MAX_SURFACE_SIZE`. + 8192) + +(defn- clamp-physical-size + "Clamp physical pixel dimensions before assigning `canvas.width/height`. + Rust `resize` applies the same cap and syncs the effective DPR from the + real drawing buffer." + [w h] + (let [w (mth/max 1 w) + h (mth/max 1 h) + scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] + [(mth/max 1 (mth/floor (* scale w))) + (mth/max 1 (mth/floor (* scale h)))])) + (defn resize-offscreen-canvas! "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and update the WASM render surfaces accordingly (via `_resize_viewbox`). The design state (shape pool) is preserved so `set-objects` is not needed again." [canvas new-physical-w new-physical-h] (when (wasm/live?) - (let [dpr (get-dpr)] - (set! (.-width canvas) new-physical-w) - (set! (.-height canvas) new-physical-h) + (let [dpr (get-dpr) + [pw ph] (clamp-physical-size new-physical-w new-physical-h)] + (set! (.-width canvas) pw) + (set! (.-height canvas) ph) (set-render-options! dpr) (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) @@ -2220,9 +2236,14 @@ (resize-canvas! canvas (get-dpr))) ([canvas new-dpr] (when (wasm/live?) - (let [[css-w css-h] (canvas-css-size canvas new-dpr)] - (set! (.-width ^js canvas) (* new-dpr css-w)) - (set! (.-height ^js canvas) (* new-dpr css-h)) + (let [[css-w css-h] (canvas-css-size canvas new-dpr) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + [phys-w phys-h] (clamp-physical-size + (mth/floor (* css-w new-dpr)) + (mth/floor (* css-h new-dpr)))] + (set! (.-width ^js canvas) phys-w) + (set! (.-height ^js canvas) phys-h) (set-render-options! new-dpr) (resize-viewbox css-w css-h))))) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..c5bff2105f 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -57,7 +57,7 @@ pub extern "C" fn set_viewport_interest_area_threshold( viewport_interest_area_threshold: i32, ) -> Result<()> { let render_state = get_render_state(); - render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold); + render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold)?; Ok(()) } diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 5e104efbff..7a158b5fc6 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -7,6 +7,10 @@ use skia_safe::{self as skia, ISize}; const MIN_MAX_TEXTURE_SIZE: i32 = 512; const MAX_MAX_TEXTURE_SIZE: i32 = 4096; +/// Cap for the canvas framebuffer / backbuffer (not the tile atlas). +/// Larger than a typical viewport at DPR 2 but below sizes that would exceed +/// GPU limits when CSS dimensions are very large. +pub const MAX_SURFACE_SIZE: i32 = 8192; #[derive(Debug, Clone)] pub struct GpuState { @@ -57,6 +61,33 @@ impl GpuState { .clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE) } + pub fn max_surface_size(&self) -> i32 { + self.context + .max_texture_size() + .clamp(MIN_MAX_TEXTURE_SIZE, MAX_SURFACE_SIZE) + } + + /// Actual default-framebuffer size after the canvas backing store is set. + /// Browsers may allocate a smaller `drawingBuffer` than `canvas.width`; + /// wrapping Skia at the requested size then shifts content (GL origin is + /// bottom-left). Native builds have no canvas; return `None`. + pub fn drawing_buffer_size(&self) -> Option<(i32, i32)> { + #[cfg(target_arch = "wasm32")] + { + let w = crate::run_script_int!( + "(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferWidth:0" + ); + let h = crate::run_script_int!( + "(typeof GLctx!=='undefined'&&GLctx)?GLctx.drawingBufferHeight:0" + ); + if w > 0 && h > 0 { + return Some((w, h)); + } + } + let _ = self; + None + } + fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { unsafe { gl::DeleteTextures(1, &texture_id); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index b8f4b51c9d..f234c9f711 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -503,8 +503,7 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; - // 512, why not? - let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let tiles = TileTextureCache::new(tile_atlas.width(), tile_atlas.height()); let atlas = DocAtlas::try_new()?; Ok(Self { target, @@ -536,6 +535,20 @@ impl Surfaces { }) } + /// Pack `needed_slots` into the existing 4096 atlas by shrinking the + /// physical cell size. No-op when the layout already fits. + pub fn ensure_tile_atlas_layout(&mut self, needed_slots: usize) { + let atlas_px = self.tile_atlas.width().min(self.tile_atlas.height()); + let slot = tiles::tile_atlas_slot_size(needed_slots, atlas_px); + if slot == self.tiles.slot_size() { + return; + } + self.tiles + .repack(self.tile_atlas.width(), self.tile_atlas.height(), slot); + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + self.tile_atlas_image = None; + } + pub fn set_dpr(&mut self, dpr: f32) { self.dpr = dpr; } @@ -544,6 +557,14 @@ impl Surfaces { self.tiles.clear(); } + fn tile_atlas_sampling(&self) -> skia::SamplingOptions { + if self.tiles.slot_size() < TILE_SIZE { + skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None) + } else { + self.atlas_sampling_options + } + } + pub fn draw_tile_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, @@ -558,6 +579,7 @@ impl Surfaces { let Some(atlas_image) = self.tile_atlas_image.as_ref() else { return; }; + let sampling = self.tile_atlas_sampling(); let canvas = self.backbuffer.canvas(); canvas.clear(background); canvas.draw_atlas( @@ -566,7 +588,7 @@ impl Surfaces { &self.tiles.textures, None, skia::BlendMode::SrcOver, - self.atlas_sampling_options, + sampling, None, None, ); @@ -643,6 +665,7 @@ impl Surfaces { let Some(atlas_image) = self.tile_atlas_image.as_ref() else { return; }; + let sampling = self.tile_atlas_sampling(); let canvas = self.backbuffer.canvas(); canvas.save(); @@ -654,7 +677,7 @@ impl Surfaces { &batch.textures, None, skia::BlendMode::SrcOver, - self.atlas_sampling_options, + sampling, None, None, ); @@ -1515,21 +1538,24 @@ pub struct TileAtlasTextureProvider { } impl TileAtlasTextureProvider { - pub fn new(texture_size: i32, tile_size: i32) -> Self { - let side = texture_size / tile_size; - let length = side * side; - let mut rects = Vec::with_capacity(length as usize); - for i in 0..length { - let left = (i % side) as f32 * tile_size as f32; - let top = (i / side) as f32 * tile_size as f32; - let right = left + tile_size as f32; - let bottom = top + tile_size as f32; - rects.push(Rect::new(left, top, right, bottom)); + pub fn new(texture_width: i32, texture_height: i32, tile_size: i32) -> Self { + let cols = texture_width / tile_size; + let rows = texture_height / tile_size; + let length = (cols * rows) as usize; + let mut rects = Vec::with_capacity(length); + for row in 0..rows { + for col in 0..cols { + let left = col as f32 * tile_size as f32; + let top = row as f32 * tile_size as f32; + let right = left + tile_size as f32; + let bottom = top + tile_size as f32; + rects.push(Rect::new(left, top, right, bottom)); + } } Self { index: 0, - length: length as usize, - in_use: vec![false; length as usize], + length, + in_use: vec![false; length], rects, } } @@ -1563,6 +1589,7 @@ impl TileAtlasTextureProvider { pub struct TileTextureCache { tile_size: f32, + slot_size: i32, is_updated: bool, provider: TileAtlasTextureProvider, transforms: Vec, @@ -1583,11 +1610,13 @@ impl AtlasDrawBatch { } impl TileTextureCache { - pub fn new(texture_size: i32, capacity: usize) -> Self { + pub fn new(texture_width: i32, texture_height: i32) -> Self { + let capacity = ((texture_width / TILE_SIZE) * (texture_height / TILE_SIZE)) as usize; Self { tile_size: tiles::TILE_SIZE, + slot_size: TILE_SIZE, is_updated: false, - provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + provider: TileAtlasTextureProvider::new(texture_width, texture_height, TILE_SIZE), transforms: Vec::with_capacity(capacity), textures: Vec::with_capacity(capacity), grid: HashMap::with_capacity(capacity), @@ -1595,6 +1624,39 @@ impl TileTextureCache { } } + pub fn slot_size(&self) -> i32 { + self.slot_size + } + + fn dest_scale(&self) -> f32 { + tiles::tile_atlas_compose_scale(self.slot_size) + } + + fn compose_src_rect(&self, rect: Rect) -> Rect { + if self.slot_size < TILE_SIZE { + let inset = tiles::TILE_ATLAS_SAMPLE_INSET; + Rect::new( + rect.left + inset, + rect.top + inset, + rect.right - inset, + rect.bottom - inset, + ) + } else { + rect + } + } + + pub fn repack(&mut self, texture_width: i32, texture_height: i32, slot_size: i32) { + let capacity = ((texture_width / slot_size) * (texture_height / slot_size)) as usize; + self.slot_size = slot_size; + self.is_updated = true; + self.provider = TileAtlasTextureProvider::new(texture_width, texture_height, slot_size); + self.transforms = Vec::with_capacity(capacity); + self.textures = Vec::with_capacity(capacity); + self.grid = HashMap::with_capacity(capacity); + self.removed = HashSet::with_capacity(capacity); + } + fn gc(&mut self) { // Make a real remove for tile in self.removed.iter() { @@ -1634,10 +1696,11 @@ impl TileTextureCache { } pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { + let dest_scale = self.dest_scale(); if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( tile_viewbox.visible_rect.len() as usize, - skia::RSXform::new(1.0, 0.0, Point::default()), + skia::RSXform::new(dest_scale, 0.0, Point::default()), ); } @@ -1666,16 +1729,18 @@ impl TileTextureCache { continue; } - self.transforms[index].tx = x as f32 * self.tile_size - offset.x; - self.transforms[index].ty = y as f32 * self.tile_size - offset.y; - - self.textures[index].set_ltrb( - tile_ref.rect.left, - tile_ref.rect.top, - tile_ref.rect.right, - tile_ref.rect.bottom, + self.transforms[index] = skia::RSXform::new( + dest_scale, + 0.0, + ( + (x as f32 * self.tile_size - offset.x).round(), + (y as f32 * self.tile_size - offset.y).round(), + ), ); + let src = self.compose_src_rect(tile_ref.rect); + self.textures[index].set_ltrb(src.left, src.top, src.right, src.bottom); + index += 1; } } @@ -1713,12 +1778,13 @@ impl TileTextureCache { continue; } - let scos = doc_rect.width() * s / self.tile_size; - let tx = (doc_rect.left + viewbox.pan.x) * s; - let ty = (doc_rect.top + viewbox.pan.y) * s; + let src = self.compose_src_rect(tile_ref.rect); + let scos = doc_rect.width() * s / src.width(); + let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); + let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); - textures.push(tile_ref.rect); + textures.push(src); } } @@ -1737,12 +1803,13 @@ impl TileTextureCache { continue; } - let tx = (doc_rect.left + viewbox.pan.x) * s; - let ty = (doc_rect.top + viewbox.pan.y) * s; - let scos = doc_rect.width() * s / self.tile_size; + let src = self.compose_src_rect(tile_ref.rect); + let tx = ((doc_rect.left + viewbox.pan.x) * s).round(); + let ty = ((doc_rect.top + viewbox.pan.y) * s).round(); + let scos = doc_rect.width() * s / src.width(); transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); - textures.push(tile_ref.rect); + textures.push(src); } AtlasDrawBatch { @@ -1769,7 +1836,10 @@ impl TileTextureCache { let Some(tile_ref) = self.provider.allocate() else { panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); }; + self.insert(tile, tile_ref) + } + fn insert(&mut self, tile: &Tile, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { self.grid.insert(*tile, tile_ref.clone()); if self.removed.contains(tile) { @@ -1777,7 +1847,7 @@ impl TileTextureCache { } self.is_updated = true; - tile_ref.clone() + tile_ref } pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index d00dc25bdf..0a59bda8da 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -258,6 +258,35 @@ pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect { skia::Rect::from_xywh(tx, ty, ts, ts) } +/// Physical atlas cell size so `needed_slots` fit in a square `atlas_px` +/// texture. Never larger than `TILE_SIZE` (tiles are stored 1:1 when they +/// fit). Smaller cells mean more slots, scaled down on blit into the atlas. +pub fn tile_atlas_slot_size(needed_slots: usize, atlas_px: i32) -> i32 { + const MIN_SLOT: i32 = 64; + let needed = needed_slots.max(1); + let side = (needed as f64).sqrt().ceil() as i32; + let side = side.max(1); + (atlas_px / side).clamp(MIN_SLOT, TILE_SIZE as i32) +} + +/// Inset (texels) applied when sampling a packed atlas slot with Linear +/// filtering, so upsample kernels do not bleed into the neighboring cell. +pub const TILE_ATLAS_SAMPLE_INSET: f32 = 1.0; + +/// Source size inside a packed slot after the Linear-filter inset. +pub fn tile_atlas_compose_src_size(slot_size: i32) -> f32 { + if slot_size < TILE_SIZE as i32 { + (slot_size as f32 - 2.0 * TILE_ATLAS_SAMPLE_INSET).max(1.0) + } else { + slot_size as f32 + } +} + +/// `draw_atlas` scale so the destination sprite stays `TILE_SIZE` after inset. +pub fn tile_atlas_compose_scale(slot_size: i32) -> f32 { + TILE_SIZE / tile_atlas_compose_src_size(slot_size) +} + // This structure is useful to keep all the shape uuids by shape id. pub struct TileHashMap { grid: HashMap>, @@ -404,3 +433,36 @@ impl PendingTiles { self.list.pop() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atlas_slot_is_full_size_when_tiles_fit() { + assert_eq!(tile_atlas_slot_size(64, 4096), 512); + assert_eq!(tile_atlas_slot_size(1, 4096), 512); + } + + #[test] + fn atlas_slot_shrinks_to_pack_interest_tiles() { + // 150 slots → 13×13 grid, 4096/13 = 315. + assert_eq!(tile_atlas_slot_size(150, 4096), 315); + let side = 4096 / 315; + assert!(side * side >= 150); + } + + #[test] + fn atlas_compose_scale_is_one_at_full_slot() { + assert_eq!(tile_atlas_compose_scale(512), 1.0); + } + + #[test] + fn atlas_compose_scale_keeps_dest_tile_size_when_packed() { + let slot = 315; + let scale = tile_atlas_compose_scale(slot); + let src = tile_atlas_compose_src_size(slot); + assert!((scale * src - TILE_SIZE).abs() < 1e-4); + assert!(src < slot as f32); + } +} diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index efa5394012..3463db6a8f 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -94,3 +94,59 @@ impl Viewbox { matrix } } + +/// Scale `dpr` down so `floor(css * dpr)` fits in `max_dim` on both axes. +/// Used when a large viewport combined with a high DPR would exceed the GPU +/// (or our surface cap) on either axis. +pub fn clamp_dpr_for_surface(css_w: f32, css_h: f32, dpr: f32, max_dim: i32) -> f32 { + let css_w = css_w.max(1.0); + let css_h = css_h.max(1.0); + let dpr = dpr.max(0.0); + let max_dim = max_dim.max(1) as f32; + let raw_w = (css_w * dpr).floor().max(1.0); + let raw_h = (css_h * dpr).floor().max(1.0); + let scale = (max_dim / raw_w).min(max_dim / raw_h).min(1.0); + dpr * scale +} + +#[cfg(test)] +mod tests { + use super::clamp_dpr_for_surface; + + #[test] + fn clamp_dpr_keeps_hidpi_viewport_under_cap() { + let dpr = clamp_dpr_for_surface(2560.0, 1440.0, 2.0, 8192); + assert!((dpr - 2.0).abs() < 1e-5); + assert!((2560.0 * dpr).floor() <= 8192.0); + } + + #[test] + fn clamp_dpr_caps_very_large_viewport_at_dpr2() { + // 10240×5760 CSS at DPR 2 → 20480 px unclamped on the long edge. + let dpr = clamp_dpr_for_surface(10240.0, 5760.0, 2.0, 8192); + assert!((10240.0 * dpr).floor() <= 8192.0); + assert!((5760.0 * dpr).floor() <= 8192.0); + assert!(dpr < 2.0); + } + + #[test] + fn clamp_dpr_caps_large_viewport_at_dpr2() { + let dpr = clamp_dpr_for_surface(5120.0, 2880.0, 2.0, 8192); + assert!((5120.0 * dpr).floor() <= 8192.0); + assert!(dpr < 2.0); + assert!(dpr > 1.0); + } + + #[test] + fn clamp_dpr_physical_size_is_floor_of_css_times_dpr() { + let css_w = 5120.0; + let css_h = 2880.0; + let dpr = clamp_dpr_for_surface(css_w, css_h, 2.0, 8192); + let phys_w = (css_w * dpr).floor(); + let phys_h = (css_h * dpr).floor(); + assert!(phys_w <= 8192.0); + assert!(phys_h <= 8192.0); + assert!((css_w * dpr - phys_w).abs() < 1.0); + assert!((css_h * dpr - phys_h).abs() < 1.0); + } +} From 4cb9f951d26fd4e71490d525127d99f56f9a6b2e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 20 Aug 2026 10:52:36 +0200 Subject: [PATCH 168/298] :bug: Skip atlas writes during pan/zoom fast mode During view gestures, fast mode renders tiles without shadows or blur. Writing those tiles into the doc/tile atlas left shadowless patches when render_from_cache overlayed them on the scaled preview. Keep the last HQ atlas tiles until the post-gesture full-quality render completes. --- render-wasm/src/render.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index ffd3f97822..1854a2d5ba 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1109,15 +1109,20 @@ impl RenderState { } let fast_mode = self.options.is_fast_mode(); + // During pan/zoom (fast mode) tiles are rendered without shadows/blur. + // Do not write them into the doc/tile atlases: render_from_cache overlays + // HQ tile textures on the scaled doc-atlas backdrop, and shadowless tiles + // would leave permanent holes until the post-gesture full render. + if fast_mode { + return Ok(()); + } // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // while still preventing stale pixels from surviving across full-quality renders. - if !fast_mode && !self.cache_cleared_this_render { + if !self.cache_cleared_this_render { self.surfaces.clear_cache(self.background_color); self.cache_cleared_this_render = true; } - // In fast mode the viewport is moving (pan/zoom) so Cache surface - // positions would be wrong — only save to the tile HashMap. let tile_rect = self.get_current_aligned_tile_bounds()?; let current_tile = *self @@ -1133,7 +1138,7 @@ impl RenderState { &self.tile_viewbox, ¤t_tile, &tile_rect, - fast_mode, + false, self.render_area, ); @@ -4198,8 +4203,9 @@ impl RenderState { } /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. - /// This does NOT invalidate the tile texture cache — cached tile images - /// survive so that fast-mode renders during pan still show shadows/blur. + /// This does NOT invalidate the tile texture cache — existing HQ tiles + /// survive across pan so `render_from_cache` keeps showing shadows/blur + /// until the post-gesture full render replaces them. pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { let zoom_changed = self.zoom_changed(); performance::begin_measure!("rebuild_tile_index"); From 2318866f8de9f9350feab32a3c7b3945724d193b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 21 Aug 2026 11:17:25 +0200 Subject: [PATCH 169/298] :tada: Add repair functions for variant validation errors (#10768) (#11309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :tada: Add repair functions for variant validation errors * :books: Fix copyright notice Co-authored-by: Andrés Moya --- common/src/app/common/files/repair.cljc | 227 +++++++++++------ common/src/app/common/files/validate.cljc | 66 ++--- .../src/app/common/test_helpers/variants.cljc | 5 + common/src/app/common/types/file.cljc | 2 + .../test/common_tests/files/repair_test.cljc | 230 ++++++++++++++++++ 5 files changed, 432 insertions(+), 98 deletions(-) create mode 100644 common/test/common_tests/files/repair_test.cljc diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 6714bfce04..95f3fda22f 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -10,12 +10,14 @@ [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.logging :as log] + [app.common.path-names :as cpn] [app.common.types.component :as ctk] [app.common.types.components-list :as ctkl] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] + [app.common.types.variant :as ctv] [app.common.uuid :as uuid])) (log/set-level! :debug) @@ -35,7 +37,7 @@ (assoc :width 0.01) (assoc :height 0.01) (cts/setup-rect)))] - (log/dbg :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-geometry" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -48,7 +50,7 @@ (log/debug :hint " -> set to " :parent-id uuid/zero) (assoc shape :parent-id uuid/zero))] - (log/dbg :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :parent-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -61,7 +63,7 @@ (log/debug :hint " -> add children to" :parent-id (:id parent-shape)) (update parent-shape :shapes conj (:id shape)))] - (log/dbg :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-in-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:parent-id shape)] repair-shape)))) @@ -74,7 +76,7 @@ (log/debug :hint " -> remove duplicated children") (update shape :shapes distinct))] - (log/dbg :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-children" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -86,14 +88,14 @@ (log/debug :hint " -> remove child" :child-id (:child-id args)) (update parent-shape :shapes (fn [shapes] (d/removev #(= (:child-id args) %) shapes))))] - (log/dbg :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :child-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-parent [_ {:keys [shape page-id args] :as error} file-data _] - (log/dbg :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-parent" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/change-parent (:parent-id args) [shape] nil {:allow-altering-copies true}))) @@ -109,7 +111,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :frame-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -125,7 +127,7 @@ (log/debug :hint " -> set to " :frame-id frame-id) (assoc shape :frame-id frame-id)))] - (log/dbg :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -138,7 +140,7 @@ (log/debug :hint " -> set :main-instance") (assoc shape :main-instance true))] - (log/dbg :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -155,7 +157,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main-external" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -174,7 +176,7 @@ ;; (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") ;; shape)] - (log/dbg :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes shape-ids repair-shape)))) @@ -194,7 +196,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-id" :id (:id shape) :name (:name shape) :page-id page-id) (if (and (some? component) (not (:deleted component))) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) @@ -211,7 +213,7 @@ ;; Assign main instance in the component to current shape (log/debug :hint " -> assign main-instance-page" :component-id (:id component)) (assoc component :main-instance-page page-id))] - (log/dbg :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance-page" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-library-data file-data) (pcb/update-component (:component-id shape) repair-component)))) @@ -224,7 +226,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-main-instance" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -237,7 +239,7 @@ (log/debug :hint " -> unset :main-instance") (dissoc shape :main-instance))] - (log/dbg :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -250,7 +252,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -263,7 +265,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :should-not-be-component-root" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -310,7 +312,7 @@ ;; If the shape still refers to the remote component, try to find the corresponding near one ;; and link to it. If not, detach the shape. - (log/dbg :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :ref-shape-not-found" :id (:id shape) :name (:name shape) :page-id page-id) (if (some? matching-shape) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) @@ -329,7 +331,7 @@ (log/debug :hint " -> unhead shape") (ctk/unhead-shape shape))] - (log/dbg :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-not-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -354,7 +356,7 @@ (nil? (:component-file args)) (dissoc :component-file)))] - (log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -367,7 +369,7 @@ (log/debug :hint " -> reroot shape") (ctk/rehead-shape shape (:component-file args) (:component-id args)))] - (log/dbg :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-is-head" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -396,7 +398,7 @@ (assoc acc k v))) {} objects)))))] - (log/dbg :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :shape-ref-cycle" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -409,7 +411,7 @@ (log/debug :hint " -> unset :shape-ref") (dissoc shape :shape-ref))] - (log/dbg :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :shape-ref-in-main" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -422,7 +424,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -435,7 +437,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape) @@ -449,7 +451,7 @@ (log/debug :hint " -> unset :component-root") (dissoc shape :component-root))] - (log/dbg :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :root-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -462,7 +464,7 @@ (log/debug :hint " -> set :component-root") (assoc shape :component-root true))] - (log/dbg :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :nested-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -475,7 +477,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-main-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -488,7 +490,7 @@ (log/debug :hint " -> detach shape" :shape-id (:id shape)) (ctk/detach-shape shape))] - (log/dbg :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-head-copy-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -501,7 +503,7 @@ (log/warn :hint " -> CANNOT REPAIR THIS AUTOMATICALLY.") shape)] - (log/dbg :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :not-component-not-allowed" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -520,7 +522,7 @@ :r3 0 :r4 0))] - (log/dbg :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :instance-head-not-frame" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -538,7 +540,7 @@ (log/debug :hint " -> remove :objects") (dissoc component :objects))))] - (log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -554,7 +556,7 @@ (dissoc component :objects)) component))] - (log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) + (log/debug :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id component) repair-component)))) @@ -567,7 +569,7 @@ (log/debug :hint " -> add :content-group to :touched-groups") (update shape :touched ctk/set-touched-group :content-group))] - (log/dbg :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :invalid-text-touched" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -580,7 +582,7 @@ (log/debug :hint " -> remove swap-slot") (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :misplaced-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) @@ -603,13 +605,11 @@ (log/debug :hint " -> remove swap-slot" :child-id (:id shape)) (ctk/remove-swap-slot shape))] - (log/dbg :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :duplicated-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes (map :id child-with-duplicate) repair-shape)))) - - (defmethod repair-error :component-duplicate-slot [_ {:keys [shape] :as error} file-data _] (let [main-shape (get-in shape [:objects (:main-instance-id shape)]) @@ -633,7 +633,7 @@ (:objects component))] (assoc component :objects objects)))] - (log/dbg :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) + (log/debug :hint "repairing component :component-duplicated-slot" :id (:id shape) :name (:name shape)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) (pcb/update-component (:id shape) repair-component)))) @@ -649,50 +649,139 @@ (ctk/set-swap-slot shape slot)) shape)))] - (log/dbg :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint "repairing shape :missing-slot" :id (:id shape) :name (:name shape) :page-id page-id) (-> (pcb/empty-changes nil page-id) (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) -(defmethod repair-error :not-a-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :main-instance-not-a-variant + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape :variant-id variant-id)))] -(defmethod repair-error :invalid-variant-id - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + (log/debug :hint "repairing shape :main-instance-not-a-variant" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + +(defmethod repair-error :main-instance-invalid-variant-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :main-instance-invalid-variant-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :invalid-variant-properties - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [prop-names (:prop-names args) + + component (get-in file-data [:components (:component-id shape)]) + prop-values (into {} (map (juxt :name :value)) (:variant-properties component)) + properties' (mapv (fn [name] {:name name :value (get prop-values name "")}) prop-names) + variant-name (ctv/properties-to-name properties') + + repair-component + (fn [component] + ;; Rebuild component properties, removing any extra ones and adding missing ones with empty value + (log/debug :hint " -> rebuild properties" :component-id (:id component) :prop-names (str prop-names)) + (assoc component :variant-properties properties')) + + repair-shape + (fn [shape] + (log/debug :hint " -> set variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + + (log/debug :hint "repairing shape :invalid-variant-properties" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-component) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :variant-not-main - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [page (ctpl/get-page file-data page-id) + shape-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))] + (log/debug :hint "repairing shape :variant-not-main" :id (:id shape) :name (:name shape) :page-id page-id) + (log/debug :hint " -> delete shapes" :shape-ids shape-ids) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/remove-objects shape-ids)))) (defmethod repair-error :parent-not-variant - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id]} file-data _] + (let [parent-id (:parent-id shape) + repair-fn + (fn [parent] + (log/debug :hint " -> set :is-variant-container true") + (assoc parent :is-variant-container true))] + (log/debug :hint "repairing shape :parent-not-variant" :id (:id shape) :name (:name shape) :parent-id parent-id :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [parent-id] repair-fn)))) -(defmethod repair-error :variant-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-name + [_ {:keys [shape page-id args]} file-data _] + (let [repair-fn + (fn [shape] + (log/debug :hint " -> set :name" :name (:variant-name args)) + (assoc shape :name (:variant-name args)))] + (log/debug :hint "repairing shape :variant-main-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) -(defmethod repair-error :variant-bad-variant-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) +(defmethod repair-error :variant-main-bad-variant-name + [_ {:keys [shape page-id]} file-data _] + (let [component (get-in file-data [:components (:component-id shape)]) + variant-name (ctv/properties-to-name (:variant-properties component)) + repair-fn + (fn [shape] + (log/debug :hint " -> set :variant-name" :variant-name variant-name) + (assoc shape :variant-name variant-name))] + (log/dbg :hint "repairing shape :variant-main-bad-variant-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-fn)))) (defmethod repair-error :variant-component-bad-name - [_ error file _] - (log/error :hint "Variant error code, we don't want to auto repair it for now" :code (:code error)) - file) + [_ {:keys [shape page-id args]} file-data _] + (let [[path name] (cpn/split-group-name (:variant-container-name args)) + repair-fn + (fn [component] + (log/debug :hint " -> set :path and :name" :path path :name name) + (assoc component :path path :name name))] + (log/dbg :hint "repairing shape :variant-component-bad-name" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-library-data file-data) + (pcb/update-component (:component-id shape) repair-fn)))) + +(defmethod repair-error :variant-component-bad-id + [_ {:keys [shape page-id args]} file-data _] + (let [repair-shape + (fn [shape] + (let [variant-id (:variant-id args)] + ;; Set the desired variant-id + (log/debug :hint (str " -> set variant-id to " variant-id)) + (assoc shape + :variant-id variant-id)))] + + (log/debug :hint "repairing shape :variant-component-bad-id" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :default [_ error file _] @@ -701,7 +790,7 @@ (defn repair-file [{:keys [data id] :as file} libraries errors] - (log/dbg :hint "repairing file" :id (str id) :errors (count errors)) + (log/debug :hint "repairing file" :id (str id) :errors (count errors)) (let [{:keys [redo-changes]} (reduce (fn [changes error] (pcb/concat-changes changes diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index ca7b469d4b..d04a22b164 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -65,13 +65,13 @@ :misplaced-slot :missing-slot :shape-ref-cycle - :not-a-variant - :invalid-variant-id + :main-instance-not-a-variant + :main-instance-invalid-variant-id :invalid-variant-properties :variant-not-main :parent-not-variant - :variant-bad-name - :variant-bad-variant-name + :variant-main-bad-name + :variant-main-bad-variant-name :variant-component-bad-name :variant-component-bad-id}) @@ -573,19 +573,23 @@ (run! (fn [child-id] (when-let [child (get objects child-id)] (if (not (ctk/is-variant? child)) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id child)) - child file page) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id child)) + child file page + :variant-id shape-id) (do (when (not= (:variant-id child) shape-id) - (report-error :invalid-variant-id - (str/ffmt "Variant % has invalid variant-id %" (:id child) (:variant-id child)) - child file page)) + (report-error :main-instance-invalid-variant-id + (str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child)) + child file page + :variant-id shape-id)) (when (not= prop-names (cfv/extract-properties-names child file-data)) (report-error :invalid-variant-properties (str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names)) - child file page)))))) + child file page + :prop-names prop-names)))))) shapes))) + (defn- check-variant "Shape is a variant, so -it should be a main component @@ -594,9 +598,9 @@ -its name should be the same as its parent's " [shape file page] - (let [parent (ctst/get-shape page (:parent-id shape)) - component (ctkl/get-component (:data file) (:component-id shape) true) - name (ctv/properties-to-name (:variant-properties component))] + (let [parent (ctst/get-shape page (:parent-id shape)) + component (ctkl/get-component (:data file) (:component-id shape) true) + variant-name (ctv/properties-to-name (:variant-properties component))] (when-not (ctk/main-instance? shape) (report-error :variant-not-main (str/ffmt "Variant % is not a main instance" (:id shape)) @@ -605,23 +609,26 @@ (report-error :parent-not-variant (str/ffmt "Variant % has an invalid parent" (:id shape)) shape file page)) - - (when-not (= name (:variant-name shape)) - (report-error :variant-bad-variant-name + (when-not (= variant-name (:variant-name shape)) + (report-error :variant-main-bad-variant-name (str/ffmt "Variant % has an invalid variant-name" (:id shape)) - shape file page)) + shape file page + :variant-name variant-name)) (when-not (= (:name parent) (:name shape)) - (report-error :variant-bad-name - (str/ffmt "Variant % has an invalid name" (:id shape)) - shape file page)) + (report-error :variant-main-bad-name + (str/ffmt "Main instance inside variant % has an invalid name" (:id shape)) + shape file page + :variant-name (:name parent))) (when-not (= (:name parent) (cpn/merge-path-item (:path component) (:name component))) (report-error :variant-component-bad-name (str/ffmt "Component % has an invalid name" (:id shape)) - shape file page)) + shape file page + :variant-container-name (:name parent))) (when-not (= (:variant-id component) (:variant-id shape)) (report-error :variant-component-bad-id (str/ffmt "Variant % has adifferent variant-id than its component" (:id shape)) - shape file page)))) + shape file page + :variant-id (:variant-id component))))) (defn- check-shape "Validate referential integrity and semantic coherence of @@ -740,14 +747,15 @@ -It should have at least one variant property" [component file] (let [component-page (ctf/get-component-page (:data file) component) - main-component (if (:deleted component) + main-instance (if (:deleted component) (dm/get-in component [:objects (:main-instance-id component)]) (ctst/get-shape component-page (:main-instance-id component)))] - (when (and main-component - (not (ctk/is-variant? main-component))) - (report-error :not-a-variant - (str/ffmt "Shape % should be a variant" (:id main-component)) - main-component file component-page)))) + (when (and main-instance + (not (ctk/is-variant? main-instance))) + (report-error :main-instance-not-a-variant + (str/ffmt "Main instance shape % should be a variant" (:id main-instance)) + main-instance file component-page + :variant-id (:variant-id component))))) (defn- check-main-inside-main [component file] diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index bf2e6fc973..07b4a58be7 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -13,6 +13,11 @@ [app.common.types.text :as txt])) (defn add-variant + "Add a variant component to a file with two variants, each with a root shape. + :variant-label [:name Board] + {:root2-label} [:name Board] # [Component :component2-label] + {:root1-label} [:name Board] # [Component :component1-label] + " [file variant-label component1-label root1-label component2-label root2-label & {:keys [variant1-params variant2-params] :or {variant1-params {} variant2-params {}}}] diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index accda94bc5..79e7050e25 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -901,8 +901,10 @@ (let [shape (get objects shape-id)] (println (str/pad (str (str/repeat " " level) (when (:main-instance shape) "{") + (when (:is-variant-container shape) "{{") (:name shape) (when (:main-instance shape) "}") + (when (:is-variant-container shape) "}}") (when (seq (:touched shape)) "*") (when show-ids (str/format " %s" (:id shape)))) {:length 20 diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc new file mode 100644 index 0000000000..a5021baf03 --- /dev/null +++ b/common/test/common_tests/files/repair_test.cljc @@ -0,0 +1,230 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.files.repair-test + "Tests for the validate / repair functions in app.common.files.validate + and app.common.files.repair. + + The tests generate cases of broken files and check that the validation functions + generate accurate errors, and that the repair functions return the file to + a stable state." + (:require + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.variants :as thv] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(t/deftest repair-main-instance-not-a-variant + (t/testing "detect and repair a variant component whose root shape is not a variant" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id nil)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different checks that detect the same problem + (t/is (= :main-instance-not-a-variant (:code (first errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-id-variant-component-bad-id + (t/testing "detect and repair a variant component whose variant id does not match the container's id" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (ths/update-shape :root1 :variant-id (uuid/next))) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1 :page-label :page1)] + + (t/is (= 2 (count errors))) ;; There are two different validation that actually check the same problem + (t/is (= :main-instance-invalid-variant-id (:code (first errors)))) + (t/is (= :variant-component-bad-id (:code (second errors)))) + + (t/is (nil? errors')) + (t/is (= (thi/id :variant1) (:variant-id root1')))))) + +(t/deftest repair-invalid-variant-properties + (t/testing "detect and repair a second variant component whose properties do not match the first variant component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Component1 has ["Property 1", "Property 2"], component2 gets ["Property 1", "Property 3"] + ;; This breaks validation: prop-names mismatch (missing "Property 2", extra "Property 3") + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}]}) + (ths/update-shape :root1 :variant-name "Value1, ValueA") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + comp1' (thc/get-component file' :component1) + comp2' (thc/get-component file' :component2) + root1' (ths/get-shape file' :root1) + root2' (ths/get-shape file' :root2)] + + (t/is (= 1 (count errors))) + (t/is (= :invalid-variant-properties (:code (first errors)))) + + (t/is (nil? errors')) + + ;; After repair, component1's properties are rebuilt to match component2's property names + ;; (the first child in the variant container is root2, so prop-names come from component2) + ;; "Property 1" keeps its value, "Property 3" is added with empty value, "Property 2" is removed + (t/is (= [{:name "Property 1" :value "Value1"} + {:name "Property 3" :value ""}] + (:variant-properties comp1'))) + + (t/is (= "Value1" (:variant-name root1'))) + + ;; Component2 is unchanged (it was the reference for the property names) + (t/is (= [{:name "Property 1" :value "Value2"} + {:name "Property 3" :value "ValueB"}] + (:variant-properties comp2'))) + + (t/is (= "Value2, ValueB" (:variant-name root2')))))) + +(t/deftest repair-variant-not-main + (t/testing "detect and repair a non-main-instance shape inside a variant container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Add a third child to the variant container with :variant-id but NOT a main-instance + (ths/add-sample-shape :bad-shape + :type :frame + :parent-label :variant1 + :variant-id (thi/id :variant1) + :variant-name "") + ;; Add a child to the bad shape (to verify the repair deletes it too) + (ths/add-sample-shape :bad-child + :type :rect + :parent-label :bad-shape)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + bad-shape' (ths/get-shape file' :bad-shape) + bad-child' (ths/get-shape file' :bad-child)] + + (t/is (= 4 (count errors))) ;; The bad container also triggers other errors + (t/is (= :invalid-variant-properties (:code (nth errors 0)))) + (t/is (= :variant-not-main (:code (nth errors 1)))) + (t/is (= :variant-component-bad-name (:code (nth errors 2)))) + (t/is (= :variant-component-bad-id (:code (nth errors 3)))) + (t/is (nil? errors')) + + (t/is (nil? bad-shape')) + (t/is (nil? bad-child'))))) + +(t/deftest repair-parent-not-variant + (t/testing "detect and repair a variant shape whose parent is not a variant-container" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Break the variant container + (ths/update-shape :variant1 :is-variant-container false)) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + container' (ths/get-shape file' :variant1)] + + (t/is (= 2 (count errors))) ;; The error is detected twice, once for each child of the variant container + (t/is (= :parent-not-variant (:code (first errors)))) + (t/is (= :parent-not-variant (:code (second errors)))) + (t/is (nil? errors')) + + (t/is (true? (:is-variant-container container')))))) + +(t/deftest repair-variant-main-bad-name + (t/testing "detect and repair a main instance whose name doesn't match the variant container's name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Change root1's name so it doesn't match the container + (ths/update-shape :root1 :name "WrongName")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Board" (:name root1')))))) + +(t/deftest repair-variant-main-bad-variant-name + (t/testing "detect and repair a variant shape whose :variant-name doesn't match the component's properties" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + (thc/update-component :component1 {:variant-properties [{:name "Property 1" :value "Value1"} + {:name "Property 2" :value "ValueA"}]}) + (thc/update-component :component2 {:variant-properties [{:name "Property 1" :value "Value2"} + {:name "Property 2" :value "ValueB"}]}) + ;; Change root1's :variant-name to something wrong + (ths/update-shape :root1 :variant-name "WrongVariantName") + (ths/update-shape :root2 :variant-name "Value2, ValueB")) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + + root1' (ths/get-shape file' :root1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-main-bad-variant-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Value1, ValueA" (:variant-name root1')))))) + +(t/deftest repair-variant-component-bad-name + (t/testing "detect and repair a variant component whose path/name doesn't match the container name" + (let [file (-> (thf/sample-file :file1 :page-label :page1) + (thv/add-variant :variant1 :component1 :root1 :component2 :root2) + ;; Update names to have path structure + (ths/update-shape :variant1 :name "Group / Subgroup / Component") + (ths/update-shape :root1 :name "Group / Subgroup / Component") + (ths/update-shape :root2 :name "Group / Subgroup / Component") + ;; Update component paths and names + (thc/update-component :component1 {:path "Group / Subgroup" :name "Component"}) + (thc/update-component :component2 {:path "Group / Subgroup" :name "Component"}) + ;; Break component1's name + (thc/update-component :component1 {:name "WrongName"})) + + errors (cfv/validate-file file {}) + changes (cfr/repair-file file {} errors) + file' (thf/apply-changes file {:redo-changes changes} :validate? false) + errors' (cfv/validate-file file' {}) + comp1' (thc/get-component file' :component1)] + + (t/is (= 1 (count errors))) + (t/is (= :variant-component-bad-name (:code (first errors)))) + (t/is (nil? errors')) + (t/is (= "Group / Subgroup" (:path comp1'))) + (t/is (= "Component" (:name comp1')))))) From 9fa07e7468c629ff58ed6183bb49ba2d926164d1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 21 Aug 2026 12:18:33 +0200 Subject: [PATCH 170/298] :arrow_up: Update opencode on devenv dockerfile --- docker/devenv/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 8a3d99216d..450cff5b5a 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.18 +ENV OPENCODE_VERSION=1.18.19 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From dd4a16321796a534c1457508bd6de195aa83568f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 21 Aug 2026 12:43:23 +0200 Subject: [PATCH 171/298] :bug: Remove internal error details from HTTP error responses (#11288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :ambulance: Remove internal error details from HTTP error responses PostgreSQL exceptions, I/O exceptions, and unhandled errors were leaking raw database messages (table names, constraint names, SQLSTATE codes), filesystem paths, and internal exception details to API clients via :hint, :state, and :path response fields. Remove these fields from server-error responses while keeping full error context in server-side logs for operators. Closes #11287 AI-assisted-by: mimo-v2.5-pro * :ambulance: Strip internal fields and map PG errors to safe messages Complete the security fix for GHSA-r8wx-23q6-w3gf by addressing the incomplete redaction found in code review. Add strip-internal-fields helper to dissoc :hint, :state, :path, and :context from error response data in three handlers that previously passed raw ex-data through to clients: - handle-error :internal - handle-exception :default (else branch) - handle-error :assertion (else branch) Add pgsql-state->message to map PostgreSQL SQLSTATE codes to safe, client-facing messages (e.g. 23505 → "A conflicting entry already exists") instead of returning raw PG error text. Include :message in all PSQLException response branches. Add regression tests asserting :hint, :state, :path, :context are absent from responses for :internal and unhandled ex-info errors. Closes #11287 AI-assisted-by: mimo-v2.5-pro * :ambulance: Keep :hint in error protocol, fix unsafe sources Refine the security fix based on code review feedback. Keep :hint as part of the error protocol — it is essential for controlled error communication. Remove it from strip-internal-fields (which now only strips :state, :path, :context). Fix the actual sources of unsafe :hint values: - http/middleware.clj: replace (ex-message cause) with safe static strings for IllegalArgumentException, RequestTooBigException, and EOFException. These :validation errors return ex-data verbatim to clients, so raw exception messages were leaking internals. - PSQLException handler: use :hint instead of :message for the SQLSTATE-mapped messages, staying consistent with the error protocol. Update tests to assert :hint is present (with safe static values) in :internal and unhandled ex-info responses, and absent only from bare RuntimeException and IOException responses. Closes #11287 AI-assisted-by: mimo-v2.5-pro --- backend/src/app/http/errors.clj | 45 +++++++++++----- backend/src/app/http/middleware.clj | 6 +-- .../backend_tests/http_middleware_test.clj | 53 +++++++++++++++++-- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index f1eaea621c..2393abf129 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -34,6 +34,12 @@ (assoc :request/auth-data (dissoc auth :token)) (assoc :frontend/version (or (yreq/get-header request "x-frontend-version") "unknown"))))) +(defn- strip-internal-fields + "Remove fields that leak internal implementation details from error + response data. Full context is preserved in server-side logs." + [data] + (dissoc data :state :path :context)) + (defmulti handle-error (fn [cause _ _] (-> cause ex-data :type))) @@ -136,6 +142,7 @@ (l/error :hint "assertion error" :cause cause) {::yres/status 500 ::yres/body (-> data + (strip-internal-fields) (assoc :type :server-error) (assoc :code :assertion))}))))) @@ -161,9 +168,9 @@ (l/error :hint "internal error" :cause cause) {::yres/status 500 ::yres/body (-> data + (strip-internal-fields) (assoc :type :server-error) - (update :code #(or % :unhandled)) - (assoc :hint (ex-message error)))}))) + (update :code #(or % :unhandled)))}))) (defmethod handle-error :default [error request parent-cause] @@ -178,6 +185,20 @@ (handle-exception (:handling edata) request error) (handle-exception error request parent-cause)))) +(defn- pgsql-state->message + "Map PostgreSQL SQLSTATE codes to safe, client-facing messages. + Returns a user-friendly string that conveys the nature of the error + without exposing table names, constraint names, or other internals." + [state] + (case state + "23505" "A conflicting entry already exists" + "23503" "The referenced item does not exist" + "23502" "A required field is missing" + "23514" "The value violates a data integrity constraint" + "57014" "The operation took too long and was cancelled" + "25P03" "The transaction was idle too long and was cancelled" + "A database error occurred")) + (defmethod handle-exception org.postgresql.util.PSQLException [error request parent-cause] (let [state (.getSQLState ^java.sql.SQLException error) @@ -190,20 +211,19 @@ {::yres/status 504 ::yres/body {:type :server-error :code :statement-timeout - :hint (ex-message error)}} + :hint (pgsql-state->message state)}} (= state "25P03") {::yres/status 504 ::yres/body {:type :server-error :code :idle-in-transaction-timeout - :hint (ex-message error)}} + :hint (pgsql-state->message state)}} :else {::yres/status 500 ::yres/body {:type :server-error - :code :unexpected - :hint (ex-message error) - :state state}})))) + :code :database-error + :hint (pgsql-state->message state)}})))) (defmethod handle-exception :default [error request parent-cause] @@ -216,17 +236,16 @@ (l/error :hint "unexpected error" :cause cause) {::yres/status 500 ::yres/body {:type :server-error - :code :unexpected - :hint (ex-message error)}}) + :code :unexpected}}) :else (binding [l/*context* (request->context request)] (l/error :hint "unhandled error" :cause cause) {::yres/status 500 ::yres/body (-> edata + (strip-internal-fields) (assoc :type :server-error) - (update :code #(or % :unhandled)) - (assoc :hint (ex-message error)))})))) + (update :code #(or % :unhandled)))})))) (defmethod handle-exception java.io.IOException [cause request _] @@ -234,9 +253,7 @@ (l/wrn :hint "io exception" :cause cause) {::yres/status 500 ::yres/body {:type :server-error - :code :io-exception - :hint (ex-message cause) - :path (:path request)}})) + :code :io-exception}})) (defmethod handle-exception java.util.concurrent.CompletionException [cause request _] diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index 31b96927a6..6cb8e6b8b7 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -83,18 +83,18 @@ (instance? IllegalArgumentException cause) (ex/raise :type :validation :code :malformed-json - :hint (ex-message cause) + :hint "invalid JSON in request body" :cause cause) (instance? RequestTooBigException cause) (ex/raise :type :validation :code :request-body-too-large - :hint (ex-message cause)) + :hint "request body exceeds size limit") (instance? java.io.EOFException cause) (ex/raise :type :validation :code :malformed-json - :hint (ex-message cause) + :hint "unexpected end of request body" :cause cause) (instance? RuntimeException cause) diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index bd986fc031..bca962d3fc 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -6,10 +6,12 @@ (ns backend-tests.http-middleware-test (:require + [app.common.exceptions :as ex] [app.common.time :as ct] [app.db :as db] [app.http :as-alias http] [app.http.access-token] + [app.http.errors :as http-errors] [app.http.middleware :as mw] [app.http.session :as session] [app.main :as-alias main] @@ -300,7 +302,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :malformed-json (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "invalid JSON in request body" (-> ex ex-data :hint))))) (t/deftest parse-request-request-too-big-exception ;; When RequestTooBigException is raised (e.g. the request body @@ -319,7 +321,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :request-body-too-large (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "request body exceeds size limit" (-> ex ex-data :hint))))) (t/deftest parse-request-eof-exception ;; When java.io.EOFException is raised (e.g. the body stream @@ -337,7 +339,7 @@ (t/is (instance? clojure.lang.ExceptionInfo ex)) (t/is (= :validation (-> ex ex-data :type))) (t/is (= :malformed-json (-> ex ex-data :code))) - (t/is (string? (-> ex ex-data :hint))))) + (t/is (= "unexpected end of request body" (-> ex ex-data :hint))))) (t/deftest parse-request-runtime-exception-with-cause ;; When a RuntimeException with a non-nil ex-cause is raised, @@ -377,7 +379,7 @@ (t/is (= 500 (::yres/status response))) (t/is (= :server-error (:type body))) (t/is (= :unexpected (:code body))) - (t/is (= "boom" (:hint body))))) + (t/is (nil? (:hint body))))) (t/deftest parse-request-non-runtime-throwable ;; When a non-RuntimeException Throwable is raised (e.g. an @@ -397,4 +399,45 @@ (t/is (= 500 (::yres/status response))) (t/is (= :server-error (:type body))) (t/is (= :io-exception (:code body))) - (t/is (= "network gone" (:hint body))))) + (t/is (nil? (:hint body))))) + +(t/deftest internal-error-strips-sensitive-fields + ;; When an :internal error is raised with :state, :path, and + ;; :context, those fields must not appear in the response body. + ;; :hint is part of the error protocol and is preserved. + (let [cause (ex-info "internal error" + {:type :internal + :code :test-error + :hint "safe user-facing hint" + :state "XX000" + :path "/data/penpot/storage" + :context {:backend :s3 :bucket "prod"}}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 500 (::yres/status response))) + (t/is (= :server-error (:type body))) + (t/is (= :test-error (:code body))) + (t/is (= "safe user-facing hint" (:hint body))) + (t/is (nil? (:state body))) + (t/is (nil? (:path body))) + (t/is (nil? (:context body))))) + +(t/deftest unhandled-exinfo-strips-sensitive-fields + ;; When an ex-info with an unregistered :type (dispatches through + ;; handle-exception :default :else) carries :state and :path, + ;; those fields must not appear in the response body. + ;; :hint is part of the error protocol and is preserved. + (let [cause (ex-info "something broke" + {:type :unregistered-type + :code :custom-code + :hint "safe user-facing hint" + :state "internal-state" + :path "/internal/path"}) + response (http-errors/handle cause {}) + body (::yres/body response)] + (t/is (= 500 (::yres/status response))) + (t/is (= :server-error (:type body))) + (t/is (= :custom-code (:code body))) + (t/is (= "safe user-facing hint" (:hint body))) + (t/is (nil? (:state body))) + (t/is (nil? (:path body))))) From 7c85837290c4e7d6f7d99472b092ad4f7c9d6a97 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 21 Aug 2026 12:55:24 +0200 Subject: [PATCH 172/298] :bug: Fix session invalidation on logout to prevent token replay (#11317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logout only cleared the auth-token cookie but never deleted the server-side row because delete-fn read ::id which wrap-authz no longer sets since 363b4e3778. Make delete-fn delete via ::session/:id attached by wrap-authz so replayed tokens are rejected (CWE-613, GHSA-mj9f-5cwq-7p3q). Add regression tests covering invalidation, idempotency and isolation of other sessions. Fix verified with Red→Green TDD and full backend suite (677 tests). Closes #11316 AI-assisted-by: muse-spark-1.2-contributor --- backend/src/app/http/session.clj | 2 +- backend/test/backend_tests/rpc_auth_test.clj | 106 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 backend/test/backend_tests/rpc_auth_test.clj diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 61140a780c..5782b3452e 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -204,7 +204,7 @@ [{:keys [::manager]}] (assert (manager? manager) "expected valid session manager") (fn [request response] - (some->> (get request ::id) (delete-session manager)) + (some->> (get request ::session) :id (delete-session manager)) (clear-session-cookie response))) (defn decode-token diff --git a/backend/test/backend_tests/rpc_auth_test.clj b/backend/test/backend_tests/rpc_auth_test.clj new file mode 100644 index 0000000000..94adfb0d3a --- /dev/null +++ b/backend/test/backend_tests/rpc_auth_test.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-auth-test + (:require + [app.common.uuid :as uuid] + [app.http.session :as session] + [backend-tests.helpers :as th] + [clojure.test :as t] + [yetti.response :as yres])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest logout-invalidates-current-session + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) "test-agent"]) + session (session/read-session manager sid)] + + ;; Arrange: session exists before logout + (t/is (some? session) "session should exist before logout") + (t/is (= sid (:id session))) + + ;; Act: simulate Ring request as produced by wrap-authz (has ::session/session) + ;; delete-fn is used as response transform via rph/with-transform in auth/logout + (let [request {::session/session session} + response {} + delete-fn (session/delete-fn th/*system*) + result (delete-fn request response)] + + ;; Assert: server-side session is deleted (CWE-613) + (t/is (nil? (session/read-session manager sid)) + "session must be deleted server-side after logout (GHSA-mj9f-5cwq-7p3q)") + + ;; Assert: cookie is cleared + (t/is (= "" (get-in result [::yres/cookies "auth-token" :value])) + "auth-token cookie should be cleared") + (t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age])) + "auth-token cookie max-age should be 0")))) + +(t/deftest logout-clears-cookie-even-when-session-missing + (let [manager (::session/manager th/*system*) + sid (uuid/random) + ;; No session inserted, read should be nil + _ (t/is (nil? (session/read-session manager sid))) + request {} + response {} + delete-fn (session/delete-fn th/*system*) + result (delete-fn request response)] + + ;; Should still clear cookie (idempotent) + (t/is (= "" (get-in result [::yres/cookies "auth-token" :value]))) + (t/is (= 0 (get-in result [::yres/cookies "auth-token" :max-age]))))) + +(t/deftest logout-does-not-invalidate-other-sessions + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid1 (uuid/random) + sid2 (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid1 (:id prof) "agent-1"]) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid2 (:id prof) "agent-2"]) + s1 (session/read-session manager sid1) + s2 (session/read-session manager sid2)] + + (t/is (some? s1)) + (t/is (some? s2)) + + ;; Logout only sid1 + (let [request {::session/session s1} + response {} + delete-fn (session/delete-fn th/*system*)] + (delete-fn request response)) + + ;; sid1 deleted, sid2 intact + (t/is (nil? (session/read-session manager sid1)) "current session should be deleted") + (t/is (some? (session/read-session manager sid2)) "other sessions should remain"))) + +(t/deftest replay-after-logout-cannot-authenticate + (let [prof (th/create-profile* 1) + manager (::session/manager th/*system*) + sid (uuid/random) + _ (th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, user_agent) VALUES (?, ?, ?)" + sid (:id prof) "test-agent"]) + session (session/read-session manager sid)] + + (t/is (some? session) "session exists before logout") + + ;; Simulate logout + (let [request {::session/session session} + response {} + delete-fn (session/delete-fn th/*system*)] + (delete-fn request response)) + + ;; Replay: attempt to read session with same sid should fail (no profile attached) + (t/is (nil? (session/read-session manager sid)) + "replayed token must not resolve to a valid session after logout"))) + + From 0cacf9bd998db42b405ec5f1f6bf75e5ffe89f83 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 21 Aug 2026 12:08:26 +0000 Subject: [PATCH 173/298] :recycle: Rename code-review-and-quality skill to code-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename skill directory and update all references to follow the same naming pattern as plan-review. Simplify review.md command from 138 to 25 lines — remove redundant content that duplicated what the skills already define. The command now acts as a thin router; the skills own the methodology. AI-assisted-by: mimo-v2.5-pro --- .opencode/commands/review.md | 125 +----------------- .../SKILL.md | 2 +- .opencode/skills/plan-review/SKILL.md | 6 +- 3 files changed, 10 insertions(+), 123 deletions(-) rename .opencode/skills/{code-review-and-quality => code-review}/SKILL.md (99%) diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md index 23d7941ce7..70bd89ac90 100644 --- a/.opencode/commands/review.md +++ b/.opencode/commands/review.md @@ -3,60 +3,14 @@ Act as a senior software engineer and perform a thorough review. ## Instructions 1. **Determine what is being reviewed** from the provided context: - - **If it is a plan** (implementation plan, design document, task breakdown) → follow the **Plan Review** path below. - - **If it is code** (diff, PR, code change) → follow the **Code Review** path below. + - **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill. + - **If it is code** (diff, PR, code change) → load the **`code-review`** skill. ---- +2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing. -## Code Review Path +3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. -1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format. -2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing the code. -3. Determine the diff or code to review from the provided context. -4. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. -5. Read the diff and the surrounding context for each changed file. -6. Review across all five axes: correctness, readability, architecture, security, performance. -7. Produce the review using the **Code Review Format** below. -8. For each finding: - - State the severity (Critical / High / Medium / Low / Suggestion) - - Identify the file and line - - Describe failure circumstances - - **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code - - **For Medium/Low**: Describe the fix clearly; code snippet optional - - If multiple approaches exist, briefly note trade-offs -9. **Perform a second review pass if the change is complex:** - - **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed - - **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings - - Second pass checks: - - Validate severity assignments: Are Critical/High findings truly blockers? - - Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass - - Remove false positives: Discard findings that aren't real issues - - Verify fixes: Are the proposed solutions actually correct and complete? - ---- - -## Plan Review Path - -1. Load the **`plan-review`** skill — it defines the six axes, severity taxonomy, and output format. -2. Read the full plan from the provided context. -3. Review across all six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality (if the plan includes implementation details). -4. Produce the review using the **Plan Review Format** below. -5. For each finding: - - State the severity (Critical / Required / Nit / Optional / FYI) - - Identify the section or task it refers to - - Describe the gap or problem - - **For Critical/Required**: Propose a concrete fix or addition - - **For Nit/Optional**: Describe the improvement; concrete text optional -6. **Perform a second review pass if the plan is complex:** - - **Complex indicators**: Critical findings, >10 tasks, migrations or breaking changes, security-sensitive features - - **Skip for simple plans**: 1–2 tasks, no risks, no code proposals - - Second pass checks: - - Validate severity assignments - - Catch missed gaps: edge cases, missing dependencies, unaddressed risks - - Remove false positives - - Verify proposed remedies are actionable - ---- +4. Follow the loaded skill's process and produce its output format. ## Strong Rules @@ -64,75 +18,8 @@ Act as a senior software engineer and perform a thorough review. 2. Do not modify any code and do not create a commit — this command only reviews. 3. Be specific and constructive. "This could be better" is not helpful — explain why and how. 4. Prioritize by impact. One structural issue outweighs ten nits. -5. Missing tests are an issue, not a suggestion. If tests are missing or inadequate for new functionality, report it as a severity-tagged finding in the findings sections below — High severity (code) or Required (plan) — never as a recommendation. +5. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation. ## Context $ARGUMENTS - -## Expected Format — Code Review - -``` -## Review Summary -[1-2 sentences on what the change does and overall assessment] - -## Critical/High Findings - -### [Severity] file.ts:123 -**Issue**: [Description of the problem] -**Impact**: [What could go wrong if this is not fixed] -**Fix**: - -````[language] -// Current code -[problematic code] - -// Fixed code -[corrected code] -[Optional: note trade-offs if multiple approaches exist] -```` - -### [Severity] file.ts:456 -**Issue**: [Description of the problem] -**Impact**: [What could go wrong if this is not fixed] -**Fix**: [Clear description of the fix; code snippet if it clarifies] - -## Other Findings - -### [Severity] file.ts:789 -**Issue**: [Description] -**Impact**: [Minor consequence or risk] -**Fix**: [Clear description; code snippet optional] - -## Positive Observations -[2-3 specific things done well] - -## Verdict -[Approve / Request Changes / Needs Discussion] -[If Request Changes: list the must-fix items] -``` - -## Expected Format — Plan Review - -``` -## Review Summary -[1-2 sentences on the plan's goal and overall assessment] - -## Critical/Required Findings -### [Severity] [Section or Task N] -**Issue**: [Description of the gap or problem] -**Impact**: [What could go wrong during implementation] -**Proposed fix**: [Concrete addition or change to the plan] - -## Other Findings -### [Severity] [Section or Task N] -**Issue**: [Description] -**Proposed fix**: [Clear description; concrete text optional] - -## Strengths -[2-3 specific things done well in the plan] - -## Verdict -[Approve / Request Changes / Needs Discussion] -[If Request Changes: list the must-fix items] -``` diff --git a/.opencode/skills/code-review-and-quality/SKILL.md b/.opencode/skills/code-review/SKILL.md similarity index 99% rename from .opencode/skills/code-review-and-quality/SKILL.md rename to .opencode/skills/code-review/SKILL.md index a0f75e7f99..7f06d90b59 100644 --- a/.opencode/skills/code-review-and-quality/SKILL.md +++ b/.opencode/skills/code-review/SKILL.md @@ -1,5 +1,5 @@ --- -name: code-review-and-quality +name: code-review description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch. --- diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review/SKILL.md index 60b61c75e8..4386d701cc 100644 --- a/.opencode/skills/plan-review/SKILL.md +++ b/.opencode/skills/plan-review/SKILL.md @@ -87,7 +87,7 @@ Can an implementer actually execute this? ### 6. Proposed Code Quality *(when the plan includes implementation details)* -If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-and-quality` criteria: +If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria: - **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)? - **Readability:** Are proposed names descriptive and consistent with project conventions? @@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked: If the plan includes code snippets, types, or API designs: ``` -- Load code-review-and-quality skill for criteria +- Load code-review skill for criteria - Check proposed signatures for edge cases - Verify naming follows project conventions - Confirm abstractions follow existing patterns @@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs: ## See Also - For producing plans, use the `planner` skill -- For reviewing implemented code, use `code-review-and-quality` — also the criteria source for axis 6 +- For reviewing implemented code, use `code-review` — also the criteria source for axis 6 - For security-specific concerns, see `security-and-hardening` - For testing strategy guidance, see `testing` From 8aefa2ddfd6653acb0f314167b412a4dbd1c4f0c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 21 Aug 2026 14:19:50 +0200 Subject: [PATCH 174/298] :bug: Freeze viewport gestures during WASM page transition (#11301) Pan/zoom via render_from_cache while the tile atlas is still empty left a blank workspace under the page-transition blur. Ignore set-view-box / view-interaction-start until tiles-complete, block pointer events on the viewport SVG, and flush any deferred local viewport sync when the overlay ends. --- .../app/main/ui/workspace/viewport_wasm.cljs | 3 +- frontend/src/app/render_wasm/api.cljs | 56 ++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 5a9601502d..b3ceaec95a 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -709,7 +709,8 @@ :global/cursor-resize-ew-0 (= @guide-hover-axis* :x) :global/cursor-resize-ns-0 (= @guide-hover-axis* :y) :viewport-controls true)) - :style {:touch-action "none"} + :style {:touch-action "none" + :pointer-events (if page-transition? "none" "auto")} :fill "none" :on-click on-click :on-context-menu on-context-menu diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 94d55480ee..1a4b9b1ba4 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -83,6 +83,7 @@ ;; `penpot:wasm:tiles-complete`. ;; ;; - `page-transition?`: true while the overlay should be considered active. +;; Pan/zoom into WASM is frozen until `tiles-complete` (atlas still empty). ;; - `transition-image*`: image shown by the UI overlay (usually an `ImageBitmap` ;; snapshot of the WebGL canvas; on initial load it may be a tiny SVG data-url ;; string derived from the page background color). @@ -92,6 +93,8 @@ ;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely. (defonce page-transition? (atom false)) (defonce context-loss-overlay? (atom false)) +;; Skipped set-view-box during transition; flushed when the overlay ends. +(defonce ^:private viewport-dirty-during-transition? (atom false)) ;; When true (initial load) the overlay clips out the ruler strips so the live ;; rulers show through. False (page switch / context loss) keeps the snapshot's ;; baked-in rulers full-bleed to avoid a blank-strip flicker on canvas remount. @@ -114,6 +117,8 @@ [] (wasm/ready?)) +(declare sync-workspace-local-viewport!) + (defn set-transition-image-from-background! "Sets `transition-image*` to a data URL representing a solid background color." @@ -128,6 +133,7 @@ (defn begin-page-transition! [] (reset! page-transition? true) + (reset! viewport-dirty-during-transition? false) (swap! transition-epoch* inc)) (defn end-page-transition! @@ -136,7 +142,11 @@ (when-let [prev @transition-tiles-handler*] (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) (reset! transition-tiles-handler* nil) - (reset! transition-image* nil)) + (reset! transition-image* nil) + ;; Keyboard/wheel may have moved workspace-local while WASM was frozen. + (when (and (initialized?) @viewport-dirty-during-transition?) + (reset! viewport-dirty-during-transition? false) + (sync-workspace-local-viewport! @st/state))) (defn- set-transition-tiles-complete-handler! "Installs a tiles-complete handler bound to the current transition epoch. @@ -1383,7 +1393,9 @@ (defn view-interaction-start! [] - (when (and (initialized?) (not @view-interaction-active?)) + (when (and (initialized?) + (not @page-transition?) + (not @view-interaction-active?)) (h/call wasm/internal-module "_set_view_start") (reset! view-interaction-active? true))) @@ -1463,25 +1475,29 @@ (defn set-view-box [zoom vbox] - (when (initialized?) - (perf/begin-measure "set-view-box") - (view-interaction-start!) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (perf/end-measure "set-view-box") + ;; Frozen during page transition: tile atlas is empty/incomplete and + ;; render_from_cache would present a blank workspace. + (if @page-transition? + (reset! viewport-dirty-during-transition? true) + (when (initialized?) + (perf/begin-measure "set-view-box") + (view-interaction-start!) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (perf/end-measure "set-view-box") - (perf/begin-measure "render-from-cache") - (h/call wasm/internal-module "_render_from_cache" 0) - ;; Keep the text-editor caret/selection glued to the shapes while the view - ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox - ;; but omits the editor overlay, so without this the selection would vanish for - ;; the whole pan/zoom gesture and only flash back when the debounced full - ;; render lands — the blink seen when zooming in/out over a selection at high - ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the - ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no - ;; editor is active. - (render-text-editor-overlay-if-active!) - (render-finish) - (perf/end-measure "render-from-cache"))) + (perf/begin-measure "render-from-cache") + (h/call wasm/internal-module "_render_from_cache" 0) + ;; Keep the text-editor caret/selection glued to the shapes while the view + ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox + ;; but omits the editor overlay, so without this the selection would vanish for + ;; the whole pan/zoom gesture and only flash back when the debounced full + ;; render lands — the blink seen when zooming in/out over a selection at high + ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the + ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no + ;; editor is active. + (render-text-editor-overlay-if-active!) + (render-finish) + (perf/end-measure "render-from-cache")))) (defn sync-workspace-local-viewport! "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." From 47d599fe346ac5478df37466135cfe43ff1051a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 21 Aug 2026 14:37:21 +0200 Subject: [PATCH 175/298] :sparkles: Persist binfile manifest and emit workspace audit events (#11106) (#11138) Persist binfile manifest metadata in file_data on import so file statistics are available at open-workspace time. Emit a new open-workspace-file audit event enriched with file statistics: page count, shape count, component count, linked libraries, design tokens, and whether the file is a shared library. Closes #11106 AI-assisted-by: mimo-v2.5-pro --- backend/src/app/binfile/common.clj | 1 + backend/src/app/binfile/v3.clj | 8 +- backend/src/app/config.clj | 2 +- backend/src/app/features/fdata.clj | 20 ++--- backend/test/backend_tests/binfile_test.clj | 23 +++++ common/src/app/common/types/file.cljc | 10 +++ frontend/src/app/main/data/workspace.cljs | 61 +++++++++++++ .../data/workspace_stats_test.cljs | 87 +++++++++++++++++++ 8 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_stats_test.cljs diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index f984a98550..3e4402be92 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -723,6 +723,7 @@ (-> (select-keys file file-attrs) (assoc :data nil) (dissoc :team-id) + (dissoc :metadata) (dissoc :migrations))) (defn- file->file-data-params diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 952cb69e8f..eab49e1eb3 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -392,7 +392,7 @@ params {:type "penpot/export-files" :version 1 :generated-by (str "penpot/" (:full cf/version)) - :refer "penpot" + :referer "penpot" :files (vec (vals files)) :relations rels}] (write-entry! output "manifest.json" params)))) @@ -734,7 +734,7 @@ :plugin-data plugin-data})) (defn- import-file - [{:keys [::db/conn ::bfc/project-id] :as cfg} {file-id :id file-name :name}] + [{:keys [::db/conn ::bfc/project-id ::manifest] :as cfg} {file-id :id file-name :name}] (let [file-id' (bfc/lookup-index file-id) file (read-file cfg file-id) media (read-file-media cfg file-id) @@ -801,8 +801,10 @@ (assoc :data data) (assoc :name file-name) (assoc :project-id project-id) + (assoc :metadata (d/without-nils + {:generated-by (get manifest :generated-by) + :referer (or (get manifest :referer) (get manifest :refer))})) (dissoc :options)) - file (bfc/process-file cfg file) file (ctf/check-file file)] diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index bebd5db826..f02136b1ca 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -52,7 +52,7 @@ :redis-uri "redis://redis/0" - :file-data-backend "legacy-db" + :file-data-backend "db" :objects-storage-backend "fs" :objects-storage-fs-directory "assets" diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index 412ca223cf..8e7ff9d978 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -12,6 +12,7 @@ [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.file :as ctf] [app.common.types.objects-map :as omap] [app.config :as cf] [app.db :as db] @@ -159,15 +160,17 @@ :content-type "application/octet-stream" :file-id file-id :id id}) - metadata {:storage-ref-id (:id sobject)} + metadata (-> (:metadata params) + (assoc :storage-ref-id (:id sobject))) params (-> params (assoc :metadata metadata) (assoc :data nil))] (upsert-in-database cfg params)) (= backend "db") - (->> (dissoc params :metadata) - (upsert-in-database cfg)) + (let [metadata (dissoc (:metadata params) :storage-ref-id) + params (assoc params :metadata metadata)] + (upsert-in-database cfg params)) (= backend "legacy-db") (cond @@ -213,18 +216,11 @@ [backend] (or backend (cf/get :file-data-backend))) -(def ^:private schema:metadata - [:map {:title "Metadata"} - [:storage-ref-id {:optional true} ::sm/uuid]]) - -(def decode-metadata-with-schema - (sm/decoder schema:metadata sm/json-transformer)) - (defn decode-metadata [metadata] (some-> metadata (db/decode-json-pgobject) - (decode-metadata-with-schema))) + (ctf/decode-file-metadata))) (def ^:private schema:update-params [:map {:closed true} @@ -232,7 +228,7 @@ [:type [:enum "main" "snapshot" "fragment"]] [:file-id ::sm/uuid] [:backend {:optional true} [:enum "db" "legacy-db" "storage"]] - [:metadata {:optional true} [:maybe schema:metadata]] + [:metadata {:optional true} ctf/schema:file-metadata] [:data {:optional true} bytes?] [:created-at {:optional true} ::ct/inst] [:modified-at {:optional true} [:maybe ::ct/inst]] diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 05f1525c5e..c0e45d5429 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -207,6 +207,29 @@ (t/is (= (count result) 1)) (t/is (every? uuid? result))))) +(t/deftest import-binfile-v3-persists-manifest-metadata + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first result))] + + (t/is (= (count result) 1)) + (t/is (some? (get-in imported [:metadata :generated-by]))) + (t/is (= "penpot" (get-in imported [:metadata :referer])))))) + (t/deftest read-obj-rejects-oversized-buffer ;; N1-07: read-obj! must reject objects exceeding max-object-size ;; before attempting to allocate the buffer diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index fe91dca8ad..d7e9feb676 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -88,6 +88,12 @@ [:plugin-data {:optional true} schema:plugin-data] [:tokens-lib {:optional true} schema:tokens-lib]]) +(def schema:file-metadata + [:map {:title "Metadata"} + [:storage-ref-id {:optional true} ::sm/uuid] + [:generated-by {:optional true} :string] + [:referer {:optional true} :string]]) + (def schema:file "A schema for validate a file data structure; data is optional because sometimes we want to validate file without the data." @@ -106,6 +112,7 @@ [:data {:optional true} schema:data] [:version :int] [:features ::cfeat/features] + [:metadata {:optional true} schema:file-metadata] [:migrations {:optional true} [::sm/set {:ordered true} :string]]]) @@ -123,6 +130,9 @@ (def check-file-media (sm/check-fn schema:media)) +(def decode-file-metadata + (sm/decoder schema:file-metadata sm/json-transformer)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 72e5f93f3e..518729d806 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -17,11 +17,13 @@ [app.common.geom.proportions :as gpp] [app.common.geom.shapes :as gsh] [app.common.logging :as log] + [app.common.math :as mth] [app.common.path-names :as cpn] [app.common.transit :as t] [app.common.types.component :as ctc] [app.common.types.components-list :as ctkl] [app.common.types.shape :as cts] + [app.common.types.tokens-lib :as ctob] [app.common.types.variant :as ctv] [app.common.uuid :as uuid] [app.config :as cf] @@ -266,6 +268,59 @@ (rx/map (fn [_] (mcp/init)))) (rx/empty)))))) +(defn- compute-shape-stats + "Compute shape statistics in a single pass over pages-index. + Returns {:num-shapes N :max-shapes-per-page M}" + [pages-index] + (reduce-kv + (fn [acc _page-id page] + (let [n (count (:objects page))] + (-> acc + (update :num-shapes + n) + (update :max-shapes-per-page max n)))) + {:num-shapes 0 + :max-shapes-per-page 0} + pages-index)) + +(defn compute-file-stats + "Compute file statistics. Returns a map of stats without event keys." + [state file-id] + (let [file (dsh/lookup-file state file-id) + file-data (:data file) + libraries (refs/select-libraries (:files state) file-id) + pages-index (:pages-index file-data) + {:keys [num-shapes max-shapes-per-page]} (compute-shape-stats pages-index) + n-pages (count (:pages file-data)) + n-components (reduce-kv (fn [n _ c] (if (:deleted c) n (inc n))) + 0 (:components file-data)) + n-linked-libs (dec (count libraries)) + tokens-lib (:tokens-lib file-data) + n-tokens (if (some? tokens-lib) + (count (ctob/get-all-tokens tokens-lib)) + 0)] + {:num-pages n-pages + :num-shapes num-shapes + :avg-shapes-per-page (if (pos? n-pages) + (mth/round (/ num-shapes n-pages)) + 0) + :max-shapes-per-page max-shapes-per-page + :num-components n-components + :num-linked-libraries (max 0 n-linked-libs) + :is-library (:is-shared file) + :num-tokens n-tokens})) + +(defn- emit-workspace-file-stats + [file-id team-id] + (ptk/reify ::emit-workspace-file-stats + ptk/WatchEvent + (watch [_ state _] + (let [stats (compute-file-stats state file-id)] + (rx/of (ev/event (assoc stats + ::ev/name "open-workspace-file" + ::ev/origin "workspace" + :file-id file-id + :team-id team-id))))))) + (defn- bundle-fetched [{:keys [file file-id thumbnails] :as bundle}] (ptk/reify ::bundle-fetched @@ -421,6 +476,12 @@ (rx/take 1) (rx/map dwc/set-workspace-visited)) + ;; Emit audit event with file statistics once all libraries are resolved + (->> stream + (rx/filter (ptk/type? ::all-libraries-resolved)) + (rx/take 1) + (rx/map #(emit-workspace-file-stats file-id team-id))) + (when-let [component-id (some-> rparams :component-id uuid/parse)] (->> stream (rx/filter (ptk/type? ::workspace-initialized)) diff --git a/frontend/test/frontend_tests/data/workspace_stats_test.cljs b/frontend/test/frontend_tests/data/workspace_stats_test.cljs new file mode 100644 index 0000000000..c4ac483e5f --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_stats_test.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.data.workspace-stats-test + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.tokens-lib :as ctob] + [app.main.data.workspace :as dw] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +;; --------------------------------------------------------------------------- +;; Test compute-file-stats with various edge cases +;; --------------------------------------------------------------------------- + +(t/deftest compute-file-stats-empty-file + (t/testing "empty file with no pages" + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 1)) + (t/is (>= (:num-shapes stats) 0)) + (t/is (>= (:avg-shapes-per-page stats) 0)) + (t/is (>= (:max-shapes-per-page stats) 0)) + (t/is (>= (:num-components stats) 0)) + (t/is (>= (:num-linked-libraries stats) 0)) + (t/is (boolean? (:is-library stats))) + (t/is (>= (:num-tokens stats) 0))))) + +(t/deftest compute-file-stats-with-shapes + (t/testing "file with shapes" + (let [file (-> (cthf/sample-file :file1 :page-label :page1) + (cthf/add-sample-shape :shape1) + (cthf/add-sample-shape :shape2)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 1)) + (t/is (>= (:num-shapes stats) 2)) + (t/is (>= (:avg-shapes-per-page stats) 2)) + (t/is (>= (:max-shapes-per-page stats) 2))))) + +(t/deftest compute-file-stats-no-tokens + (t/testing "file with no tokens lib" + (let [file (cthf/sample-file :file1 :page-label :page1) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-tokens stats) 0))))) + +(t/deftest compute-file-stats-with-tokens + (t/testing "file with tokens" + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set {:name "global" + :description "Global tokens" + :tokens [{:name "color.primary" + :type :color + :value "#000000"}]})) + file (-> (cthf/sample-file :file1 :page-label :page1) + (assoc-in [:data :tokens-lib] tokens-lib)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-tokens stats) 1))))) + +(t/deftest compute-file-stats-multiple-pages + (t/testing "file with multiple pages" + (let [file (-> (cthf/sample-file :file1 :page-label :page1) + (cthf/add-sample-page :page2) + (cthf/add-sample-page :page3)) + store (ths/setup-store file) + state @store + file-id (:id file) + stats (dw/compute-file-stats state file-id)] + (t/is (= (:num-pages stats) 3))))) From dd6b521bc72964ee492b1a2d51f2e92f85bf4dd6 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 21 Aug 2026 15:15:40 +0200 Subject: [PATCH 176/298] :bug: Fix WASM text selection copy to Windows apps (#11305) Write text/html alongside text/plain on copy/cut so Windows apps that prefer CF_HTML do not paste the empty contenteditable newline. --- .../ui/workspace/shapes/text/v3_editor.cljs | 19 ++++++++++++++----- frontend/src/app/util/clipboard.cljs | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 58377e9c1c..2563463d17 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -19,6 +19,7 @@ [app.main.ui.css-cursors :as cur] [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as text-editor] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.keyboard :as kbd] [cuerdas.core :as str] @@ -267,8 +268,13 @@ (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) (when (text-editor/text-editor-has-selection?) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" text)))))) + (let [text (or (text-editor/text-editor-export-selection) "") + html (clipboard/plain-text->html text) + data (.-clipboardData event)] + ;; text/html matters on Windows: many apps prefer CF_HTML, and + ;; without it they can pick up the empty contenteditable `
    `. + (.setData data "text/plain" text) + (.setData data "text/html" html)))))) on-cut (mf/use-fn @@ -276,9 +282,12 @@ (when (text-editor/text-editor-has-focus?) (dom/prevent-default event) (when (text-editor/text-editor-has-selection?) - (let [text (text-editor/text-editor-export-selection)] - (.setData (.-clipboardData event) "text/plain" (or text "")) - (when (and text (seq text)) + (let [text (or (text-editor/text-editor-export-selection) "") + html (clipboard/plain-text->html text) + data (.-clipboardData event)] + (.setData data "text/plain" text) + (.setData data "text/html" html) + (when (seq text) (text-editor/text-editor-delete-backward) (sync-wasm-text-editor-content!) (wasm.api/request-render-preserving-target "text-cut")))) diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 3bc1f1bba3..c2c92c9a76 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -22,6 +22,18 @@ #js {:decodeTransit t/decode-str :allowHTMLPaste false}) +(defn plain-text->html + "Build a minimal text/html clipboard payload from plain text. + + Windows apps often prefer CF_HTML over CF_UNICODETEXT; writing only + text/plain from a contenteditable copy handler can leave them with the + editor surface's empty/`
    ` fallback (a lone newline)." + [text] + (let [escaped (-> (or text "") + (dom/escape-html) + (str/replace "\n" "
    "))] + (str "" escaped))) + (defn- from-data-transfer "Get clipboard stream from DataTransfer instance" ([data-transfer] From 7732c9354165197bdad0b7c3e7f83986567ced2e Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 24 Aug 2026 14:04:22 +0200 Subject: [PATCH 177/298] :lipstick: Update nitrate activation code link (#11330) --- frontend/src/app/main/ui/nitrate/nitrate_form.cljs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 5b983ef888..c9c34bfb73 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -121,9 +121,7 @@ [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} - [:p {:class (stl/css :modal-text-large)} - (tr "nitrate.form.subscribe-with-code")] [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) :on-click on-activate-click} - (tr "nitrate.form.enter-code")]]]])]]]])) + (tr "nitrate.form.subscribe-with-code")]]]])]]]])) From e93c41978516e29f505254417b5c0ec09d3d8023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Mon, 24 Aug 2026 15:25:31 +0200 Subject: [PATCH 178/298] :bug: Fix wrong copy on disabled invite button tooltip (#11311) --- frontend/src/app/main/ui/dashboard/team.cljs | 23 +++++++------------- frontend/translations/en.po | 5 +---- frontend/translations/es.po | 5 +---- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 730cf08f66..80d010c75b 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -140,23 +140,16 @@ [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} (when (and (or invitations-section? members-section?) (not-empty invitations)) - (let [organization (:organization team) - owners-only-invites? (and (contains? cfg/flags :admin-console) - organization - (= (get-in organization [:permissions :send-invitations]) "owners")) - title-text (if owners-only-invites? - (tr "dashboard.invite-profile-disabled.owners-only" (:name organization)) - (tr "dashboard.invite-profile-disabled")) - invite-button (mf/html - [:> button* {:class (stl/css :invite-button) - :variant "secondary" - :on-click on-invite-member - :disabled (not can-invite?) - :data-testid "invite-member"} - (tr "dashboard.invite-profile")])] + (let [invite-button (mf/html + [:> button* {:class (stl/css :invite-button) + :variant "secondary" + :on-click on-invite-member + :disabled (not can-invite?) + :data-testid "invite-member"} + (tr "dashboard.invite-profile")])] (if can-invite? invite-button - [:> tooltip* {:content title-text + [:> tooltip* {:content (tr "dashboard.invite-profile-disabled") :id "invite-member-disabled-tooltip" :tab-index 0} invite-button])))]])) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 828353e3c9..b85b0f4cc0 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10354,7 +10354,4 @@ msgid "labels.sso-error.retry" msgstr "Try again" msgid "dashboard.invite-profile-disabled" -msgstr "You don't have permission to invite people to this team" - -msgid "dashboard.invite-profile-disabled.owners-only" -msgstr "Only team owners can invite within %s" +msgstr "You don't have permission to invite people to this team" \ No newline at end of file diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 53cdbdc91e..acfeeb8fab 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9999,7 +9999,4 @@ msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" msgid "dashboard.invite-profile-disabled" -msgstr "No tienes permiso para invitar a personas a este equipo" - -msgid "dashboard.invite-profile-disabled.owners-only" -msgstr "Solo los propietarios del equipo pueden invitar dentro de %s" \ No newline at end of file +msgstr "No tienes permiso para invitar a personas a este equipo" \ No newline at end of file From 316b58bc75c9282d5253202baa4ac6995a94103f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Mon, 24 Aug 2026 20:02:59 +0200 Subject: [PATCH 179/298] :bug: Fix team dropdown not refreshing after organization deletion (#11336) --- frontend/src/app/main/data/team.cljs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index d250543807..f6173b3876 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -61,13 +61,18 @@ ;; Delete old teams from state state (update state :teams #(select-keys % team-ids))] (reduce (fn [state {:keys [id organization-id] :as team}] - (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) - (not organization-id) (dissoc :organization-id - :organization-name - :organization-slug - :organization-owner-id - :organization-avatar-bg-url - :organization-permissions))] + (let [team-merged (merge (dm/get-in state [:teams id]) team) + has-org? (or (some? organization-id) (some? (:organization team))) + team-updated (if has-org? + team-merged + (dissoc team-merged + :organization + :organization-id + :organization-name + :organization-slug + :organization-owner-id + :organization-avatar-bg-url + :organization-permissions))] (update state :teams assoc id team-updated))) state teams))))) From 52573be07472987703b2ce715c4c10919f4e153e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 09:37:42 +0200 Subject: [PATCH 180/298] :bug: Enforce share-link page scope in get-page and get-file-fragment RPC commands (#11284) The get-page RPC command did not validate that the requested page-id was within the share-link's authorized :pages set, allowing share-link holders to read out-of-scope pages. The get-file-fragment command had the same issue. This fix adds page scope validation to get-page, rejecting requests for pages not in the share-link's :pages set with a :not-found error. For get-file-fragment, share-link access is denied entirely as fragments lack direct page-id mapping. The fix aligns these commands with the existing correct behavior in get-view-only-bundle, which already filters pages based on share-link scope. Closes #11281 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/audit.clj | 1 + backend/src/app/rpc/commands/files.clj | 15 ++- backend/test/backend_tests/rpc_file_test.clj | 100 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 0f4bf1c320..69777a1ddd 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -183,6 +183,7 @@ (sv/defmethod ::get-enabled-flags {::audit/skip true + ::rpc/auth false ::doc/skip true ::doc/added "1.20"} [_cfg _params] diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 69bead539d..323ddf6c3f 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -245,6 +245,10 @@ [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] + (when (= :share-link (:type perms)) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")) (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -392,6 +396,14 @@ (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) + resolved-page-id (or page-id (-> file :data :pages first)) + + _ (when (and (= :share-link (:type perms)) + (not (contains? (:pages perms) resolved-page-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")) + proj (db/get conn :project {:id (:project-id file)}) team (-> (db/get conn :team {:id (:team-id proj)}) @@ -402,8 +414,7 @@ (cfeat/check-file-features! (:features file))) page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] - (let [page-id (or page-id (-> file :data :pages first)) - page (dm/get-in file [:data :pages-index page-id])] + (let [page (dm/get-in file [:data :pages-index resolved-page-id])] (if (pmap/pointer-map? page) (deref page) page)))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 27e881dc45..da8fbbb1f7 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2532,6 +2532,74 @@ share-links (:share-links (:result check))] (t/is (some #(= slink2-id (:id %)) share-links))))))) +(t/deftest share-link-page-scope-enforcement + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + ;; Add a second page to the file + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + ;; Create share-link scoped to page A only + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder can access authorized page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-a + :share-id share-id})] + (t/is (nil? (:error out))) + (t/is (some? (:result out))))) + + (t/testing "share-link holder cannot access out-of-scope page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-b + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))) + + (t/testing "team member can access all pages" + (let [out-a (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-a}) + out-b (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-b})] + (t/is (nil? (:error out-a))) + (t/is (nil? (:error out-b))))))) + (t/deftest share-link-deletion-escape-hatches (let [owner (th/create-profile* 1 {:is-active true}) editor (th/create-profile* 2 {:is-active true}) @@ -2594,3 +2662,35 @@ ::rpc/profile-id (:id owner) :id slink-id})] (t/is (nil? (:error out))))))) + +(t/deftest share-link-fragment-access-denied + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + + ;; Create share-link + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder cannot access file fragments" + (let [out (th/command! {::th/type :get-file-fragment + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :fragment-id (uuid/random) + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))))) From c31236947c8d4f28ab2ce3c02d55a2f34a4a98e3 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 25 Aug 2026 09:37:54 +0200 Subject: [PATCH 181/298] :bug: Fix skipped-tokens warning details show raw HTML tags instead of a bulleted list (#11325) --- frontend/src/app/main/ui/notifications.cljs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index 47c038f835..a7cead63cc 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -35,6 +35,7 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (:is-html notification) :detail (:detail notification) :on-close on-close} content] @@ -57,5 +58,6 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (:is-html notification) :detail (:detail notification) :on-close on-close} content])))) From 81c3b3cd562d476466e4cd9272a0c4244817b28c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 11:55:10 +0200 Subject: [PATCH 182/298] :paperclip: Update copyright name on file header (#11346) --- README.md | 2 +- backend/dev/script-fix-sobjects.clj | 2 +- backend/dev/user.clj | 2 +- backend/scripts/manage.py | 2 +- backend/src/app/auth.clj | 2 +- backend/src/app/auth/ldap.clj | 2 +- backend/src/app/auth/oidc.clj | 2 +- backend/src/app/auth/passwords.clj | 2 +- backend/src/app/binfile/cleaner.clj | 2 +- backend/src/app/binfile/common.clj | 2 +- backend/src/app/binfile/migrations.clj | 2 +- backend/src/app/binfile/v1.clj | 2 +- backend/src/app/binfile/v2.clj | 2 +- backend/src/app/binfile/v3.clj | 2 +- backend/src/app/config.clj | 2 +- backend/src/app/db.clj | 2 +- backend/src/app/db/sql.clj | 2 +- backend/src/app/email.clj | 2 +- backend/src/app/email/blacklist.clj | 2 +- backend/src/app/email/whitelist.clj | 2 +- backend/src/app/features/fdata.clj | 2 +- backend/src/app/features/file_migrations.clj | 2 +- backend/src/app/features/file_snapshots.clj | 2 +- backend/src/app/features/logical_deletion.clj | 2 +- backend/src/app/http.clj | 2 +- backend/src/app/http/access_token.clj | 2 +- backend/src/app/http/assets.clj | 2 +- backend/src/app/http/awsns.clj | 2 +- backend/src/app/http/client.clj | 2 +- backend/src/app/http/debug.clj | 2 +- backend/src/app/http/errors.clj | 2 +- backend/src/app/http/management.clj | 2 +- backend/src/app/http/middleware.clj | 2 +- backend/src/app/http/security.clj | 2 +- backend/src/app/http/session.clj | 2 +- backend/src/app/http/sse.clj | 2 +- backend/src/app/http/websocket.clj | 2 +- backend/src/app/loggers/audit.clj | 2 +- .../src/app/loggers/audit/archive_task.clj | 2 +- backend/src/app/loggers/audit/gc_task.clj | 2 +- backend/src/app/loggers/database.clj | 2 +- backend/src/app/loggers/mattermost.clj | 2 +- backend/src/app/loggers/webhooks.clj | 2 +- backend/src/app/main.clj | 2 +- backend/src/app/media.clj | 2 +- backend/src/app/media/local.clj | 2 +- backend/src/app/media/remote.clj | 2 +- backend/src/app/media/sanitize.clj | 2 +- backend/src/app/media/svg.clj | 2 +- backend/src/app/media/validation.clj | 2 +- backend/src/app/metrics.clj | 2 +- backend/src/app/migrations.clj | 2 +- .../src/app/migrations/clj/migration_0023.clj | 2 +- .../src/app/migrations/clj/migration_0145.clj | 2 +- backend/src/app/migrations/media_refs.clj | 2 +- backend/src/app/msgbus.clj | 2 +- backend/src/app/nitrate.clj | 2 +- backend/src/app/redis.clj | 2 +- backend/src/app/rpc.clj | 2 +- backend/src/app/rpc/climit.clj | 2 +- backend/src/app/rpc/commands/access_token.clj | 2 +- backend/src/app/rpc/commands/audit.clj | 2 +- backend/src/app/rpc/commands/auth.clj | 2 +- backend/src/app/rpc/commands/binfile.clj | 2 +- backend/src/app/rpc/commands/comments.clj | 2 +- backend/src/app/rpc/commands/demo.clj | 2 +- .../src/app/rpc/commands/error_reports.clj | 2 +- backend/src/app/rpc/commands/feedback.clj | 2 +- backend/src/app/rpc/commands/files.clj | 2 +- backend/src/app/rpc/commands/files_create.clj | 2 +- backend/src/app/rpc/commands/files_share.clj | 2 +- .../src/app/rpc/commands/files_snapshot.clj | 2 +- .../src/app/rpc/commands/files_thumbnails.clj | 2 +- backend/src/app/rpc/commands/files_update.clj | 2 +- backend/src/app/rpc/commands/fonts.clj | 2 +- backend/src/app/rpc/commands/ldap.clj | 2 +- backend/src/app/rpc/commands/management.clj | 2 +- backend/src/app/rpc/commands/media.clj | 2 +- backend/src/app/rpc/commands/nitrate.clj | 2 +- backend/src/app/rpc/commands/profile.clj | 2 +- backend/src/app/rpc/commands/projects.clj | 2 +- backend/src/app/rpc/commands/search.clj | 2 +- backend/src/app/rpc/commands/teams.clj | 2 +- .../app/rpc/commands/teams_invitations.clj | 2 +- backend/src/app/rpc/commands/verify_token.clj | 2 +- backend/src/app/rpc/commands/viewer.clj | 2 +- backend/src/app/rpc/commands/webhooks.clj | 2 +- backend/src/app/rpc/cond.clj | 2 +- backend/src/app/rpc/doc.clj | 2 +- backend/src/app/rpc/helpers.clj | 2 +- backend/src/app/rpc/management/exporter.clj | 2 +- backend/src/app/rpc/management/nitrate.clj | 2 +- backend/src/app/rpc/nitrate/emails_helper.clj | 2 +- .../app/rpc/nitrate/organization_helper.clj | 2 +- backend/src/app/rpc/notifications.clj | 2 +- backend/src/app/rpc/permissions.clj | 2 +- backend/src/app/rpc/quotes.clj | 2 +- backend/src/app/rpc/retry.clj | 2 +- backend/src/app/rpc/rlimit.clj | 2 +- backend/src/app/setup.clj | 2 +- backend/src/app/setup/clock.clj | 2 +- backend/src/app/setup/keys.clj | 2 +- backend/src/app/setup/templates.clj | 2 +- backend/src/app/setup/welcome_file.clj | 2 +- backend/src/app/srepl.clj | 2 +- backend/src/app/srepl/binfile.clj | 2 +- backend/src/app/srepl/cli.clj | 2 +- backend/src/app/srepl/helpers.clj | 2 +- backend/src/app/srepl/main.clj | 2 +- backend/src/app/srepl/procs/fdata_storage.clj | 2 +- backend/src/app/srepl/procs/file_repair.clj | 2 +- backend/src/app/srepl/procs/media_refs.clj | 2 +- backend/src/app/srepl/procs/path_data.clj | 2 +- backend/src/app/storage.clj | 2 +- backend/src/app/storage/fs.clj | 2 +- backend/src/app/storage/gc_deleted.clj | 2 +- backend/src/app/storage/gc_touched.clj | 2 +- backend/src/app/storage/impl.clj | 2 +- backend/src/app/storage/s3.clj | 2 +- backend/src/app/storage/tmp.clj | 2 +- backend/src/app/system.clj | 2 +- backend/src/app/tasks/delete_object.clj | 2 +- backend/src/app/tasks/file_gc.clj | 2 +- backend/src/app/tasks/file_gc_scheduler.clj | 2 +- backend/src/app/tasks/objects_gc.clj | 2 +- backend/src/app/tasks/offload_file_data.clj | 2 +- backend/src/app/tasks/tasks_gc.clj | 2 +- backend/src/app/tasks/telemetry.clj | 2 +- backend/src/app/tasks/upload_session_gc.clj | 2 +- backend/src/app/tokens.clj | 2 +- backend/src/app/util/blob.clj | 2 +- backend/src/app/util/cache.clj | 2 +- backend/src/app/util/cron.clj | 2 +- backend/src/app/util/events.clj | 2 +- backend/src/app/util/inet.clj | 2 +- backend/src/app/util/json.clj | 2 +- backend/src/app/util/locks.clj | 2 +- backend/src/app/util/migrations.clj | 2 +- backend/src/app/util/nio.clj | 2 +- backend/src/app/util/objects_map.clj | 2 +- backend/src/app/util/overrides.clj | 2 +- backend/src/app/util/pointer_map.clj | 2 +- backend/src/app/util/services.clj | 2 +- backend/src/app/util/shell.clj | 2 +- backend/src/app/util/ssrf.clj | 2 +- backend/src/app/util/template.clj | 2 +- backend/src/app/util/websocket.clj | 2 +- backend/src/app/worker.clj | 2 +- backend/src/app/worker/cron.clj | 2 +- backend/src/app/worker/dispatcher.clj | 2 +- backend/src/app/worker/executor.clj | 2 +- backend/src/app/worker/runner.clj | 2 +- backend/test/backend_tests/auth_oidc_test.clj | 2 +- backend/test/backend_tests/binfile_test.clj | 2 +- .../backend_tests/bounce_handling_test.clj | 2 +- backend/test/backend_tests/db_test.clj | 2 +- .../backend_tests/email_blacklist_test.clj | 2 +- .../test/backend_tests/email_sending_test.clj | 2 +- backend/test/backend_tests/helpers.clj | 2 +- .../test/backend_tests/http_assets_test.clj | 2 +- .../backend_tests/http_management_test.clj | 2 +- .../http_middleware_security.clj | 2 +- .../backend_tests/http_middleware_test.clj | 2 +- .../backend_tests/loggers_webhooks_test.clj | 2 +- .../backend_tests/logical_deletion_test.clj | 2 +- .../test/backend_tests/media_remote_test.clj | 2 +- .../backend_tests/media_sanitize_test.clj | 2 +- backend/test/backend_tests/media_test.clj | 2 +- .../backend_tests/rpc_access_tokens_test.clj | 2 +- backend/test/backend_tests/rpc_audit_test.clj | 2 +- backend/test/backend_tests/rpc_auth_test.clj | 2 +- .../test/backend_tests/rpc_binfile_test.clj | 2 +- .../rpc_commands_error_reports_test.clj | 2 +- .../test/backend_tests/rpc_comment_test.clj | 2 +- .../rpc_cond_middleware_test.clj | 2 +- backend/test/backend_tests/rpc_doc_test.clj | 2 +- .../test/backend_tests/rpc_feedback_test.clj | 2 +- .../backend_tests/rpc_file_snapshot_test.clj | 2 +- backend/test/backend_tests/rpc_file_test.clj | 2 +- .../rpc_file_thumbnails_test.clj | 2 +- backend/test/backend_tests/rpc_font_test.clj | 2 +- .../rpc_management_nitrate_test.clj | 2 +- .../backend_tests/rpc_management_test.clj | 2 +- backend/test/backend_tests/rpc_media_test.clj | 2 +- .../test/backend_tests/rpc_nitrate_test.clj | 2 +- ...pc_organization_owner_permissions_test.clj | 2 +- .../test/backend_tests/rpc_profile_test.clj | 2 +- .../test/backend_tests/rpc_project_test.clj | 2 +- .../test/backend_tests/rpc_quotes_test.clj | 2 +- .../test/backend_tests/rpc_rlimit_test.clj | 2 +- backend/test/backend_tests/rpc_team_test.clj | 2 +- .../test/backend_tests/rpc_viewer_test.clj | 2 +- .../test/backend_tests/rpc_webhooks_test.clj | 2 +- backend/test/backend_tests/shell_test.clj | 2 +- backend/test/backend_tests/storage_test.clj | 2 +- .../backend_tests/tasks_telemetry_test.clj | 2 +- backend/test/backend_tests/util_blob_test.clj | 2 +- .../backend_tests/util_objects_map_test.clj | 2 +- .../backend_tests/util_pointer_map_test.clj | 2 +- backend/test/backend_tests/util_ssrf_test.clj | 2 +- common/dev/user.clj | 2 +- common/src/app/common/UUIDv8.java | 2 +- common/src/app/common/attrs.cljc | 2 +- common/src/app/common/buffer.cljc | 2 +- common/src/app/common/colors.cljc | 2 +- common/src/app/common/data.cljc | 2 +- common/src/app/common/data/macros.cljc | 2 +- common/src/app/common/data/undo_stack.cljc | 2 +- common/src/app/common/debug.clj | 2 +- common/src/app/common/encoding_impl.js | 2 +- common/src/app/common/exceptions.cljc | 2 +- common/src/app/common/features.cljc | 2 +- common/src/app/common/files/builder.cljc | 2 +- common/src/app/common/files/changes.cljc | 2 +- .../src/app/common/files/changes_builder.cljc | 2 +- .../src/app/common/files/comp_processors.cljc | 2 +- common/src/app/common/files/defaults.cljc | 2 +- common/src/app/common/files/focus.cljc | 2 +- common/src/app/common/files/helpers.cljc | 2 +- common/src/app/common/files/indices.cljc | 2 +- common/src/app/common/files/migrations.cljc | 2 +- common/src/app/common/files/page_diff.cljc | 2 +- common/src/app/common/files/repair.cljc | 2 +- .../src/app/common/files/shapes_builder.cljc | 2 +- .../src/app/common/files/shapes_helpers.cljc | 2 +- common/src/app/common/files/stats.cljc | 2 +- common/src/app/common/files/tokens.cljc | 2 +- common/src/app/common/files/validate.cljc | 2 +- common/src/app/common/files/variant.cljc | 2 +- common/src/app/common/flags.cljc | 2 +- common/src/app/common/fonts.clj | 2 +- common/src/app/common/fonts.cljs | 2 +- common/src/app/common/fressian.clj | 2 +- common/src/app/common/generic_pool.clj | 2 +- common/src/app/common/geom/align.cljc | 2 +- common/src/app/common/geom/bounds_map.cljc | 2 +- common/src/app/common/geom/grid.cljc | 2 +- common/src/app/common/geom/line.cljc | 2 +- common/src/app/common/geom/matrix.cljc | 2 +- common/src/app/common/geom/modif_tree.cljc | 2 +- common/src/app/common/geom/modifiers.cljc | 2 +- common/src/app/common/geom/point.cljc | 2 +- common/src/app/common/geom/proportions.cljc | 2 +- common/src/app/common/geom/rect.cljc | 2 +- common/src/app/common/geom/shapes.cljc | 2 +- common/src/app/common/geom/shapes/bounds.cljc | 2 +- common/src/app/common/geom/shapes/common.cljc | 2 +- .../app/common/geom/shapes/constraints.cljc | 2 +- .../src/app/common/geom/shapes/corners.cljc | 2 +- .../src/app/common/geom/shapes/effects.cljc | 2 +- .../src/app/common/geom/shapes/fit_frame.cljc | 2 +- .../app/common/geom/shapes/flex_layout.cljc | 2 +- .../geom/shapes/flex_layout/bounds.cljc | 2 +- .../geom/shapes/flex_layout/drop_area.cljc | 2 +- .../geom/shapes/flex_layout/layout_data.cljc | 2 +- .../geom/shapes/flex_layout/modifiers.cljc | 2 +- .../geom/shapes/flex_layout/params.cljc | 2 +- .../geom/shapes/flex_layout/positions.cljc | 2 +- .../app/common/geom/shapes/grid_layout.cljc | 2 +- .../common/geom/shapes/grid_layout/areas.cljc | 2 +- .../geom/shapes/grid_layout/bounds.cljc | 2 +- .../geom/shapes/grid_layout/layout_data.cljc | 2 +- .../geom/shapes/grid_layout/params.cljc | 2 +- .../geom/shapes/grid_layout/positions.cljc | 2 +- .../src/app/common/geom/shapes/intersect.cljc | 2 +- .../common/geom/shapes/min_size_layout.cljc | 2 +- .../common/geom/shapes/pixel_precision.cljc | 2 +- common/src/app/common/geom/shapes/points.cljc | 2 +- common/src/app/common/geom/shapes/rect.cljc | 2 +- .../src/app/common/geom/shapes/strokes.cljc | 2 +- common/src/app/common/geom/shapes/text.cljc | 2 +- .../app/common/geom/shapes/transforms.cljc | 2 +- .../src/app/common/geom/shapes/tree_seq.cljc | 2 +- common/src/app/common/geom/snap.cljc | 2 +- common/src/app/common/i18n.cljc | 2 +- common/src/app/common/json.cljc | 2 +- common/src/app/common/logging.cljc | 2 +- common/src/app/common/logic/libraries.cljc | 2 +- common/src/app/common/logic/shapes.cljc | 2 +- common/src/app/common/logic/tokens.cljc | 2 +- .../app/common/logic/variant_properties.cljc | 2 +- common/src/app/common/math.cljc | 2 +- common/src/app/common/media.cljc | 2 +- common/src/app/common/path_names.cljc | 2 +- common/src/app/common/perf.cljc | 2 +- common/src/app/common/pprint.cljc | 2 +- common/src/app/common/record.cljc | 2 +- .../src/app/common/render_wasm/api/props.cljs | 2 +- .../app/common/render_wasm/api/shapes.cljs | 2 +- common/src/app/common/render_wasm/enums.clj | 2 +- .../src/app/common/render_wasm/helpers.cljc | 2 +- common/src/app/common/render_wasm/mem.cljs | 2 +- .../app/common/render_wasm/mem/heap32.cljs | 2 +- .../common/render_wasm/serialize_shape.cljs | 2 +- .../app/common/render_wasm/serializers.cljs | 2 +- .../app/common/render_wasm/text_content.cljs | 2 +- common/src/app/common/render_wasm/wasm.cljs | 2 +- common/src/app/common/schema.cljc | 2 +- .../src/app/common/schema/desc_js_like.cljc | 2 +- common/src/app/common/schema/desc_native.cljc | 2 +- common/src/app/common/schema/generators.cljc | 2 +- common/src/app/common/schema/messages.cljc | 2 +- common/src/app/common/schema/openapi.cljc | 2 +- common/src/app/common/schema/registry.cljc | 2 +- common/src/app/common/schema/test.cljc | 2 +- common/src/app/common/spec.cljc | 2 +- common/src/app/common/svg.cljc | 2 +- common/src/app/common/svg/path.cljc | 2 +- common/src/app/common/svg/path/Parser.java | 2 +- .../src/app/common/svg/path/arc_to_bezier.js | 2 +- .../app/common/svg/path/legacy_parser2.cljc | 2 +- common/src/app/common/svg/path/parser.js | 2 +- .../app/common/test_helpers/components.cljc | 2 +- .../app/common/test_helpers/compositions.cljc | 2 +- common/src/app/common/test_helpers/files.cljc | 2 +- .../src/app/common/test_helpers/ids_map.cljc | 2 +- .../src/app/common/test_helpers/shapes.cljc | 2 +- .../src/app/common/test_helpers/tokens.cljc | 2 +- .../src/app/common/test_helpers/variants.cljc | 2 +- common/src/app/common/text.cljc | 2 +- common/src/app/common/time.cljc | 2 +- common/src/app/common/transit.cljc | 2 +- common/src/app/common/types/color.cljc | 2 +- common/src/app/common/types/component.cljc | 2 +- common/src/app/common/types/container.cljc | 2 +- common/src/app/common/types/file.cljc | 2 +- common/src/app/common/types/fills.cljc | 2 +- common/src/app/common/types/fills/impl.cljc | 2 +- common/src/app/common/types/font.cljc | 2 +- common/src/app/common/types/grid.cljc | 2 +- common/src/app/common/types/library.cljc | 2 +- common/src/app/common/types/objects_map.cljc | 2 +- common/src/app/common/types/organization.cljc | 2 +- common/src/app/common/types/page.cljc | 2 +- common/src/app/common/types/pages_list.cljc | 2 +- common/src/app/common/types/path.cljc | 2 +- common/src/app/common/types/path/bool.cljc | 2 +- common/src/app/common/types/path/helpers.cljc | 2 +- common/src/app/common/types/path/impl.cljc | 2 +- common/src/app/common/types/path/segment.cljc | 2 +- .../app/common/types/path/shape_to_path.cljc | 2 +- common/src/app/common/types/path/subpath.cljc | 2 +- common/src/app/common/types/plugins.cljc | 2 +- common/src/app/common/types/profile.cljc | 2 +- common/src/app/common/types/project.cljc | 2 +- common/src/app/common/types/shape.cljc | 2 +- common/src/app/common/types/shape/attrs.cljc | 2 +- .../common/types/shape/background_blur.cljc | 2 +- common/src/app/common/types/shape/blur.cljc | 2 +- common/src/app/common/types/shape/export.cljc | 2 +- common/src/app/common/types/shape/images.cljs | 2 +- .../app/common/types/shape/interactions.cljc | 2 +- common/src/app/common/types/shape/layout.cljc | 2 +- common/src/app/common/types/shape/radius.cljc | 2 +- common/src/app/common/types/shape/shadow.cljc | 2 +- common/src/app/common/types/shape/text.cljc | 2 +- common/src/app/common/types/shape_tree.cljc | 2 +- common/src/app/common/types/stroke.cljc | 2 +- common/src/app/common/types/team.cljc | 2 +- common/src/app/common/types/text.cljc | 2 +- common/src/app/common/types/token.cljc | 2 +- common/src/app/common/types/tokens_lib.cljc | 2 +- .../app/common/types/typographies_list.cljc | 2 +- common/src/app/common/types/typography.cljc | 2 +- common/src/app/common/types/variant.cljc | 2 +- common/src/app/common/uri.cljc | 2 +- common/src/app/common/uuid.cljc | 2 +- common/src/app/common/uuid_impl.js | 2 +- common/src/app/common/version.cljc | 2 +- common/src/app/common/weak.cljc | 2 +- .../weak/impl_loadable_weak_value_map.clj | 2 +- common/src/app/common/weak/impl_weak_map.js | 2 +- .../app/common/weak/impl_weak_value_map.js | 2 +- common/test/common_tests/attrs_test.cljc | 2 +- common/test/common_tests/buffer_test.cljc | 2 +- common/test/common_tests/colors_test.cljc | 2 +- common/test/common_tests/data_test.cljc | 2 +- .../files/comp_processors_test.cljc | 2 +- .../test/common_tests/files/helpers_test.cljc | 2 +- .../test/common_tests/files/repair_test.cljc | 2 +- .../files/shapes_builder_test.cljc | 2 +- .../test/common_tests/files/tokens_test.cljc | 2 +- .../common_tests/files/validate_test.cljc | 2 +- .../test/common_tests/files_builder_test.cljc | 2 +- .../test/common_tests/files_changes_test.cljc | 2 +- .../files_migrations_0025_test.cljc | 2 +- .../common_tests/files_migrations_test.cljc | 2 +- common/test/common_tests/fressian_test.clj | 2 +- common/test/common_tests/geom_align_test.cljc | 2 +- .../geom_bounds_layout_nil_test.cljc | 2 +- .../common_tests/geom_bounds_map_test.cljc | 2 +- .../common_tests/geom_flex_layout_test.cljc | 2 +- .../common_tests/geom_grid_layout_test.cljc | 2 +- common/test/common_tests/geom_grid_test.cljc | 2 +- common/test/common_tests/geom_line_test.cljc | 2 +- .../common_tests/geom_modif_tree_test.cljc | 2 +- .../common_tests/geom_modifiers_test.cljc | 2 +- common/test/common_tests/geom_point_test.cljc | 2 +- .../common_tests/geom_proportions_test.cljc | 2 +- common/test/common_tests/geom_rect_test.cljc | 2 +- .../common_tests/geom_shapes_common_test.cljc | 2 +- .../geom_shapes_constraints_test.cljc | 2 +- .../geom_shapes_corners_test.cljc | 2 +- .../geom_shapes_effects_test.cljc | 2 +- .../geom_shapes_intersect_test.cljc | 2 +- .../geom_shapes_strokes_test.cljc | 2 +- .../test/common_tests/geom_shapes_test.cljc | 2 +- .../common_tests/geom_shapes_text_test.cljc | 2 +- .../geom_shapes_tree_seq_test.cljc | 2 +- common/test/common_tests/geom_snap_test.cljc | 2 +- common/test/common_tests/geom_test.cljc | 2 +- common/test/common_tests/helpers_test.cljc | 2 +- .../logic/chained_propagation_test.cljc | 2 +- .../logic/comp_creation_test.cljc | 2 +- .../logic/comp_detach_with_nested_test.cljc | 2 +- ...comp_main_edit_breaks_copy_slots_test.cljc | 2 +- .../logic/comp_remove_swap_slots_test.cljc | 2 +- .../common_tests/logic/comp_reset_test.cljc | 2 +- .../common_tests/logic/comp_sync_test.cljc | 2 +- .../common_tests/logic/comp_touched_test.cljc | 2 +- .../logic/copying_and_duplicating_test.cljc | 2 +- .../logic/duplicated_pages_test.cljc | 2 +- .../common_tests/logic/move_shapes_test.cljc | 2 +- .../logic/multiple_nesting_levels_test.cljc | 2 +- .../logic/swap_and_reset_test.cljc | 2 +- .../logic/swap_as_override_test.cljc | 2 +- .../logic/swap_keeps_id_test.cljc | 2 +- .../common_tests/logic/text_sync_test.cljc | 2 +- .../common_tests/logic/text_touched_test.cljc | 2 +- .../common_tests/logic/token_apply_test.cljc | 2 +- .../test/common_tests/logic/token_test.cljc | 2 +- .../logic/variants_switch_test.cljc | 2 +- .../common_tests/logic/variants_test.cljc | 2 +- common/test/common_tests/math_test.cljc | 2 +- common/test/common_tests/media_test.cljc | 2 +- common/test/common_tests/path_names_test.cljc | 2 +- common/test/common_tests/record_test.cljc | 2 +- common/test/common_tests/runner.cljc | 2 +- common/test/common_tests/schema_test.cljc | 2 +- common/test/common_tests/spec_test.cljc | 2 +- common/test/common_tests/svg_path_test.cljc | 2 +- common/test/common_tests/svg_test.cljc | 2 +- common/test/common_tests/text_test.cljc | 2 +- common/test/common_tests/time_test.cljc | 2 +- common/test/common_tests/token_test.cljc | 2 +- .../types/absorb_assets_test.cljc | 2 +- .../test/common_tests/types/color_test.cljc | 2 +- .../common_tests/types/components_test.cljc | 2 +- .../common_tests/types/container_test.cljc | 2 +- common/test/common_tests/types/fill_test.cljc | 2 +- common/test/common_tests/types/font_test.cljc | 2 +- .../common_tests/types/modifiers_test.cljc | 2 +- .../common_tests/types/objects_map_test.cljc | 2 +- .../common_tests/types/organization_test.cljc | 2 +- .../common_tests/types/path_data_test.cljc | 2 +- .../types/shape_decode_encode_test.cljc | 2 +- .../types/shape_interactions_test.cljc | 2 +- .../common_tests/types/shape_layout_test.cljc | 2 +- common/test/common_tests/types/text_test.cljc | 2 +- .../test/common_tests/types/token_test.cljc | 2 +- .../common_tests/types/tokens_lib_test.cljc | 2 +- .../types/tokens_migrations_test.cljc | 2 +- .../test/common_tests/types/variant_test.cljc | 2 +- common/test/common_tests/undo_stack_test.cljc | 2 +- common/test/common_tests/uuid_test.cljc | 2 +- common/test/common_tests/variant_test.cljc | 2 +- exporter/src/app/browser.cljs | 2 +- exporter/src/app/config.cljs | 2 +- exporter/src/app/core.cljs | 2 +- exporter/src/app/handlers.cljs | 2 +- exporter/src/app/handlers/export_frames.cljs | 2 +- exporter/src/app/handlers/export_shapes.cljs | 2 +- exporter/src/app/handlers/resources.cljs | 2 +- exporter/src/app/http.cljs | 2 +- exporter/src/app/redis.cljs | 2 +- exporter/src/app/renderer.cljs | 2 +- exporter/src/app/renderer/bitmap.cljs | 2 +- exporter/src/app/renderer/pdf.cljs | 2 +- exporter/src/app/renderer/svg.cljs | 2 +- exporter/src/app/renderer/svg_gradient.cljs | 2 +- exporter/src/app/renderer/wasm.cljs | 2 +- exporter/src/app/util/mime.cljs | 2 +- exporter/src/app/util/object.cljs | 2 +- exporter/src/app/util/shell.cljs | 2 +- exporter/src/app/util/transit.cljs | 2 +- exporter/src/app/wasm.cljs | 2 +- exporter/src/app/wasm/enums.cljs | 2 +- exporter/src/app/wasm/serialize.cljs | 2 +- exporter/src/app/wasm/text.cljs | 2 +- .../exporter_tests/renderer_svg_test.cljs | 2 +- exporter/test/exporter_tests/runner.cljs | 2 +- exporter/test/exporter_tests/shell_test.cljs | 2 +- frontend/dev/user.clj | 2 +- frontend/packages/draft-js/index.js | 2 +- frontend/resources/styles/common/base.scss | 2 +- .../styles/common/dependencies/fonts.scss | 2 +- .../styles/common/dependencies/highlight.scss | 2 +- .../styles/common/dependencies/storybook.scss | 2 +- .../styles/common/refactor/animations.scss | 2 +- .../styles/common/refactor/basic-rules.scss | 2 +- .../styles/common/refactor/borders.scss | 2 +- .../styles/common/refactor/color-defs.scss | 2 +- .../common/refactor/common-dashboard.scss | 2 +- .../common/refactor/common-refactor.scss | 2 +- .../styles/common/refactor/design-tokens.scss | 2 +- .../styles/common/refactor/focus.scss | 2 +- .../styles/common/refactor/fonts.scss | 2 +- .../styles/common/refactor/mixins.scss | 2 +- .../styles/common/refactor/opacity.scss | 2 +- .../styles/common/refactor/shadows.scss | 2 +- .../styles/common/refactor/spacing.scss | 2 +- .../styles/common/refactor/themes.scss | 2 +- .../common/refactor/themes/default-theme.scss | 2 +- .../common/refactor/themes/light-theme.scss | 2 +- .../styles/common/refactor/z-index.scss | 2 +- frontend/resources/styles/debug.scss | 2 +- frontend/resources/styles/main-default.scss | 2 +- frontend/scripts/build-fonts-preview.js | 2 +- frontend/src/app/config.cljs | 2 +- frontend/src/app/main.cljs | 2 +- frontend/src/app/main/broadcast.cljs | 2 +- frontend/src/app/main/constants.cljs | 2 +- frontend/src/app/main/data/auth.cljs | 2 +- frontend/src/app/main/data/changes.cljs | 2 +- frontend/src/app/main/data/comments.cljs | 2 +- frontend/src/app/main/data/common.cljs | 2 +- frontend/src/app/main/data/dashboard.cljs | 2 +- .../app/main/data/dashboard/shortcuts.cljs | 2 +- .../data/dashboard/shortcuts/customize.cljs | 2 +- frontend/src/app/main/data/event.cljs | 2 +- .../src/app/main/data/exports/assets.cljs | 2 +- frontend/src/app/main/data/exports/files.cljs | 2 +- frontend/src/app/main/data/exports/wasm.cljs | 2 +- frontend/src/app/main/data/fonts.cljs | 2 +- frontend/src/app/main/data/helpers.cljs | 2 +- frontend/src/app/main/data/media.cljs | 2 +- frontend/src/app/main/data/modal.cljs | 2 +- frontend/src/app/main/data/nitrate_audit.cljs | 2 +- frontend/src/app/main/data/notifications.cljs | 2 +- frontend/src/app/main/data/persistence.cljs | 2 +- frontend/src/app/main/data/plugins.cljs | 2 +- frontend/src/app/main/data/preview.cljs | 2 +- frontend/src/app/main/data/profile.cljs | 2 +- frontend/src/app/main/data/project.cljs | 2 +- frontend/src/app/main/data/shortcuts.cljs | 2 +- .../src/app/main/data/style_dictionary.cljs | 2 +- frontend/src/app/main/data/team.cljs | 2 +- frontend/src/app/main/data/tinycolor.cljs | 2 +- frontend/src/app/main/data/uploads.cljs | 2 +- frontend/src/app/main/data/viewer.cljs | 2 +- .../src/app/main/data/viewer/shortcuts.cljs | 2 +- frontend/src/app/main/data/websocket.cljs | 2 +- frontend/src/app/main/data/workspace.cljs | 2 +- .../src/app/main/data/workspace/assets.cljs | 2 +- .../src/app/main/data/workspace/bool.cljs | 2 +- .../app/main/data/workspace/clipboard.cljs | 2 +- .../src/app/main/data/workspace/collapse.cljs | 2 +- .../src/app/main/data/workspace/colors.cljs | 2 +- .../src/app/main/data/workspace/comments.cljs | 2 +- .../src/app/main/data/workspace/common.cljs | 2 +- .../src/app/main/data/workspace/drawing.cljs | 2 +- .../app/main/data/workspace/drawing/box.cljs | 2 +- .../main/data/workspace/drawing/common.cljs | 2 +- .../main/data/workspace/drawing/curve.cljs | 2 +- .../app/main/data/workspace/drawing/line.cljs | 2 +- .../src/app/main/data/workspace/edition.cljs | 2 +- .../data/workspace/fix_deleted_fonts.cljs | 2 +- .../src/app/main/data/workspace/grid.cljs | 2 +- .../data/workspace/grid_layout/editor.cljs | 2 +- .../data/workspace/grid_layout/shortcuts.cljs | 2 +- .../src/app/main/data/workspace/groups.cljs | 2 +- .../src/app/main/data/workspace/guides.cljs | 2 +- .../app/main/data/workspace/highlight.cljs | 2 +- .../src/app/main/data/workspace/history.cljs | 2 +- .../app/main/data/workspace/interactions.cljs | 2 +- .../src/app/main/data/workspace/layers.cljs | 2 +- .../src/app/main/data/workspace/layout.cljs | 2 +- .../app/main/data/workspace/libraries.cljs | 2 +- frontend/src/app/main/data/workspace/mcp.cljs | 2 +- .../src/app/main/data/workspace/media.cljs | 2 +- .../app/main/data/workspace/modifiers.cljs | 2 +- .../main/data/workspace/notifications.cljs | 2 +- .../src/app/main/data/workspace/pages.cljs | 2 +- .../src/app/main/data/workspace/path.cljs | 2 +- .../app/main/data/workspace/path/changes.cljs | 2 +- .../app/main/data/workspace/path/common.cljs | 2 +- .../app/main/data/workspace/path/drawing.cljs | 2 +- .../app/main/data/workspace/path/edition.cljs | 2 +- .../app/main/data/workspace/path/helpers.cljs | 2 +- .../main/data/workspace/path/selection.cljs | 2 +- .../data/workspace/path/shapes_to_path.cljs | 2 +- .../main/data/workspace/path/shortcuts.cljs | 2 +- .../app/main/data/workspace/path/state.cljs | 2 +- .../app/main/data/workspace/path/streams.cljs | 2 +- .../app/main/data/workspace/path/tools.cljs | 2 +- .../app/main/data/workspace/path/undo.cljs | 2 +- .../src/app/main/data/workspace/reflow.cljs | 2 +- .../main/data/workspace/reflow/signals.cljs | 2 +- .../app/main/data/workspace/selection.cljs | 2 +- .../app/main/data/workspace/shape_layout.cljs | 2 +- .../src/app/main/data/workspace/shapes.cljs | 2 +- .../app/main/data/workspace/shortcuts.cljs | 2 +- .../data/workspace/specialized_panel.cljs | 2 +- .../app/main/data/workspace/svg_upload.cljs | 2 +- .../main/data/workspace/text/shortcuts.cljs | 2 +- .../src/app/main/data/workspace/texts.cljs | 2 +- .../src/app/main/data/workspace/texts_v3.cljs | 2 +- .../app/main/data/workspace/thumbnails.cljs | 2 +- .../main/data/workspace/thumbnails_wasm.cljs | 2 +- .../data/workspace/tokens/application.cljs | 2 +- .../app/main/data/workspace/tokens/color.cljs | 2 +- .../main/data/workspace/tokens/errors.cljs | 2 +- .../data/workspace/tokens/import_export.cljs | 2 +- .../data/workspace/tokens/library_edit.cljs | 2 +- .../data/workspace/tokens/propagation.cljs | 2 +- .../main/data/workspace/tokens/remapping.cljs | 2 +- .../data/workspace/tokens/selected_set.cljs | 2 +- .../data/workspace/tokens/typography.cljs | 2 +- .../main/data/workspace/tokens/warnings.cljs | 2 +- .../app/main/data/workspace/transforms.cljs | 2 +- .../src/app/main/data/workspace/undo.cljs | 2 +- .../src/app/main/data/workspace/variants.cljs | 2 +- .../src/app/main/data/workspace/versions.cljs | 2 +- .../src/app/main/data/workspace/viewport.cljs | 2 +- .../main/data/workspace/viewport_wasm.cljs | 2 +- .../app/main/data/workspace/wasm_text.cljs | 2 +- .../src/app/main/data/workspace/zoom.cljs | 2 +- frontend/src/app/main/errors.cljs | 2 +- frontend/src/app/main/features.cljs | 2 +- .../src/app/main/features/pointer_map.cljs | 2 +- frontend/src/app/main/fonts.cljs | 2 +- frontend/src/app/main/rasterizer.cljs | 2 +- frontend/src/app/main/refs.cljs | 2 +- frontend/src/app/main/render.cljs | 2 +- frontend/src/app/main/render_viewer_wasm.cljs | 2 +- frontend/src/app/main/repo.cljs | 2 +- frontend/src/app/main/router.cljs | 2 +- frontend/src/app/main/snap.cljs | 2 +- frontend/src/app/main/store.cljs | 2 +- frontend/src/app/main/streams.cljs | 2 +- frontend/src/app/main/style.clj | 2 +- frontend/src/app/main/ui.cljs | 2 +- frontend/src/app/main/ui/alert.cljs | 2 +- frontend/src/app/main/ui/alert.scss | 2 +- frontend/src/app/main/ui/auth.cljs | 2 +- frontend/src/app/main/ui/auth.scss | 2 +- frontend/src/app/main/ui/auth/common.scss | 2 +- frontend/src/app/main/ui/auth/login.cljs | 2 +- frontend/src/app/main/ui/auth/login.scss | 2 +- frontend/src/app/main/ui/auth/recovery.cljs | 2 +- frontend/src/app/main/ui/auth/recovery.scss | 2 +- .../app/main/ui/auth/recovery_request.cljs | 2 +- .../app/main/ui/auth/recovery_request.scss | 2 +- frontend/src/app/main/ui/auth/register.cljs | 2 +- frontend/src/app/main/ui/auth/register.scss | 2 +- .../src/app/main/ui/auth/verify_token.cljs | 2 +- frontend/src/app/main/ui/comments.cljs | 2 +- frontend/src/app/main/ui/comments.scss | 2 +- .../app/main/ui/components/button_link.cljs | 2 +- .../app/main/ui/components/button_link.scss | 2 +- .../app/main/ui/components/code_block.cljs | 2 +- .../app/main/ui/components/code_block.scss | 2 +- .../app/main/ui/components/color_bullet.cljs | 2 +- .../app/main/ui/components/color_bullet.scss | 2 +- .../app/main/ui/components/color_input.cljs | 2 +- .../main/ui/components/context_menu_a11y.cljs | 2 +- .../main/ui/components/context_menu_a11y.scss | 2 +- .../app/main/ui/components/copy_button.cljs | 2 +- .../app/main/ui/components/copy_button.scss | 2 +- .../src/app/main/ui/components/dropdown.cljs | 2 +- .../app/main/ui/components/dropdown_menu.cljs | 2 +- .../main/ui/components/editable_label.cljs | 2 +- .../main/ui/components/editable_label.scss | 2 +- .../main/ui/components/editable_select.cljs | 2 +- .../main/ui/components/editable_select.scss | 2 +- .../app/main/ui/components/file_uploader.cljs | 2 +- .../src/app/main/ui/components/forms.cljs | 2 +- .../src/app/main/ui/components/forms.scss | 2 +- frontend/src/app/main/ui/components/link.cljs | 2 +- .../app/main/ui/components/link_button.cljs | 2 +- .../app/main/ui/components/numeric_input.cljs | 2 +- .../ui/components/organization_avatar.cljs | 2 +- .../ui/components/organization_avatar.scss | 2 +- .../src/app/main/ui/components/portal.cljs | 2 +- .../src/app/main/ui/components/progress.cljs | 2 +- .../src/app/main/ui/components/progress.scss | 2 +- .../app/main/ui/components/radio_buttons.cljs | 2 +- .../app/main/ui/components/radio_buttons.scss | 2 +- .../main/ui/components/reorder_handler.cljs | 2 +- .../main/ui/components/reorder_handler.scss | 2 +- .../app/main/ui/components/search_bar.cljs | 2 +- .../app/main/ui/components/search_bar.scss | 2 +- .../src/app/main/ui/components/select.cljs | 2 +- .../src/app/main/ui/components/select.scss | 2 +- .../src/app/main/ui/components/title_bar.cljs | 2 +- .../src/app/main/ui/components/title_bar.scss | 2 +- frontend/src/app/main/ui/confirm.cljs | 2 +- frontend/src/app/main/ui/confirm.scss | 2 +- frontend/src/app/main/ui/context.cljs | 2 +- frontend/src/app/main/ui/cursors.clj | 2 +- frontend/src/app/main/ui/cursors.cljs | 2 +- frontend/src/app/main/ui/dashboard.cljs | 2 +- frontend/src/app/main/ui/dashboard.scss | 2 +- .../app/main/ui/dashboard/change_owner.cljs | 2 +- .../app/main/ui/dashboard/change_owner.scss | 2 +- .../src/app/main/ui/dashboard/comments.cljs | 2 +- .../src/app/main/ui/dashboard/comments.scss | 2 +- .../src/app/main/ui/dashboard/deleted.cljs | 2 +- .../src/app/main/ui/dashboard/deleted.scss | 2 +- .../src/app/main/ui/dashboard/file_menu.cljs | 2 +- frontend/src/app/main/ui/dashboard/files.cljs | 2 +- frontend/src/app/main/ui/dashboard/files.scss | 2 +- frontend/src/app/main/ui/dashboard/fonts.cljs | 2 +- frontend/src/app/main/ui/dashboard/fonts.scss | 2 +- frontend/src/app/main/ui/dashboard/grid.cljs | 2 +- frontend/src/app/main/ui/dashboard/grid.scss | 2 +- .../src/app/main/ui/dashboard/import.cljs | 2 +- .../src/app/main/ui/dashboard/import.scss | 2 +- .../app/main/ui/dashboard/inline_edition.cljs | 2 +- .../app/main/ui/dashboard/inline_edition.scss | 2 +- .../app/main/ui/dashboard/layout_toggle.cljs | 2 +- .../src/app/main/ui/dashboard/libraries.cljs | 2 +- .../src/app/main/ui/dashboard/libraries.scss | 2 +- .../src/app/main/ui/dashboard/pin_button.cljs | 2 +- .../src/app/main/ui/dashboard/pin_button.scss | 2 +- .../app/main/ui/dashboard/placeholder.cljs | 2 +- .../app/main/ui/dashboard/placeholder.scss | 2 +- .../app/main/ui/dashboard/project_menu.cljs | 2 +- .../src/app/main/ui/dashboard/projects.cljs | 2 +- .../src/app/main/ui/dashboard/projects.scss | 2 +- .../src/app/main/ui/dashboard/search.cljs | 2 +- .../src/app/main/ui/dashboard/search.scss | 2 +- .../src/app/main/ui/dashboard/sidebar.cljs | 2 +- .../src/app/main/ui/dashboard/sidebar.scss | 2 +- .../app/main/ui/dashboard/subscription.cljs | 2 +- .../app/main/ui/dashboard/subscription.scss | 2 +- frontend/src/app/main/ui/dashboard/team.cljs | 2 +- frontend/src/app/main/ui/dashboard/team.scss | 2 +- .../src/app/main/ui/dashboard/team_form.cljs | 2 +- .../src/app/main/ui/dashboard/team_form.scss | 2 +- .../src/app/main/ui/dashboard/templates.cljs | 2 +- .../src/app/main/ui/dashboard/templates.scss | 2 +- frontend/src/app/main/ui/delete_shared.cljs | 2 +- frontend/src/app/main/ui/delete_shared.scss | 2 +- frontend/src/app/main/ui/ds.cljs | 2 +- frontend/src/app/main/ui/ds/_borders.scss | 2 +- frontend/src/app/main/ui/ds/_sizes.scss | 2 +- frontend/src/app/main/ui/ds/_utils.scss | 2 +- .../src/app/main/ui/ds/buttons/_buttons.scss | 2 +- .../src/app/main/ui/ds/buttons/button.cljs | 2 +- .../src/app/main/ui/ds/buttons/button.scss | 2 +- .../app/main/ui/ds/buttons/button.stories.jsx | 2 +- .../src/app/main/ui/ds/buttons/buttons.mdx | 2 +- .../app/main/ui/ds/buttons/icon_button.cljs | 2 +- .../app/main/ui/ds/buttons/icon_button.scss | 2 +- .../ui/ds/buttons/icon_button.stories.jsx | 2 +- frontend/src/app/main/ui/ds/colors.scss | 2 +- .../src/app/main/ui/ds/controls/checkbox.cljs | 2 +- .../src/app/main/ui/ds/controls/checkbox.mdx | 2 +- .../src/app/main/ui/ds/controls/checkbox.scss | 2 +- .../main/ui/ds/controls/checkbox.stories.jsx | 2 +- .../src/app/main/ui/ds/controls/combobox.cljs | 2 +- .../src/app/main/ui/ds/controls/combobox.mdx | 2 +- .../src/app/main/ui/ds/controls/combobox.scss | 2 +- .../main/ui/ds/controls/combobox.stories.jsx | 2 +- .../src/app/main/ui/ds/controls/input.cljs | 2 +- .../src/app/main/ui/ds/controls/input.mdx | 2 +- .../src/app/main/ui/ds/controls/input.scss | 2 +- .../app/main/ui/ds/controls/input.stories.jsx | 2 +- .../ui/ds/controls/numeric-input.stories.jsx | 2 +- .../main/ui/ds/controls/numeric_input.cljs | 2 +- .../app/main/ui/ds/controls/numeric_input.mdx | 2 +- .../main/ui/ds/controls/numeric_input.scss | 2 +- .../main/ui/ds/controls/radio_buttons.cljs | 2 +- .../app/main/ui/ds/controls/radio_buttons.mdx | 2 +- .../main/ui/ds/controls/radio_buttons.scss | 2 +- .../ui/ds/controls/radio_buttons.stories.jsx | 2 +- .../src/app/main/ui/ds/controls/select.cljs | 2 +- .../src/app/main/ui/ds/controls/select.mdx | 2 +- .../src/app/main/ui/ds/controls/select.scss | 2 +- .../main/ui/ds/controls/select.stories.jsx | 2 +- .../controls/shared/dropdown_navigation.cljs | 2 +- .../main/ui/ds/controls/shared/option.cljs | 2 +- .../main/ui/ds/controls/shared/option.scss | 2 +- .../ds/controls/shared/options_dropdown.cljs | 2 +- .../ds/controls/shared/options_dropdown.scss | 2 +- .../ui/ds/controls/shared/render_option.cljs | 2 +- .../ui/ds/controls/shared/render_option.scss | 2 +- .../shared/searchable_options_dropdown.cljs | 2 +- .../shared/searchable_options_dropdown.scss | 2 +- .../ui/ds/controls/shared/token_option.cljs | 2 +- .../ui/ds/controls/shared/token_option.scss | 2 +- .../src/app/main/ui/ds/controls/switch.cljs | 2 +- .../src/app/main/ui/ds/controls/switch.mdx | 2 +- .../src/app/main/ui/ds/controls/switch.scss | 2 +- .../main/ui/ds/controls/switch.stories.jsx | 2 +- .../ds/controls/utilities/hint_message.cljs | 2 +- .../ds/controls/utilities/hint_message.scss | 2 +- .../ui/ds/controls/utilities/input_field.cljs | 2 +- .../ui/ds/controls/utilities/input_field.scss | 2 +- .../main/ui/ds/controls/utilities/label.cljs | 2 +- .../main/ui/ds/controls/utilities/label.scss | 2 +- .../ui/ds/controls/utilities/token_field.cljs | 2 +- .../ui/ds/controls/utilities/token_field.scss | 2 +- frontend/src/app/main/ui/ds/elevations.scss | 2 +- .../main/ui/ds/foundations/assets/icon.clj | 2 +- .../main/ui/ds/foundations/assets/icon.cljs | 2 +- .../main/ui/ds/foundations/assets/icon.mdx | 2 +- .../main/ui/ds/foundations/assets/icon.scss | 2 +- .../main/ui/ds/foundations/assets/raw_svg.clj | 2 +- .../ui/ds/foundations/assets/raw_svg.cljs | 2 +- .../main/ui/ds/foundations/assets/raw_svg.mdx | 2 +- .../main/ui/ds/foundations/typography.cljs | 2 +- .../ui/ds/foundations/typography/heading.cljs | 2 +- .../ui/ds/foundations/typography/heading.mdx | 2 +- .../ui/ds/foundations/typography/heading.scss | 2 +- .../ui/ds/foundations/typography/text.cljs | 2 +- .../ui/ds/foundations/typography/text.mdx | 2 +- .../ui/ds/foundations/typography/text.scss | 2 +- .../ds/foundations/typography/typography.mdx | 2 +- .../utilities/token/token_status.scss | 2 +- frontend/src/app/main/ui/ds/helpers.cljs | 2 +- .../app/main/ui/ds/layers/layer_button.cljs | 2 +- .../app/main/ui/ds/layers/layer_button.scss | 2 +- frontend/src/app/main/ui/ds/layout/modal.cljs | 2 +- frontend/src/app/main/ui/ds/layout/modal.mdx | 2 +- frontend/src/app/main/ui/ds/layout/modal.scss | 2 +- .../app/main/ui/ds/layout/modal.stories.jsx | 2 +- .../app/main/ui/ds/layout/tab_switcher.cljs | 2 +- .../app/main/ui/ds/layout/tab_switcher.mdx | 2 +- .../app/main/ui/ds/layout/tab_switcher.scss | 2 +- .../ui/ds/layout/tab_switcher.stories.jsx | 2 +- frontend/src/app/main/ui/ds/mixins.scss | 2 +- .../main/ui/ds/notifications/actionable.cljs | 2 +- .../main/ui/ds/notifications/actionable.scss | 2 +- .../context-notification.stories.jsx | 2 +- .../notifications/context_notification.cljs | 2 +- .../ui/ds/notifications/notifications.mdx | 2 +- .../shared/notification_pill.cljs | 2 +- .../shared/notification_pill.scss | 2 +- .../app/main/ui/ds/notifications/toast.cljs | 2 +- .../app/main/ui/ds/notifications/toast.scss | 2 +- .../ui/ds/notifications/toast.stories.jsx | 2 +- .../src/app/main/ui/ds/product/avatar.cljs | 2 +- .../src/app/main/ui/ds/product/avatar.scss | 2 +- frontend/src/app/main/ui/ds/product/cta.cljs | 2 +- frontend/src/app/main/ui/ds/product/cta.scss | 2 +- .../main/ui/ds/product/empty_placeholder.cljs | 2 +- .../main/ui/ds/product/empty_placeholder.scss | 2 +- .../app/main/ui/ds/product/empty_state.cljs | 2 +- .../app/main/ui/ds/product/empty_state.mdx | 2 +- .../app/main/ui/ds/product/empty_state.scss | 2 +- .../ui/ds/product/empty_state.stories.jsx | 2 +- .../main/ui/ds/product/input_with_meta.cljs | 2 +- .../main/ui/ds/product/input_with_meta.mdx | 2 +- .../main/ui/ds/product/input_with_meta.scss | 2 +- .../ui/ds/product/input_with_meta.stories.jsx | 2 +- .../src/app/main/ui/ds/product/loader.cljs | 2 +- .../src/app/main/ui/ds/product/loader.scss | 2 +- .../src/app/main/ui/ds/product/milestone.cljs | 2 +- .../src/app/main/ui/ds/product/milestone.scss | 2 +- .../main/ui/ds/product/milestone_group.cljs | 2 +- .../main/ui/ds/product/milestone_group.scss | 2 +- .../app/main/ui/ds/product/panel_title.cljs | 2 +- .../app/main/ui/ds/product/panel_title.mdx | 2 +- .../app/main/ui/ds/product/panel_title.scss | 2 +- frontend/src/app/main/ui/ds/spacing.scss | 2 +- frontend/src/app/main/ui/ds/storybook.cljs | 2 +- frontend/src/app/main/ui/ds/tooltip.cljs | 2 +- .../src/app/main/ui/ds/tooltip/tooltip.cljs | 2 +- .../src/app/main/ui/ds/tooltip/tooltip.mdx | 2 +- .../src/app/main/ui/ds/tooltip/tooltip.scss | 2 +- .../main/ui/ds/tooltip/tooltip.stories.jsx | 2 +- frontend/src/app/main/ui/ds/typography.scss | 2 +- .../src/app/main/ui/ds/utilities/date.cljs | 2 +- .../src/app/main/ui/ds/utilities/date.scss | 2 +- .../src/app/main/ui/ds/utilities/swatch.cljs | 2 +- .../src/app/main/ui/ds/utilities/swatch.mdx | 2 +- .../src/app/main/ui/ds/utilities/swatch.scss | 2 +- .../main/ui/ds/utilities/swatch.stories.jsx | 2 +- frontend/src/app/main/ui/ds/z-index.scss | 2 +- frontend/src/app/main/ui/error_boundary.cljs | 2 +- frontend/src/app/main/ui/exports/assets.cljs | 2 +- frontend/src/app/main/ui/exports/assets.scss | 2 +- frontend/src/app/main/ui/exports/files.cljs | 2 +- frontend/src/app/main/ui/exports/files.scss | 2 +- frontend/src/app/main/ui/flex_controls.cljs | 2 +- .../src/app/main/ui/flex_controls/gap.cljs | 2 +- .../src/app/main/ui/flex_controls/margin.cljs | 2 +- .../app/main/ui/flex_controls/padding.cljs | 2 +- frontend/src/app/main/ui/formats.cljs | 2 +- frontend/src/app/main/ui/forms.cljs | 2 +- frontend/src/app/main/ui/forms.scss | 2 +- frontend/src/app/main/ui/frame_preview.cljs | 2 +- .../src/app/main/ui/hooks/floating_drag.cljs | 2 +- .../app/main/ui/hooks/mutable_observer.cljs | 2 +- frontend/src/app/main/ui/hooks/resize.cljs | 2 +- frontend/src/app/main/ui/icons.clj | 2 +- frontend/src/app/main/ui/icons.cljs | 2 +- .../src/app/main/ui/inspect/annotation.cljs | 2 +- .../src/app/main/ui/inspect/annotation.scss | 2 +- .../src/app/main/ui/inspect/attributes.cljs | 2 +- .../src/app/main/ui/inspect/attributes.scss | 2 +- .../app/main/ui/inspect/attributes/blur.cljs | 2 +- .../app/main/ui/inspect/attributes/blur.scss | 2 +- .../main/ui/inspect/attributes/common.cljs | 2 +- .../main/ui/inspect/attributes/common.scss | 2 +- .../app/main/ui/inspect/attributes/fill.cljs | 2 +- .../app/main/ui/inspect/attributes/fill.scss | 2 +- .../main/ui/inspect/attributes/geometry.cljs | 2 +- .../main/ui/inspect/attributes/geometry.scss | 2 +- .../main/ui/inspect/attributes/layout.cljs | 2 +- .../main/ui/inspect/attributes/layout.scss | 2 +- .../ui/inspect/attributes/layout_element.cljs | 2 +- .../ui/inspect/attributes/layout_element.scss | 2 +- .../main/ui/inspect/attributes/shadow.cljs | 2 +- .../main/ui/inspect/attributes/shadow.scss | 2 +- .../main/ui/inspect/attributes/stroke.cljs | 2 +- .../main/ui/inspect/attributes/stroke.scss | 2 +- .../app/main/ui/inspect/attributes/svg.cljs | 2 +- .../app/main/ui/inspect/attributes/svg.scss | 2 +- .../app/main/ui/inspect/attributes/text.cljs | 2 +- .../app/main/ui/inspect/attributes/text.scss | 2 +- .../main/ui/inspect/attributes/variant.cljs | 2 +- .../main/ui/inspect/attributes/variant.scss | 2 +- .../ui/inspect/attributes/visibility.cljs | 2 +- .../ui/inspect/attributes/visibility.scss | 2 +- frontend/src/app/main/ui/inspect/code.cljs | 2 +- frontend/src/app/main/ui/inspect/code.scss | 2 +- .../app/main/ui/inspect/common/colors.cljs | 2 +- .../main/ui/inspect/common/typography.cljs | 2 +- frontend/src/app/main/ui/inspect/exports.cljs | 2 +- frontend/src/app/main/ui/inspect/exports.scss | 2 +- .../src/app/main/ui/inspect/left_sidebar.cljs | 2 +- .../src/app/main/ui/inspect/left_sidebar.scss | 2 +- frontend/src/app/main/ui/inspect/render.cljs | 2 +- .../app/main/ui/inspect/right_sidebar.cljs | 2 +- .../app/main/ui/inspect/right_sidebar.scss | 2 +- .../main/ui/inspect/selection_feedback.cljs | 2 +- frontend/src/app/main/ui/inspect/styles.cljs | 2 +- frontend/src/app/main/ui/inspect/styles.scss | 2 +- .../main/ui/inspect/styles/panels/blur.cljs | 2 +- .../main/ui/inspect/styles/panels/fill.cljs | 2 +- .../ui/inspect/styles/panels/geometry.cljs | 2 +- .../main/ui/inspect/styles/panels/layout.cljs | 2 +- .../inspect/styles/panels/layout_element.cljs | 2 +- .../main/ui/inspect/styles/panels/shadow.cljs | 2 +- .../main/ui/inspect/styles/panels/stroke.cljs | 2 +- .../main/ui/inspect/styles/panels/svg.cljs | 2 +- .../main/ui/inspect/styles/panels/text.cljs | 2 +- .../main/ui/inspect/styles/panels/text.scss | 2 +- .../inspect/styles/panels/tokens_panel.cljs | 2 +- .../inspect/styles/panels/tokens_panel.scss | 2 +- .../inspect/styles/panels/variants_panel.cljs | 2 +- .../ui/inspect/styles/panels/visibility.cljs | 2 +- .../styles/property_detail_copiable.cljs | 2 +- .../styles/property_detail_copiable.scss | 2 +- .../styles/rows/color_properties_row.cljs | 2 +- .../styles/rows/color_properties_row.scss | 2 +- .../inspect/styles/rows/properties_row.cljs | 2 +- .../inspect/styles/rows/properties_row.scss | 2 +- .../app/main/ui/inspect/styles/style_box.cljs | 2 +- .../app/main/ui/inspect/styles/style_box.scss | 2 +- frontend/src/app/main/ui/measurements.cljs | 2 +- frontend/src/app/main/ui/measurements.scss | 2 +- frontend/src/app/main/ui/modal.cljs | 2 +- frontend/src/app/main/ui/modal.scss | 2 +- frontend/src/app/main/ui/nitrate/entry.cljs | 2 +- .../nitrate_activation_success_modal.cljs | 2 +- .../nitrate_activation_success_modal.scss | 2 +- .../nitrate_code_activation_modal.cljs | 2 +- .../nitrate_code_activation_modal.scss | 2 +- .../src/app/main/ui/nitrate/nitrate_form.cljs | 2 +- .../src/app/main/ui/nitrate/nitrate_form.scss | 2 +- frontend/src/app/main/ui/notifications.cljs | 2 +- .../src/app/main/ui/notifications/badge.cljs | 2 +- .../src/app/main/ui/notifications/badge.scss | 2 +- .../notifications/context_notification.cljs | 2 +- .../notifications/context_notification.scss | 2 +- .../ui/notifications/inline_notification.cljs | 2 +- .../ui/notifications/inline_notification.scss | 2 +- .../src/app/main/ui/onboarding/questions.cljs | 2 +- .../src/app/main/ui/onboarding/questions.scss | 2 +- .../app/main/ui/onboarding/team_choice.cljs | 2 +- .../app/main/ui/onboarding/team_choice.scss | 2 +- .../src/app/main/ui/onboarding/templates.cljs | 2 +- frontend/src/app/main/ui/releases.cljs | 2 +- frontend/src/app/main/ui/releases/common.cljs | 2 +- frontend/src/app/main/ui/releases/common.scss | 2 +- frontend/src/app/main/ui/releases/v1_10.cljs | 2 +- frontend/src/app/main/ui/releases/v1_11.cljs | 2 +- frontend/src/app/main/ui/releases/v1_12.cljs | 2 +- frontend/src/app/main/ui/releases/v1_13.cljs | 2 +- frontend/src/app/main/ui/releases/v1_14.cljs | 2 +- frontend/src/app/main/ui/releases/v1_15.cljs | 2 +- frontend/src/app/main/ui/releases/v1_16.cljs | 2 +- frontend/src/app/main/ui/releases/v1_17.cljs | 2 +- frontend/src/app/main/ui/releases/v1_18.cljs | 2 +- frontend/src/app/main/ui/releases/v1_19.cljs | 2 +- frontend/src/app/main/ui/releases/v1_4.cljs | 2 +- frontend/src/app/main/ui/releases/v1_5.cljs | 2 +- frontend/src/app/main/ui/releases/v1_6.cljs | 2 +- frontend/src/app/main/ui/releases/v1_7.cljs | 2 +- frontend/src/app/main/ui/releases/v1_8.cljs | 2 +- frontend/src/app/main/ui/releases/v1_9.cljs | 2 +- frontend/src/app/main/ui/releases/v2_0.cljs | 2 +- frontend/src/app/main/ui/releases/v2_0.scss | 2 +- frontend/src/app/main/ui/releases/v2_1.cljs | 2 +- frontend/src/app/main/ui/releases/v2_1.scss | 2 +- frontend/src/app/main/ui/releases/v2_10.cljs | 2 +- frontend/src/app/main/ui/releases/v2_10.scss | 2 +- frontend/src/app/main/ui/releases/v2_11.cljs | 2 +- frontend/src/app/main/ui/releases/v2_11.scss | 2 +- frontend/src/app/main/ui/releases/v2_12.cljs | 2 +- frontend/src/app/main/ui/releases/v2_12.scss | 2 +- frontend/src/app/main/ui/releases/v2_13.cljs | 2 +- frontend/src/app/main/ui/releases/v2_13.scss | 2 +- frontend/src/app/main/ui/releases/v2_14.cljs | 2 +- frontend/src/app/main/ui/releases/v2_14.scss | 2 +- frontend/src/app/main/ui/releases/v2_15.cljs | 2 +- frontend/src/app/main/ui/releases/v2_15.scss | 2 +- frontend/src/app/main/ui/releases/v2_16.cljs | 2 +- frontend/src/app/main/ui/releases/v2_16.scss | 2 +- frontend/src/app/main/ui/releases/v2_17.cljs | 2 +- frontend/src/app/main/ui/releases/v2_17.scss | 2 +- frontend/src/app/main/ui/releases/v2_2.cljs | 2 +- frontend/src/app/main/ui/releases/v2_2.scss | 2 +- frontend/src/app/main/ui/releases/v2_3.cljs | 2 +- frontend/src/app/main/ui/releases/v2_3.scss | 2 +- frontend/src/app/main/ui/releases/v2_4.cljs | 2 +- frontend/src/app/main/ui/releases/v2_4.scss | 2 +- frontend/src/app/main/ui/releases/v2_5.cljs | 2 +- frontend/src/app/main/ui/releases/v2_5.scss | 2 +- frontend/src/app/main/ui/releases/v2_6.cljs | 2 +- frontend/src/app/main/ui/releases/v2_6.scss | 2 +- frontend/src/app/main/ui/releases/v2_7.cljs | 2 +- frontend/src/app/main/ui/releases/v2_7.scss | 2 +- frontend/src/app/main/ui/releases/v2_8.cljs | 2 +- frontend/src/app/main/ui/releases/v2_8.scss | 2 +- frontend/src/app/main/ui/releases/v2_9.cljs | 2 +- frontend/src/app/main/ui/releases/v2_9.scss | 2 +- frontend/src/app/main/ui/routes.cljs | 2 +- frontend/src/app/main/ui/settings.cljs | 2 +- frontend/src/app/main/ui/settings.scss | 2 +- .../app/main/ui/settings/change_email.cljs | 2 +- .../app/main/ui/settings/change_email.scss | 2 +- .../app/main/ui/settings/delete_account.cljs | 2 +- .../app/main/ui/settings/delete_account.scss | 2 +- .../src/app/main/ui/settings/feedback.cljs | 2 +- .../src/app/main/ui/settings/feedback.scss | 2 +- .../settings/import_shortcuts_diff_modal.cljs | 2 +- .../settings/import_shortcuts_diff_modal.scss | 2 +- .../app/main/ui/settings/integrations.cljs | 2 +- .../app/main/ui/settings/integrations.scss | 2 +- .../app/main/ui/settings/notifications.cljs | 2 +- .../app/main/ui/settings/notifications.scss | 2 +- .../src/app/main/ui/settings/options.cljs | 2 +- .../src/app/main/ui/settings/options.scss | 2 +- .../src/app/main/ui/settings/password.cljs | 2 +- .../src/app/main/ui/settings/password.scss | 2 +- .../src/app/main/ui/settings/profile.cljs | 2 +- .../src/app/main/ui/settings/profile.scss | 2 +- .../ui/settings/restore_shortcuts_modal.scss | 2 +- .../src/app/main/ui/settings/shortcuts.scss | 2 +- .../src/app/main/ui/settings/sidebar.cljs | 2 +- .../src/app/main/ui/settings/sidebar.scss | 2 +- .../app/main/ui/settings/subscription.scss | 2 +- frontend/src/app/main/ui/shapes/attrs.cljs | 2 +- frontend/src/app/main/ui/shapes/bool.cljs | 2 +- frontend/src/app/main/ui/shapes/circle.cljs | 2 +- .../src/app/main/ui/shapes/custom_stroke.cljs | 2 +- frontend/src/app/main/ui/shapes/embed.cljs | 2 +- frontend/src/app/main/ui/shapes/export.cljs | 2 +- frontend/src/app/main/ui/shapes/fills.cljs | 2 +- frontend/src/app/main/ui/shapes/filters.cljs | 2 +- frontend/src/app/main/ui/shapes/frame.cljs | 2 +- .../src/app/main/ui/shapes/gradients.cljs | 2 +- .../main/ui/shapes/grid_layout_viewer.cljs | 2 +- frontend/src/app/main/ui/shapes/group.cljs | 2 +- frontend/src/app/main/ui/shapes/image.cljs | 2 +- frontend/src/app/main/ui/shapes/mask.cljs | 2 +- frontend/src/app/main/ui/shapes/path.cljs | 2 +- frontend/src/app/main/ui/shapes/rect.cljs | 2 +- frontend/src/app/main/ui/shapes/shape.cljs | 2 +- frontend/src/app/main/ui/shapes/svg_defs.cljs | 2 +- frontend/src/app/main/ui/shapes/svg_raw.cljs | 2 +- frontend/src/app/main/ui/shapes/text.cljs | 2 +- .../src/app/main/ui/shapes/text/fo_text.cljs | 2 +- .../app/main/ui/shapes/text/fontfaces.cljs | 2 +- .../app/main/ui/shapes/text/html_text.cljs | 2 +- .../src/app/main/ui/shapes/text/styles.cljs | 2 +- .../src/app/main/ui/shapes/text/svg_text.cljs | 2 +- frontend/src/app/main/ui/shortcuts.cljs | 2 +- frontend/src/app/main/ui/shortcuts.scss | 2 +- frontend/src/app/main/ui/static.cljs | 2 +- frontend/src/app/main/ui/static.scss | 2 +- frontend/src/app/main/ui/viewer.cljs | 2 +- frontend/src/app/main/ui/viewer.scss | 2 +- frontend/src/app/main/ui/viewer/comments.cljs | 2 +- frontend/src/app/main/ui/viewer/comments.scss | 2 +- frontend/src/app/main/ui/viewer/header.cljs | 2 +- frontend/src/app/main/ui/viewer/header.scss | 2 +- frontend/src/app/main/ui/viewer/inspect.cljs | 2 +- frontend/src/app/main/ui/viewer/inspect.scss | 2 +- .../src/app/main/ui/viewer/interactions.cljs | 2 +- .../src/app/main/ui/viewer/interactions.scss | 2 +- frontend/src/app/main/ui/viewer/login.cljs | 2 +- frontend/src/app/main/ui/viewer/login.scss | 2 +- frontend/src/app/main/ui/viewer/shapes.cljs | 2 +- .../src/app/main/ui/viewer/share_link.cljs | 2 +- .../src/app/main/ui/viewer/share_link.scss | 2 +- .../src/app/main/ui/viewer/thumbnails.cljs | 2 +- .../src/app/main/ui/viewer/thumbnails.scss | 2 +- .../app/main/ui/viewer/viewport_common.cljs | 2 +- .../src/app/main/ui/viewer/viewport_wasm.cljs | 2 +- frontend/src/app/main/ui/workspace.cljs | 2 +- frontend/src/app/main/ui/workspace.scss | 2 +- .../app/main/ui/workspace/color_palette.cljs | 2 +- .../app/main/ui/workspace/color_palette.scss | 2 +- .../ui/workspace/color_palette_ctx_menu.cljs | 2 +- .../ui/workspace/color_palette_ctx_menu.scss | 2 +- .../app/main/ui/workspace/colorpicker.cljs | 2 +- .../app/main/ui/workspace/colorpicker.scss | 2 +- .../workspace/colorpicker/color_inputs.cljs | 2 +- .../workspace/colorpicker/color_inputs.scss | 2 +- .../workspace/colorpicker/color_tokens.cljs | 2 +- .../workspace/colorpicker/color_tokens.scss | 2 +- .../ui/workspace/colorpicker/gradients.cljs | 2 +- .../ui/workspace/colorpicker/gradients.scss | 2 +- .../ui/workspace/colorpicker/harmony.cljs | 2 +- .../ui/workspace/colorpicker/harmony.scss | 2 +- .../main/ui/workspace/colorpicker/hsva.cljs | 2 +- .../main/ui/workspace/colorpicker/hsva.scss | 2 +- .../ui/workspace/colorpicker/libraries.cljs | 2 +- .../ui/workspace/colorpicker/libraries.scss | 2 +- .../main/ui/workspace/colorpicker/ramp.cljs | 2 +- .../main/ui/workspace/colorpicker/ramp.scss | 2 +- .../ui/workspace/colorpicker/shortcuts.cljs | 2 +- .../colorpicker/slider_selector.cljs | 2 +- .../colorpicker/slider_selector.scss | 2 +- .../src/app/main/ui/workspace/comments.cljs | 2 +- .../src/app/main/ui/workspace/comments.scss | 2 +- .../ui/workspace/components_debugger.cljs | 2 +- .../ui/workspace/components_debugger.scss | 2 +- .../app/main/ui/workspace/context_menu.cljs | 2 +- .../app/main/ui/workspace/context_menu.scss | 2 +- .../app/main/ui/workspace/coordinates.cljs | 2 +- .../app/main/ui/workspace/coordinates.scss | 2 +- .../app/main/ui/workspace/left_header.cljs | 2 +- .../app/main/ui/workspace/left_header.scss | 2 +- .../src/app/main/ui/workspace/libraries.cljs | 2 +- .../src/app/main/ui/workspace/libraries.scss | 2 +- .../src/app/main/ui/workspace/main_menu.cljs | 2 +- .../src/app/main/ui/workspace/main_menu.scss | 2 +- frontend/src/app/main/ui/workspace/nudge.cljs | 2 +- frontend/src/app/main/ui/workspace/nudge.scss | 2 +- .../src/app/main/ui/workspace/palette.cljs | 2 +- .../src/app/main/ui/workspace/palette.scss | 2 +- .../src/app/main/ui/workspace/plugins.cljs | 2 +- .../src/app/main/ui/workspace/plugins.scss | 2 +- .../src/app/main/ui/workspace/presence.cljs | 2 +- .../src/app/main/ui/workspace/presence.scss | 2 +- .../app/main/ui/workspace/right_header.cljs | 2 +- .../app/main/ui/workspace/right_header.scss | 2 +- .../src/app/main/ui/workspace/shapes.cljs | 2 +- .../app/main/ui/workspace/shapes/bool.cljs | 2 +- .../app/main/ui/workspace/shapes/common.cljs | 2 +- .../app/main/ui/workspace/shapes/debug.cljs | 2 +- .../app/main/ui/workspace/shapes/frame.cljs | 2 +- .../shapes/frame/dynamic_modifiers.cljs | 2 +- .../app/main/ui/workspace/shapes/group.cljs | 2 +- .../app/main/ui/workspace/shapes/path.cljs | 2 +- .../main/ui/workspace/shapes/path/editor.cljs | 2 +- .../app/main/ui/workspace/shapes/svg_raw.cljs | 2 +- .../app/main/ui/workspace/shapes/text.cljs | 2 +- .../main/ui/workspace/shapes/text/editor.cljs | 2 +- .../shapes/text/text_edition_outline.cljs | 2 +- .../ui/workspace/shapes/text/v2_editor.cljs | 2 +- .../ui/workspace/shapes/text/v3_editor.cljs | 2 +- .../shapes/text/viewport_texts_html.cljs | 2 +- .../src/app/main/ui/workspace/sidebar.cljs | 2 +- .../src/app/main/ui/workspace/sidebar.scss | 2 +- .../app/main/ui/workspace/sidebar/assets.cljs | 2 +- .../app/main/ui/workspace/sidebar/assets.scss | 2 +- .../ui/workspace/sidebar/assets/colors.cljs | 2 +- .../ui/workspace/sidebar/assets/colors.scss | 2 +- .../ui/workspace/sidebar/assets/common.cljs | 2 +- .../ui/workspace/sidebar/assets/common.scss | 2 +- .../workspace/sidebar/assets/components.cljs | 2 +- .../workspace/sidebar/assets/components.scss | 2 +- .../sidebar/assets/file_library.cljs | 2 +- .../sidebar/assets/file_library.scss | 2 +- .../ui/workspace/sidebar/assets/groups.cljs | 2 +- .../ui/workspace/sidebar/assets/groups.scss | 2 +- .../sidebar/assets/typographies.cljs | 2 +- .../sidebar/assets/typographies.scss | 2 +- .../ui/workspace/sidebar/common/sidebar.scss | 2 +- .../app/main/ui/workspace/sidebar/debug.cljs | 2 +- .../app/main/ui/workspace/sidebar/debug.scss | 2 +- .../workspace/sidebar/debug_shape_info.cljs | 2 +- .../workspace/sidebar/debug_shape_info.scss | 2 +- .../main/ui/workspace/sidebar/history.cljs | 2 +- .../main/ui/workspace/sidebar/history.scss | 2 +- .../main/ui/workspace/sidebar/layer_item.cljs | 2 +- .../main/ui/workspace/sidebar/layer_item.scss | 2 +- .../main/ui/workspace/sidebar/layer_name.cljs | 2 +- .../main/ui/workspace/sidebar/layer_name.scss | 2 +- .../app/main/ui/workspace/sidebar/layers.cljs | 2 +- .../app/main/ui/workspace/sidebar/layers.scss | 2 +- .../main/ui/workspace/sidebar/options.cljs | 2 +- .../main/ui/workspace/sidebar/options.scss | 2 +- .../ui/workspace/sidebar/options/common.cljs | 2 +- .../ui/workspace/sidebar/options/common.scss | 2 +- .../ui/workspace/sidebar/options/drawing.cljs | 2 +- .../sidebar/options/drawing/frame.cljs | 2 +- .../sidebar/options/drawing/frame.scss | 2 +- .../sidebar/options/menus/align.cljs | 2 +- .../sidebar/options/menus/align.scss | 2 +- .../workspace/sidebar/options/menus/blur.cljs | 2 +- .../workspace/sidebar/options/menus/blur.scss | 2 +- .../workspace/sidebar/options/menus/bool.cljs | 2 +- .../workspace/sidebar/options/menus/bool.scss | 2 +- .../sidebar/options/menus/border_radius.scss | 2 +- .../options/menus/color_selection.cljs | 2 +- .../options/menus/color_selection.scss | 2 +- .../sidebar/options/menus/component.cljs | 2 +- .../sidebar/options/menus/component.scss | 2 +- .../sidebar/options/menus/constraints.cljs | 2 +- .../sidebar/options/menus/constraints.scss | 2 +- .../sidebar/options/menus/exports.cljs | 2 +- .../sidebar/options/menus/exports.scss | 2 +- .../workspace/sidebar/options/menus/fill.cljs | 2 +- .../workspace/sidebar/options/menus/fill.scss | 2 +- .../sidebar/options/menus/frame_grid.cljs | 2 +- .../sidebar/options/menus/frame_grid.scss | 2 +- .../sidebar/options/menus/grid_cell.cljs | 2 +- .../sidebar/options/menus/grid_cell.scss | 2 +- .../options/menus/input_wrapper_tokens.scss | 2 +- .../sidebar/options/menus/interactions.cljs | 2 +- .../sidebar/options/menus/interactions.scss | 2 +- .../sidebar/options/menus/layer.cljs | 2 +- .../sidebar/options/menus/layer.scss | 2 +- .../options/menus/layout_container.cljs | 2 +- .../options/menus/layout_container.scss | 2 +- .../sidebar/options/menus/layout_item.cljs | 2 +- .../sidebar/options/menus/layout_item.scss | 2 +- .../sidebar/options/menus/measures.cljs | 2 +- .../sidebar/options/menus/measures.scss | 2 +- .../sidebar/options/menus/shadow.cljs | 2 +- .../sidebar/options/menus/shadow.scss | 2 +- .../sidebar/options/menus/stroke.cljs | 2 +- .../sidebar/options/menus/stroke.scss | 2 +- .../sidebar/options/menus/svg_attrs.cljs | 2 +- .../sidebar/options/menus/svg_attrs.scss | 2 +- .../workspace/sidebar/options/menus/text.cljs | 2 +- .../workspace/sidebar/options/menus/text.scss | 2 +- .../options/menus/token_typography_row.cljs | 2 +- .../options/menus/token_typography_row.scss | 2 +- .../sidebar/options/menus/typography.cljs | 2 +- .../sidebar/options/menus/typography.scss | 2 +- .../options/menus/variants_help_modal.cljs | 2 +- .../options/menus/variants_help_modal.scss | 2 +- .../ui/workspace/sidebar/options/page.cljs | 2 +- .../ui/workspace/sidebar/options/page.scss | 2 +- .../sidebar/options/rows/color_row.cljs | 2 +- .../sidebar/options/rows/color_row.scss | 2 +- .../sidebar/options/rows/shadow_row.cljs | 2 +- .../sidebar/options/rows/shadow_row.scss | 2 +- .../sidebar/options/rows/stroke_row.cljs | 2 +- .../sidebar/options/rows/stroke_row.scss | 2 +- .../sidebar/options/shapes/bool.cljs | 2 +- .../sidebar/options/shapes/circle.cljs | 2 +- .../sidebar/options/shapes/frame.cljs | 2 +- .../sidebar/options/shapes/group.cljs | 2 +- .../sidebar/options/shapes/group.scss | 2 +- .../sidebar/options/shapes/multiple.cljs | 2 +- .../sidebar/options/shapes/multiple.scss | 2 +- .../sidebar/options/shapes/path.cljs | 2 +- .../sidebar/options/shapes/rect.cljs | 2 +- .../sidebar/options/shapes/svg_raw.cljs | 2 +- .../sidebar/options/shapes/text.cljs | 2 +- .../main/ui/workspace/sidebar/shortcuts.cljs | 2 +- .../main/ui/workspace/sidebar/shortcuts.scss | 2 +- .../main/ui/workspace/sidebar/sitemap.cljs | 2 +- .../main/ui/workspace/sidebar/sitemap.scss | 2 +- .../main/ui/workspace/sidebar/versions.cljs | 2 +- .../main/ui/workspace/sidebar/versions.scss | 2 +- .../app/main/ui/workspace/text_palette.cljs | 2 +- .../app/main/ui/workspace/text_palette.scss | 2 +- .../ui/workspace/text_palette_ctx_menu.cljs | 2 +- .../ui/workspace/text_palette_ctx_menu.scss | 2 +- .../app/main/ui/workspace/tokens/export.cljs | 2 +- .../app/main/ui/workspace/tokens/export.scss | 2 +- .../ui/workspace/tokens/export/modal.cljs | 2 +- .../ui/workspace/tokens/export/modal.scss | 2 +- .../app/main/ui/workspace/tokens/import.scss | 2 +- .../ui/workspace/tokens/import/modal.cljs | 2 +- .../ui/workspace/tokens/import/modal.scss | 2 +- .../workspace/tokens/import_from_library.cljs | 2 +- .../workspace/tokens/import_from_library.scss | 2 +- .../main/ui/workspace/tokens/management.scss | 2 +- .../tokens/management/context_menu.cljs | 2 +- .../tokens/management/context_menu.scss | 2 +- .../tokens/management/forms/color.cljs | 2 +- .../forms/controls/color_input.cljs | 2 +- .../management/forms/controls/combobox.cljs | 2 +- .../management/forms/controls/combobox.scss | 2 +- .../forms/controls/combobox_navigation.cljs | 2 +- .../forms/controls/floating_dropdown.cljs | 2 +- .../forms/controls/fonts_combobox.cljs | 2 +- .../forms/controls/fonts_combobox.scss | 2 +- .../management/forms/controls/input.cljs | 2 +- .../management/forms/controls/select.cljs | 2 +- .../forms/controls/token_parsing.cljs | 2 +- .../tokens/management/forms/font_family.cljs | 2 +- .../management/forms/form_container.cljs | 2 +- .../tokens/management/forms/generic_form.cljs | 2 +- .../tokens/management/forms/generic_form.scss | 2 +- .../tokens/management/forms/modals.cljs | 2 +- .../tokens/management/forms/modals.scss | 2 +- .../management/forms/rename_node_modal.scss | 2 +- .../tokens/management/forms/shadow.cljs | 2 +- .../tokens/management/forms/shadow.scss | 2 +- .../tokens/management/forms/typography.cljs | 2 +- .../tokens/management/forms/typography.scss | 2 +- .../ui/workspace/tokens/management/group.cljs | 2 +- .../tokens/management/node_context_menu.scss | 2 +- .../tokens/management/token_pill.cljs | 2 +- .../tokens/management/token_pill.scss | 2 +- .../tokens/management/token_tree.cljs | 2 +- .../tokens/management/token_tree.scss | 2 +- .../ui/workspace/tokens/remapping_modal.cljs | 2 +- .../ui/workspace/tokens/remapping_modal.scss | 2 +- .../app/main/ui/workspace/tokens/sets.cljs | 2 +- .../app/main/ui/workspace/tokens/sets.scss | 2 +- .../workspace/tokens/sets/context_menu.cljs | 2 +- .../workspace/tokens/sets/context_menu.scss | 2 +- .../main/ui/workspace/tokens/sets/lists.cljs | 2 +- .../main/ui/workspace/tokens/sets/lists.scss | 2 +- .../main/ui/workspace/tokens/settings.cljs | 2 +- .../ui/workspace/tokens/settings/menu.cljs | 2 +- .../ui/workspace/tokens/settings/menu.scss | 2 +- .../app/main/ui/workspace/tokens/sidebar.cljs | 2 +- .../app/main/ui/workspace/tokens/sidebar.scss | 2 +- .../app/main/ui/workspace/tokens/themes.cljs | 2 +- .../app/main/ui/workspace/tokens/themes.scss | 2 +- .../workspace/tokens/themes/create_modal.cljs | 2 +- .../workspace/tokens/themes/create_modal.scss | 2 +- .../tokens/themes/theme_selector.cljs | 2 +- .../tokens/themes/theme_selector.scss | 2 +- .../app/main/ui/workspace/top_toolbar.cljs | 2 +- .../app/main/ui/workspace/top_toolbar.scss | 2 +- .../src/app/main/ui/workspace/viewport.cljs | 2 +- .../src/app/main/ui/workspace/viewport.scss | 2 +- .../main/ui/workspace/viewport/actions.cljs | 2 +- .../main/ui/workspace/viewport/comments.cljs | 2 +- .../main/ui/workspace/viewport/comments.scss | 2 +- .../app/main/ui/workspace/viewport/debug.cljs | 2 +- .../main/ui/workspace/viewport/drawarea.cljs | 2 +- .../ui/workspace/viewport/frame_grid.cljs | 2 +- .../main/ui/workspace/viewport/gradients.cljs | 2 +- .../viewport/grid_layout_editor.cljs | 2 +- .../viewport/grid_layout_editor.scss | 2 +- .../main/ui/workspace/viewport/guides.cljs | 2 +- .../app/main/ui/workspace/viewport/hooks.cljs | 2 +- .../ui/workspace/viewport/interactions.cljs | 2 +- .../main/ui/workspace/viewport/outline.cljs | 2 +- .../ui/workspace/viewport/path_actions.cljs | 2 +- .../ui/workspace/viewport/path_actions.scss | 2 +- .../ui/workspace/viewport/pixel_overlay.cljs | 2 +- .../ui/workspace/viewport/pixel_overlay.scss | 2 +- .../main/ui/workspace/viewport/presence.cljs | 2 +- .../main/ui/workspace/viewport/presence.scss | 2 +- .../main/ui/workspace/viewport/rulers.cljs | 2 +- .../ui/workspace/viewport/scroll_bars.cljs | 2 +- .../main/ui/workspace/viewport/selection.cljs | 2 +- .../ui/workspace/viewport/snap_distances.cljs | 2 +- .../ui/workspace/viewport/snap_points.cljs | 2 +- .../main/ui/workspace/viewport/top_bar.cljs | 2 +- .../main/ui/workspace/viewport/top_bar.scss | 2 +- .../app/main/ui/workspace/viewport/utils.cljs | 2 +- .../ui/workspace/viewport/viewport_ref.cljs | 2 +- .../main/ui/workspace/viewport/widgets.cljs | 2 +- .../main/ui/workspace/viewport/widgets.scss | 2 +- .../app/main/ui/workspace/viewport_wasm.cljs | 2 +- .../app/main/ui/workspace/viewport_wasm.scss | 2 +- .../ui/workspace/webgl_unavailable_modal.cljs | 2 +- .../ui/workspace/webgl_unavailable_modal.scss | 2 +- frontend/src/app/main/worker.cljs | 2 +- frontend/src/app/plugins.cljs | 2 +- frontend/src/app/plugins/api.cljs | 2 +- frontend/src/app/plugins/comments.cljs | 2 +- frontend/src/app/plugins/events.cljs | 2 +- frontend/src/app/plugins/exports.cljs | 2 +- frontend/src/app/plugins/file.cljs | 2 +- frontend/src/app/plugins/fills.cljs | 2 +- frontend/src/app/plugins/flags.cljs | 2 +- frontend/src/app/plugins/flex.cljs | 2 +- frontend/src/app/plugins/fonts.cljs | 2 +- frontend/src/app/plugins/format.cljs | 2 +- frontend/src/app/plugins/gradients.cljs | 2 +- frontend/src/app/plugins/grid.cljs | 2 +- frontend/src/app/plugins/history.cljs | 2 +- frontend/src/app/plugins/image_data.cljs | 2 +- frontend/src/app/plugins/library.cljs | 2 +- frontend/src/app/plugins/local_storage.cljs | 2 +- frontend/src/app/plugins/page.cljs | 2 +- frontend/src/app/plugins/parser.cljs | 2 +- frontend/src/app/plugins/public_utils.cljs | 2 +- frontend/src/app/plugins/reflow.cljs | 2 +- frontend/src/app/plugins/register.cljs | 2 +- frontend/src/app/plugins/ruler_guides.cljs | 2 +- frontend/src/app/plugins/shadows.cljs | 2 +- frontend/src/app/plugins/shape.cljs | 2 +- frontend/src/app/plugins/strokes.cljs | 2 +- frontend/src/app/plugins/system_events.cljs | 2 +- frontend/src/app/plugins/text.cljs | 2 +- frontend/src/app/plugins/tokens.cljs | 2 +- frontend/src/app/plugins/tracks.cljs | 2 +- frontend/src/app/plugins/user.cljs | 2 +- frontend/src/app/plugins/utils.cljs | 2 +- frontend/src/app/plugins/viewport.cljs | 2 +- frontend/src/app/rasterizer.cljs | 2 +- frontend/src/app/render.cljs | 2 +- frontend/src/app/render_wasm.cljs | 2 +- frontend/src/app/render_wasm/api.cljs | 2 +- frontend/src/app/render_wasm/api/enums.cljs | 2 +- frontend/src/app/render_wasm/api/fonts.cljs | 2 +- frontend/src/app/render_wasm/api/texts.cljs | 2 +- frontend/src/app/render_wasm/api/webgl.cljs | 2 +- .../src/app/render_wasm/deserializers.cljs | 2 +- frontend/src/app/render_wasm/gesture.cljs | 2 +- frontend/src/app/render_wasm/performance.cljc | 2 +- .../src/app/render_wasm/rulers_state.cljs | 2 +- frontend/src/app/render_wasm/shape.cljs | 2 +- frontend/src/app/render_wasm/svg_fills.cljs | 2 +- frontend/src/app/render_wasm/svg_filters.cljs | 2 +- frontend/src/app/render_wasm/text_editor.cljs | 2 +- frontend/src/app/util/array.cljs | 2 +- frontend/src/app/util/avatars.cljs | 2 +- frontend/src/app/util/browser_history.js | 2 +- frontend/src/app/util/cache.cljs | 2 +- frontend/src/app/util/clipboard.cljs | 2 +- frontend/src/app/util/clipboard.js | 2 +- frontend/src/app/util/code_beautify.cljs | 2 +- frontend/src/app/util/code_gen.cljs | 2 +- frontend/src/app/util/code_gen/common.cljs | 2 +- .../src/app/util/code_gen/markup_html.cljs | 2 +- .../src/app/util/code_gen/markup_svg.cljs | 2 +- frontend/src/app/util/code_gen/style_css.cljs | 2 +- .../app/util/code_gen/style_css_formats.cljs | 2 +- .../app/util/code_gen/style_css_values.cljs | 2 +- frontend/src/app/util/code_highlight.cljs | 2 +- frontend/src/app/util/color.cljs | 2 +- frontend/src/app/util/debug.cljs | 2 +- frontend/src/app/util/dom.cljs | 2 +- frontend/src/app/util/dom/dnd.cljs | 2 +- frontend/src/app/util/extends.cljs | 2 +- frontend/src/app/util/forms.cljs | 2 +- frontend/src/app/util/functions.cljs | 2 +- frontend/src/app/util/globals.js | 2 +- frontend/src/app/util/http.cljs | 2 +- frontend/src/app/util/i18n.cljs | 2 +- frontend/src/app/util/json.cljs | 2 +- frontend/src/app/util/kdtree.cljs | 2 +- frontend/src/app/util/keyboard.cljs | 2 +- frontend/src/app/util/modules.clj | 2 +- frontend/src/app/util/modules.cljs | 2 +- frontend/src/app/util/mouse.cljs | 2 +- frontend/src/app/util/navigator.cljs | 2 +- frontend/src/app/util/object.cljc | 2 +- frontend/src/app/util/path/arc_to_curve.js | 2 +- .../src/app/util/path/simplify_curve.cljs | 2 +- frontend/src/app/util/perf.clj | 2 +- frontend/src/app/util/perf.cljs | 2 +- frontend/src/app/util/queue.cljs | 2 +- frontend/src/app/util/range_tree.js | 2 +- frontend/src/app/util/rxops.cljs | 2 +- frontend/src/app/util/session_state.cljs | 2 +- frontend/src/app/util/shape_icon.cljs | 2 +- frontend/src/app/util/simple_math.cljs | 2 +- frontend/src/app/util/sse.cljs | 2 +- frontend/src/app/util/storage.cljs | 2 +- frontend/src/app/util/strings.cljs | 2 +- frontend/src/app/util/text/content.cljs | 2 +- .../src/app/util/text/content/from_dom.cljs | 2 +- .../src/app/util/text/content/styles.cljs | 2 +- .../src/app/util/text/content/to_dom.cljs | 2 +- frontend/src/app/util/text/ui.cljs | 2 +- frontend/src/app/util/text_editor.cljs | 2 +- frontend/src/app/util/text_position_data.js | 2 +- frontend/src/app/util/text_svg_position.cljs | 2 +- frontend/src/app/util/theme.cljs | 2 +- frontend/src/app/util/thumbnails.cljs | 2 +- frontend/src/app/util/timers.cljs | 2 +- frontend/src/app/util/webapi.cljs | 2 +- frontend/src/app/util/websocket.cljs | 2 +- frontend/src/app/util/worker.cljs | 2 +- frontend/src/app/util/zip.cljs | 2 +- frontend/src/app/worker.cljs | 2 +- frontend/src/app/worker/impl.cljs | 2 +- frontend/src/app/worker/import.cljs | 2 +- frontend/src/app/worker/index.cljs | 2 +- frontend/src/app/worker/messages.cljs | 2 +- frontend/src/app/worker/selection.cljs | 2 +- frontend/src/app/worker/snap.cljs | 2 +- frontend/src/app/worker/thumbnails.cljs | 2 +- frontend/src/debug.cljs | 2 +- frontend/src/features.cljs | 2 +- .../frontend_tests/basic_shapes_test.cljs | 2 +- .../frontend_tests/code_gen_style_test.cljs | 2 +- .../composable_tests/comp/nodes.cljs | 2 +- .../composable_tests/comp/setups.cljs | 2 +- .../composable_tests/comp/sync_test.cljs | 2 +- .../frontend_tests/composable_tests/core.cljs | 2 +- .../composable_tests/interpreter.cljs | 2 +- .../test/frontend_tests/copy_as_svg_test.cljs | 2 +- .../frontend_tests/data/dashboard_test.cljs | 2 +- .../data/exports_assets_test.cljs | 2 +- .../frontend_tests/data/nitrate_test.cljs | 2 +- .../frontend_tests/data/profile_test.cljs | 2 +- .../test/frontend_tests/data/repo_test.cljs | 2 +- .../test/frontend_tests/data/store_test.cljs | 2 +- .../frontend_tests/data/uploads_test.cljs | 2 +- .../test/frontend_tests/data/viewer_test.cljs | 2 +- .../data/workspace_colors_test.cljs | 2 +- .../data/workspace_comments_test.cljs | 2 +- .../data/workspace_interactions_test.cljs | 2 +- .../data/workspace_mcp_test.cljs | 2 +- .../data/workspace_media_test.cljs | 2 +- .../data/workspace_pages_test.cljs | 2 +- .../data/workspace_path_edition_test.cljs | 2 +- .../data/workspace_reflow_test.cljs | 2 +- .../data/workspace_shortcuts_test.cljs | 2 +- .../data/workspace_stats_test.cljs | 2 +- .../data/workspace_texts_test.cljs | 2 +- .../data/workspace_thumbnails_test.cljs | 2 +- frontend/test/frontend_tests/errors_test.cljs | 2 +- frontend/test/frontend_tests/fonts_test.cljs | 2 +- .../test/frontend_tests/helpers/events.cljs | 2 +- .../test/frontend_tests/helpers/http.cljs | 2 +- .../frontend_tests/helpers/libraries.cljs | 2 +- .../test/frontend_tests/helpers/mock.cljc | 2 +- .../test/frontend_tests/helpers/pages.cljs | 2 +- .../test/frontend_tests/helpers/state.cljs | 2 +- .../test/frontend_tests/helpers/wasm.cljs | 2 +- .../frontend_tests/helpers_shapes_test.cljs | 2 +- .../logic/comp_remove_swap_slots_test.cljs | 2 +- .../logic/components_and_tokens.cljs | 2 +- .../logic/copying_and_duplicating_test.cljs | 2 +- .../logic/frame_guides_test.cljs | 2 +- .../frontend_tests/logic/groups_test.cljs | 2 +- .../logic/nudge_selected_shapes_test.cljs | 2 +- .../logic/pasting_in_containers_test.cljs | 2 +- .../sidebar_transform_coalescing_test.cljs | 2 +- .../logic/update_position_test.cljs | 2 +- .../test/frontend_tests/main_errors_test.cljs | 2 +- .../frontend_tests/plugins/comments_test.cljs | 2 +- .../plugins/context_shapes_test.cljs | 2 +- .../frontend_tests/plugins/file_test.cljs | 2 +- .../frontend_tests/plugins/format_test.cljs | 2 +- .../frontend_tests/plugins/grid_test.cljs | 2 +- .../plugins/interactions_test.cljs | 2 +- .../frontend_tests/plugins/library_test.cljs | 2 +- .../plugins/local_storage_test.cljs | 2 +- .../plugins/page_active_validation_test.cljs | 2 +- .../frontend_tests/plugins/page_test.cljs | 2 +- .../frontend_tests/plugins/parser_test.cljs | 2 +- .../plugins/shape_bugfixes_test.cljs | 2 +- .../frontend_tests/plugins/text_test.cljs | 2 +- .../frontend_tests/plugins/tokens_test.cljs | 2 +- .../frontend_tests/plugins/utils_test.cljs | 2 +- .../plugins/value_objects_test.cljs | 2 +- .../render_dimensions_test.cljs | 2 +- .../render_wasm/process_objects_test.cljs | 2 +- .../text_editor_apply_styles_test.cljs | 2 +- .../text_editor_caret_color_test.cljs | 2 +- frontend/test/frontend_tests/setup_test.cljs | 2 +- .../test/frontend_tests/svg_fills_test.cljs | 2 +- .../test/frontend_tests/svg_filters_test.cljs | 2 +- .../text_editor_paste_guard_test.cljs | 2 +- .../tokens/copy_paste_props_test.cljs | 2 +- .../frontend_tests/tokens/helpers/state.cljs | 2 +- .../frontend_tests/tokens/helpers/tokens.cljs | 2 +- .../tokens/import_export_test.cljs | 2 +- .../tokens/logic/token_actions_test.cljs | 2 +- .../tokens/logic/token_data_test.cljs | 2 +- .../tokens/logic/token_remapping_test.cljs | 2 +- .../tokens/style_dictionary_test.cljs | 2 +- .../tokens/token_errors_test.cljs | 2 +- .../tokens/workspace_tokens_remap_test.cljs | 2 +- .../ui/colorpicker_token_set_order_test.cljs | 2 +- .../ui/comments_clustering_test.cljs | 2 +- .../ui/comments_position_modifier_test.cljs | 2 +- .../ui/ds_controls_numeric_input_test.cljs | 2 +- .../ui/gradient_handlers_test.cljs | 2 +- .../ui/layout_container_multiple_test.cljs | 2 +- .../ui/measures_menu_props_test.cljs | 2 +- .../test/frontend_tests/ui/routes_test.cljs | 2 +- .../ui/settings_password_schema_test.cljs | 2 +- .../frontend_tests/util/dom/dnd_test.cljs | 2 +- .../frontend_tests/util_clipboard_test.cljs | 2 +- .../test/frontend_tests/util_object_test.cljs | 2 +- .../frontend_tests/util_range_tree_test.cljs | 2 +- .../frontend_tests/util_simple_math_test.cljs | 2 +- .../frontend_tests/util_text_editor_test.cljs | 2 +- .../test/frontend_tests/util_webapi_test.cljs | 2 +- .../test/frontend_tests/util_zip_test.cljs | 2 +- .../test/frontend_tests/worker_snap_test.cljs | 2 +- frontend/text-editor/src/editor/Event.js | 2 +- frontend/text-editor/src/editor/TextEditor.js | 2 +- .../text-editor/src/editor/clipboard/copy.js | 2 +- .../text-editor/src/editor/clipboard/cut.js | 2 +- .../text-editor/src/editor/clipboard/index.js | 2 +- .../text-editor/src/editor/clipboard/paste.js | 2 +- .../src/editor/commands/deleteByCut.js | 2 +- .../editor/commands/deleteContentBackward.js | 2 +- .../editor/commands/deleteContentForward.js | 2 +- .../text-editor/src/editor/commands/index.js | 2 +- .../src/editor/commands/insertParagraph.js | 2 +- .../src/editor/commands/insertText.js | 2 +- .../text-editor/src/editor/content/Text.js | 2 +- .../src/editor/content/dom/Content.js | 2 +- .../src/editor/content/dom/Editor.js | 2 +- .../src/editor/content/dom/Element.js | 2 +- .../src/editor/content/dom/LineBreak.js | 2 +- .../src/editor/content/dom/Paragraph.js | 2 +- .../src/editor/content/dom/Root.js | 2 +- .../src/editor/content/dom/Style.js | 2 +- .../src/editor/content/dom/TextNode.js | 2 +- .../editor/content/dom/TextNodeIterator.js | 2 +- .../src/editor/content/dom/TextSpan.js | 2 +- .../editor/controllers/ChangeController.js | 2 +- .../editor/controllers/SelectionController.js | 2 +- .../editor/debug/SelectionControllerDebug.js | 2 +- .../src/editor/layout/LayoutType.js | 2 +- library/README.md | 2 +- library/src/lib/builder.cljs | 2 +- library/src/lib/export.cljs | 2 +- manage.sh | 2 +- plugins/README.md | 2 +- scripts/replace-copyright.sh | 210 ++++++++++++++++++ 1642 files changed, 1851 insertions(+), 1641 deletions(-) create mode 100755 scripts/replace-copyright.sh diff --git a/README.md b/README.md index 5a1d6d60c2..0def50aa6a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,6 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` Penpot is a Kaleidos’ [open source project](https://kaleidos.net/) diff --git a/backend/dev/script-fix-sobjects.clj b/backend/dev/script-fix-sobjects.clj index 3194ce0716..114e21684a 100644 --- a/backend/dev/script-fix-sobjects.clj +++ b/backend/dev/script-fix-sobjects.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; This is an example on how it can be executed: ;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj diff --git a/backend/dev/user.clj b/backend/dev/user.clj index 16908f4cab..9e13c8e7af 100644 --- a/backend/dev/user.clj +++ b/backend/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py index 56141571e7..e0e7780595 100755 --- a/backend/scripts/manage.py +++ b/backend/scripts/manage.py @@ -4,7 +4,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # -# Copyright (c) KALEIDOS INC Sucursal en España SL +# Copyright (c) KALEIDOS SUBSIDIARY SL import argparse import json diff --git a/backend/src/app/auth.clj b/backend/src/app/auth.clj index 1f978f357c..69d3f705b6 100644 --- a/backend/src/app/auth.clj +++ b/backend/src/app/auth.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth (:require diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj index 659e990c91..8dae1291d6 100644 --- a/backend/src/app/auth/ldap.clj +++ b/backend/src/app/auth/ldap.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth.ldap (:require diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 164edafd76..e860d5e7db 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth.oidc "OIDC client implementation." diff --git a/backend/src/app/auth/passwords.clj b/backend/src/app/auth/passwords.clj index cc75b202b7..e3a3e1df52 100644 --- a/backend/src/app/auth/passwords.clj +++ b/backend/src/app/auth/passwords.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.auth.passwords "Password strength validation using Passay library." diff --git a/backend/src/app/binfile/cleaner.clj b/backend/src/app/binfile/cleaner.clj index 66964b5358..c846a90149 100644 --- a/backend/src/app/binfile/cleaner.clj +++ b/backend/src/app/binfile/cleaner.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.cleaner "A collection of helpers for perform cleaning of artifacts; mainly diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 3e4402be92..b0329ce65a 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.common "A binfile related file processing common code, used for different diff --git a/backend/src/app/binfile/migrations.clj b/backend/src/app/binfile/migrations.clj index ce60eb0f68..9fa5a4b4e9 100644 --- a/backend/src/app/binfile/migrations.clj +++ b/backend/src/app/binfile/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.migrations "A binfile related migrations handling" diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 5f1834cb74..0f00a7564e 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v1 "A custom, perfromance and efficiency focused binfile format impl" diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index 347074586b..9e9644c47c 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v2 "A sqlite3 based binary file exportation with support for exportation diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index eab49e1eb3..314320ef39 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.binfile.v3 "A ZIP based binary file exportation" diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index f02136b1ca..ba38a76a88 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:refer-clojure :exclude [get]) diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index f10b0089a0..70ceca3819 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.db (:refer-clojure :exclude [get run!]) diff --git a/backend/src/app/db/sql.clj b/backend/src/app/db/sql.clj index dc0f54a01a..b81e0c3832 100644 --- a/backend/src/app/db/sql.clj +++ b/backend/src/app/db/sql.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.db.sql (:refer-clojure :exclude [update]) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index e069b2908b..9f2557f92e 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email "Main api for send emails." diff --git a/backend/src/app/email/blacklist.clj b/backend/src/app/email/blacklist.clj index e54d546f4f..b56cfd42be 100644 --- a/backend/src/app/email/blacklist.clj +++ b/backend/src/app/email/blacklist.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email.blacklist "Email blacklist provider" diff --git a/backend/src/app/email/whitelist.clj b/backend/src/app/email/whitelist.clj index c8c0f5b310..8b08c0c599 100644 --- a/backend/src/app/email/whitelist.clj +++ b/backend/src/app/email/whitelist.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.email.whitelist "Email whitelist provider" diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index 8e7ff9d978..e65aa5d930 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.fdata "A `fdata/*` related feature migration helpers" diff --git a/backend/src/app/features/file_migrations.clj b/backend/src/app/features/file_migrations.clj index 2334cbc121..f7a4ffdcb1 100644 --- a/backend/src/app/features/file_migrations.clj +++ b/backend/src/app/features/file_migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.file-migrations "Backend specific code for file migrations. Implemented as permanent feature of files." diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 2c29b2b5de..6bc70fd52d 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.file-snapshots (:require diff --git a/backend/src/app/features/logical_deletion.clj b/backend/src/app/features/logical_deletion.clj index 0b87006616..5dc80ab4f1 100644 --- a/backend/src/app/features/logical_deletion.clj +++ b/backend/src/app/features/logical_deletion.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.features.logical-deletion "A code related to handle logical deletion mechanism" diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index e991fd9849..d3496a58e5 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http (:require diff --git a/backend/src/app/http/access_token.clj b/backend/src/app/http/access_token.clj index 02d877b1df..57f19fdda8 100644 --- a/backend/src/app/http/access_token.clj +++ b/backend/src/app/http/access_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.access-token (:require diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 98ba0d8ba5..9c415fdad4 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.assets "Assets related handlers." diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 3dddc1045d..a3c2f03ec7 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.awsns "AWS SNS webhook handler for bounces." diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index bba77f9aa0..db5ec6ffbe 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.client "Http client abstraction layer. diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 26cac774f6..7c5956b375 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.debug (:refer-clojure :exclude [error-handler]) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 2393abf129..4f713a5f6b 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.errors "A errors handling for the http server." diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj index 507a518e5c..bcee432b1a 100644 --- a/backend/src/app/http/management.clj +++ b/backend/src/app/http/management.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.management "Internal mangement HTTP API" diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index 6cb8e6b8b7..22c1cc29d9 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.middleware (:require diff --git a/backend/src/app/http/security.clj b/backend/src/app/http/security.clj index af4f875b10..5529421cb1 100644 --- a/backend/src/app/http/security.clj +++ b/backend/src/app/http/security.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.security "Additional security layer middlewares" diff --git a/backend/src/app/http/session.clj b/backend/src/app/http/session.clj index 5782b3452e..914dfc169c 100644 --- a/backend/src/app/http/session.clj +++ b/backend/src/app/http/session.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.session (:refer-clojure :exclude [read]) diff --git a/backend/src/app/http/sse.clj b/backend/src/app/http/sse.clj index 8d6290571e..0fc7c385bd 100644 --- a/backend/src/app/http/sse.clj +++ b/backend/src/app/http/sse.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.sse "SSE (server sent events) helpers" diff --git a/backend/src/app/http/websocket.clj b/backend/src/app/http/websocket.clj index 2517b42725..a9631be1d3 100644 --- a/backend/src/app/http/websocket.clj +++ b/backend/src/app/http/websocket.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http.websocket "A penpot notification service for file cooperative edition." diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index 6ded7befaf..86778ab55f 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit "Services related to the user activity (audit log)." diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index e577351e31..1c5f953d44 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit.archive-task (:require diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj index 24af10cfed..6fbd1710b4 100644 --- a/backend/src/app/loggers/audit/gc_task.clj +++ b/backend/src/app/loggers/audit/gc_task.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.audit.gc-task (:require diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index 9f9c1bebaa..cd17dcd51d 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.database "A specific logger impl that persists errors on the database." diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index 82a3c134e6..71a61afb37 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.mattermost "A mattermost integration for error reporting." diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index a57fd6bca2..83df9825cc 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.loggers.webhooks "A mattermost integration for error reporting." diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 743a17804b..dc51498bb3 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main (:require diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index dc23e3483c..c00ac00b24 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media "Media & Font postprocessing. diff --git a/backend/src/app/media/local.clj b/backend/src/app/media/local.clj index f86e46c02e..f3811e83aa 100644 --- a/backend/src/app/media/local.clj +++ b/backend/src/app/media/local.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.local "Local media processing via ImageMagick and FontForge shell commands." diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj index 0b5a0a4a42..1004035703 100644 --- a/backend/src/app/media/remote.clj +++ b/backend/src/app/media/remote.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.remote "Remote media processing via the media-processor HTTP service." diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj index f67414501c..6f0d9465d5 100644 --- a/backend/src/app/media/sanitize.clj +++ b/backend/src/app/media/sanitize.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.sanitize "Image EOF truncation helpers — strips trailing data after image EOF diff --git a/backend/src/app/media/svg.clj b/backend/src/app/media/svg.clj index 1de52d4030..287322d460 100644 --- a/backend/src/app/media/svg.clj +++ b/backend/src/app/media/svg.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.svg "SVG parsing, sanitization, and info extraction. diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj index 17dbd80e71..5da86fb99f 100644 --- a/backend/src/app/media/validation.clj +++ b/backend/src/app/media/validation.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.media.validation "Schemas and validation functions for media uploads. diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index fa1b8bab0b..63145eb5b5 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.metrics (:refer-clojure :exclude [run!]) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2edb8614d5..9255a14f03 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations (:require diff --git a/backend/src/app/migrations/clj/migration_0023.clj b/backend/src/app/migrations/clj/migration_0023.clj index 2ee4151de4..d41d55cab8 100644 --- a/backend/src/app/migrations/clj/migration_0023.clj +++ b/backend/src/app/migrations/clj/migration_0023.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.clj.migration-0023 (:require diff --git a/backend/src/app/migrations/clj/migration_0145.clj b/backend/src/app/migrations/clj/migration_0145.clj index d8a0f0fc21..7d7e46fafb 100644 --- a/backend/src/app/migrations/clj/migration_0145.clj +++ b/backend/src/app/migrations/clj/migration_0145.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.clj.migration-0145 "Migrate plugins references on profiles" diff --git a/backend/src/app/migrations/media_refs.clj b/backend/src/app/migrations/media_refs.clj index eb624ace88..7be652794c 100644 --- a/backend/src/app/migrations/media_refs.clj +++ b/backend/src/app/migrations/media_refs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.migrations.media-refs "A media refs migration fixer script" diff --git a/backend/src/app/msgbus.clj b/backend/src/app/msgbus.clj index 85828d3f23..f6a05ff586 100644 --- a/backend/src/app/msgbus.clj +++ b/backend/src/app/msgbus.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.msgbus "The msgbus abstraction implemented using redis as underlying backend." diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 2d7c4e22f7..bd036667c2 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.nitrate "Module that make calls to the external nitrate aplication" diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index be9e331dda..2539b9eddc 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.redis "The msgbus abstraction implemented using redis as underlying backend." diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index bdc912ef2d..8828044dde 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc (:require diff --git a/backend/src/app/rpc/climit.clj b/backend/src/app/rpc/climit.clj index 60d14af09b..daff05550b 100644 --- a/backend/src/app/rpc/climit.clj +++ b/backend/src/app/rpc/climit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.climit "Concurrencly limiter for RPC." diff --git a/backend/src/app/rpc/commands/access_token.clj b/backend/src/app/rpc/commands/access_token.clj index 0aa20ba3c1..09aa9189c2 100644 --- a/backend/src/app/rpc/commands/access_token.clj +++ b/backend/src/app/rpc/commands/access_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.access-token (:require diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 69777a1ddd..fceb258cf8 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.audit "Audit Log related RPC methods" diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index 78d2ac45c4..b134d2a0c9 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.auth (:require diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 2f57a6624b..b97cc899ac 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.binfile (:refer-clojure :exclude [assert]) diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 6a926d1e98..118543dd38 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.comments (:require diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 13b7a2f374..3459410248 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.demo "A demo specific mutations." diff --git a/backend/src/app/rpc/commands/error_reports.clj b/backend/src/app/rpc/commands/error_reports.clj index dfb6e6ba49..2ed30879f3 100644 --- a/backend/src/app/rpc/commands/error_reports.clj +++ b/backend/src/app/rpc/commands/error_reports.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.error-reports "RPC methods for listing and fetching server error reports. diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj index b70341fc33..346697abbc 100644 --- a/backend/src/app/rpc/commands/feedback.clj +++ b/backend/src/app/rpc/commands/feedback.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.feedback "A general purpose feedback module." diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 323ddf6c3f..412da68667 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files (:require diff --git a/backend/src/app/rpc/commands/files_create.clj b/backend/src/app/rpc/commands/files_create.clj index dd297e223b..bb8f49694a 100644 --- a/backend/src/app/rpc/commands/files_create.clj +++ b/backend/src/app/rpc/commands/files_create.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-create (:require diff --git a/backend/src/app/rpc/commands/files_share.clj b/backend/src/app/rpc/commands/files_share.clj index 0e8c184ede..6643554a06 100644 --- a/backend/src/app/rpc/commands/files_share.clj +++ b/backend/src/app/rpc/commands/files_share.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-share "Share link related rpc mutation methods." diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 7baac52428..cb3e93285f 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-snapshot (:require diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index f4d9b538cb..74d277ee79 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-thumbnails (:require diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 0c19c1c315..8313c61f63 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.files-update (:require diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 7b5ac6ac4e..317f30eb9b 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.fonts (:require diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index 6620e28b30..20321d3d0a 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.ldap (:require diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 41931f53ec..99d822314e 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.management "A collection of RPC methods for manage the files, projects and team organization." diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 3dff04fa10..64718f09a8 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.media (:require diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index a476ce0dbf..1ceec28848 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.nitrate "Nitrate API for Penpot. Provides nitrate-related endpoints to be called diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 114f07be3e..c26fda758d 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.profile (:require diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj index cfb03a2f0d..618ee5cd01 100644 --- a/backend/src/app/rpc/commands/projects.clj +++ b/backend/src/app/rpc/commands/projects.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.projects (:require diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj index 7b60e6db30..1186b15554 100644 --- a/backend/src/app/rpc/commands/search.clj +++ b/backend/src/app/rpc/commands/search.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.search (:require diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index ab2a0628a5..101238e44f 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.teams (:require diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index b96cb0a8ce..c1a9301402 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.teams-invitations (:require diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 8db7afcd8b..3d7a53d28d 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.verify-token (:require diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 9333800af6..bc3205adf4 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.viewer (:require diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 85051e8ad7..2476128e7c 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.commands.webhooks (:require diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index 288bec4b9c..9837c5e92e 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.cond "Conditional loading middleware. diff --git a/backend/src/app/rpc/doc.clj b/backend/src/app/rpc/doc.clj index d5f73bf4de..205c01d975 100644 --- a/backend/src/app/rpc/doc.clj +++ b/backend/src/app/rpc/doc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.doc "API autogenerated documentation." diff --git a/backend/src/app/rpc/helpers.clj b/backend/src/app/rpc/helpers.clj index 60c7e524cb..8ad1f841a4 100644 --- a/backend/src/app/rpc/helpers.clj +++ b/backend/src/app/rpc/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.helpers "General purpose RPC helpers." diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 317850b126..88ee5d161f 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.management.exporter (:require diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 5df54b7e7f..2ae619997f 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.management.nitrate "Internal Nitrate HTTP RPC API. Provides authenticated access to diff --git a/backend/src/app/rpc/nitrate/emails_helper.clj b/backend/src/app/rpc/nitrate/emails_helper.clj index 73d72c178c..5a9379e358 100644 --- a/backend/src/app/rpc/nitrate/emails_helper.clj +++ b/backend/src/app/rpc/nitrate/emails_helper.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.nitrate.emails-helper "Helpers for organization SSO notification emails triggered by Nitrate integration." diff --git a/backend/src/app/rpc/nitrate/organization_helper.clj b/backend/src/app/rpc/nitrate/organization_helper.clj index ed5d918e8b..e5cabf4c72 100644 --- a/backend/src/app/rpc/nitrate/organization_helper.clj +++ b/backend/src/app/rpc/nitrate/organization_helper.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.nitrate.organization-helper "Shared Nitrate organization query helpers." diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index ec3d7c89d9..ad68556493 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.notifications (:require diff --git a/backend/src/app/rpc/permissions.clj b/backend/src/app/rpc/permissions.clj index 36ff9b2c23..77653017db 100644 --- a/backend/src/app/rpc/permissions.clj +++ b/backend/src/app/rpc/permissions.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.permissions "A permission checking helper factories." diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 0a7004cc54..aa00ec029c 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.quotes "Penpot resource usage quotes." diff --git a/backend/src/app/rpc/retry.clj b/backend/src/app/rpc/retry.clj index 7bc52f1649..7665eabbed 100644 --- a/backend/src/app/rpc/retry.clj +++ b/backend/src/app/rpc/retry.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.retry (:require diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index 3e05eb9837..d78897e50b 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rpc.rlimit "Rate limit strategies implementation for RPC services. diff --git a/backend/src/app/setup.clj b/backend/src/app/setup.clj index 4a6ef8ec62..2afb5955b4 100644 --- a/backend/src/app/setup.clj +++ b/backend/src/app/setup.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup "Initial data setup of instance." diff --git a/backend/src/app/setup/clock.clj b/backend/src/app/setup/clock.clj index 22f04831e5..4b8644019e 100644 --- a/backend/src/app/setup/clock.clj +++ b/backend/src/app/setup/clock.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.clock "A service/module that manages the system clock and allows runtime diff --git a/backend/src/app/setup/keys.clj b/backend/src/app/setup/keys.clj index 25fdc9a854..aac9d60bd7 100644 --- a/backend/src/app/setup/keys.clj +++ b/backend/src/app/setup/keys.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.keys "Keys derivation service." diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index c90120d22f..4cb45e31c1 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.templates "A service/module that is responsible for download, load & internally diff --git a/backend/src/app/setup/welcome_file.clj b/backend/src/app/setup/welcome_file.clj index 48887e53ed..f6874aafa5 100644 --- a/backend/src/app/setup/welcome_file.clj +++ b/backend/src/app/setup/welcome_file.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.setup.welcome-file (:require diff --git a/backend/src/app/srepl.clj b/backend/src/app/srepl.clj index 8faa741ab0..2f6935b226 100644 --- a/backend/src/app/srepl.clj +++ b/backend/src/app/srepl.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl "Server Repl." diff --git a/backend/src/app/srepl/binfile.clj b/backend/src/app/srepl/binfile.clj index badf02d98c..43cb69f8f3 100644 --- a/backend/src/app/srepl/binfile.clj +++ b/backend/src/app/srepl/binfile.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.binfile (:require diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index dc44047e32..383017f81d 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.cli "PREPL API for external usage (CLI or ADMIN)" diff --git a/backend/src/app/srepl/helpers.clj b/backend/src/app/srepl/helpers.clj index 658181635b..0836429f91 100644 --- a/backend/src/app/srepl/helpers.clj +++ b/backend/src/app/srepl/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.helpers "A main namespace for server repl." diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 6745858f0e..d6d81f38c6 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.main #_:clj-kondo/ignore diff --git a/backend/src/app/srepl/procs/fdata_storage.clj b/backend/src/app/srepl/procs/fdata_storage.clj index 5ed64e206e..5759aa43b9 100644 --- a/backend/src/app/srepl/procs/fdata_storage.clj +++ b/backend/src/app/srepl/procs/fdata_storage.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.fdata-storage (:require diff --git a/backend/src/app/srepl/procs/file_repair.clj b/backend/src/app/srepl/procs/file_repair.clj index fba086433a..f863f250cf 100644 --- a/backend/src/app/srepl/procs/file_repair.clj +++ b/backend/src/app/srepl/procs/file_repair.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.file-repair (:require diff --git a/backend/src/app/srepl/procs/media_refs.clj b/backend/src/app/srepl/procs/media_refs.clj index b1a492e226..fc6d0721d7 100644 --- a/backend/src/app/srepl/procs/media_refs.clj +++ b/backend/src/app/srepl/procs/media_refs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.media-refs (:require diff --git a/backend/src/app/srepl/procs/path_data.clj b/backend/src/app/srepl/procs/path_data.clj index a26ab288df..545cc2ff47 100644 --- a/backend/src/app/srepl/procs/path_data.clj +++ b/backend/src/app/srepl/procs/path_data.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.srepl.procs.path-data (:require diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index f30d8762ec..05e4fceb8c 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage "Objects storage abstraction layer." diff --git a/backend/src/app/storage/fs.clj b/backend/src/app/storage/fs.clj index bbeb3010c7..a90fecff88 100644 --- a/backend/src/app/storage/fs.clj +++ b/backend/src/app/storage/fs.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.fs (:require diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index c380293453..2aadca4b55 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.gc-deleted "A task responsible to permanently delete already marked as deleted diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index b7ace59ef3..1c412b593d 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.gc-touched "This task is part of the garbage collection process of storage diff --git a/backend/src/app/storage/impl.clj b/backend/src/app/storage/impl.clj index a4d61ee532..9b5a33b7dd 100644 --- a/backend/src/app/storage/impl.clj +++ b/backend/src/app/storage/impl.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.impl "Storage backends abstraction layer." diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index a56a23d99d..c55158a7ba 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.s3 "S3 Storage backend implementation." diff --git a/backend/src/app/storage/tmp.clj b/backend/src/app/storage/tmp.clj index b7a3076159..4448920d99 100644 --- a/backend/src/app/storage/tmp.clj +++ b/backend/src/app/storage/tmp.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.storage.tmp "Temporal files service all created files will be tried to clean after diff --git a/backend/src/app/system.clj b/backend/src/app/system.clj index a424bebcf1..487aea1a60 100644 --- a/backend/src/app/system.clj +++ b/backend/src/app/system.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.system) diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index 00d0046f27..95cdabef8b 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.delete-object "A generic task for object deletion cascade handling" diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 75a92665e4..f2e6ee16b5 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.file-gc "A maintenance task that is responsible of: purge unused file media, diff --git a/backend/src/app/tasks/file_gc_scheduler.clj b/backend/src/app/tasks/file_gc_scheduler.clj index 4f4061d814..e70703afaa 100644 --- a/backend/src/app/tasks/file_gc_scheduler.clj +++ b/backend/src/app/tasks/file_gc_scheduler.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.file-gc-scheduler "A maintenance task that is responsible of properly scheduling the diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index ac457d47b0..8b6c90e82d 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.objects-gc "A maintenance task that performs a general purpose garbage collection diff --git a/backend/src/app/tasks/offload_file_data.clj b/backend/src/app/tasks/offload_file_data.clj index d58eaba308..72dfd44be7 100644 --- a/backend/src/app/tasks/offload_file_data.clj +++ b/backend/src/app/tasks/offload_file_data.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.offload-file-data "A maintenance task responsible of moving file data from hot diff --git a/backend/src/app/tasks/tasks_gc.clj b/backend/src/app/tasks/tasks_gc.clj index a2a8f4bbc4..ee84e37714 100644 --- a/backend/src/app/tasks/tasks_gc.clj +++ b/backend/src/app/tasks/tasks_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.tasks-gc "A maintenance task that performs a cleanup of already executed tasks diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 4fb5f7d904..82696144db 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.telemetry "A task that is responsible to collect anonymous statistical diff --git a/backend/src/app/tasks/upload_session_gc.clj b/backend/src/app/tasks/upload_session_gc.clj index ef60f1c598..b5a6a1c078 100644 --- a/backend/src/app/tasks/upload_session_gc.clj +++ b/backend/src/app/tasks/upload_session_gc.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tasks.upload-session-gc "A maintenance task that deletes stalled (incomplete) upload sessions. diff --git a/backend/src/app/tokens.clj b/backend/src/app/tokens.clj index 71d2a7de88..372a3194ad 100644 --- a/backend/src/app/tokens.clj +++ b/backend/src/app/tokens.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.tokens "Tokens generation API." diff --git a/backend/src/app/util/blob.clj b/backend/src/app/util/blob.clj index 09df353da1..27cd5333ef 100644 --- a/backend/src/app/util/blob.clj +++ b/backend/src/app/util/blob.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.blob "A generic blob storage encoding. Mainly used for page data, page diff --git a/backend/src/app/util/cache.clj b/backend/src/app/util/cache.clj index 0414d52c87..d30a5de574 100644 --- a/backend/src/app/util/cache.clj +++ b/backend/src/app/util/cache.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cache "In-memory cache backed by Caffeine" diff --git a/backend/src/app/util/cron.clj b/backend/src/app/util/cron.clj index b8aeb89120..d3f9607cd8 100644 --- a/backend/src/app/util/cron.clj +++ b/backend/src/app/util/cron.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cron (:require diff --git a/backend/src/app/util/events.clj b/backend/src/app/util/events.clj index b42971510b..ffbd96a47c 100644 --- a/backend/src/app/util/events.clj +++ b/backend/src/app/util/events.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.events "A generic asynchronous events notifications subsystem; used mainly diff --git a/backend/src/app/util/inet.clj b/backend/src/app/util/inet.clj index 56c9bded0e..438298de5a 100644 --- a/backend/src/app/util/inet.clj +++ b/backend/src/app/util/inet.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.inet "INET addr parsing and validation helpers" diff --git a/backend/src/app/util/json.clj b/backend/src/app/util/json.clj index 80d2068e68..1b5d6cf681 100644 --- a/backend/src/app/util/json.clj +++ b/backend/src/app/util/json.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.json (:require diff --git a/backend/src/app/util/locks.clj b/backend/src/app/util/locks.clj index 0335b1b345..18ae45d313 100644 --- a/backend/src/app/util/locks.clj +++ b/backend/src/app/util/locks.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.locks "A syntactic helpers for using locks." diff --git a/backend/src/app/util/migrations.clj b/backend/src/app/util/migrations.clj index abdb5d87ca..f49f570b13 100644 --- a/backend/src/app/util/migrations.clj +++ b/backend/src/app/util/migrations.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.migrations (:require diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj index 35f5b0c976..6575ed7f3e 100644 --- a/backend/src/app/util/nio.clj +++ b/backend/src/app/util/nio.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.nio "NIO helpers for working with files and byte arrays. diff --git a/backend/src/app/util/objects_map.clj b/backend/src/app/util/objects_map.clj index 0407111e2c..6c4063e192 100644 --- a/backend/src/app/util/objects_map.clj +++ b/backend/src/app/util/objects_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.objects-map "Implements a specialized map-like data structure for store an UUID => diff --git a/backend/src/app/util/overrides.clj b/backend/src/app/util/overrides.clj index 4cd9ae464e..8c9ee23af0 100644 --- a/backend/src/app/util/overrides.clj +++ b/backend/src/app/util/overrides.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.overrides "A utility ns for declare default overrides over clojure runtime" diff --git a/backend/src/app/util/pointer_map.clj b/backend/src/app/util/pointer_map.clj index 6c95e89ad7..c8384480ce 100644 --- a/backend/src/app/util/pointer_map.clj +++ b/backend/src/app/util/pointer_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.pointer-map "Implements a map-like data structure that provides an entry point for diff --git a/backend/src/app/util/services.clj b/backend/src/app/util/services.clj index 340acb04f3..7e3d69fa72 100644 --- a/backend/src/app/util/services.clj +++ b/backend/src/app/util/services.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.services "A helpers and macros for define rpc like registry based services." diff --git a/backend/src/app/util/shell.clj b/backend/src/app/util/shell.clj index 61dd08e682..d705b02b3a 100644 --- a/backend/src/app/util/shell.clj +++ b/backend/src/app/util/shell.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shell "A penpot specific, modern api for executing external (shell) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj index 2dc68afb98..e2c5fdd25d 100644 --- a/backend/src/app/util/ssrf.clj +++ b/backend/src/app/util/ssrf.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.ssrf "URL/host validation to prevent Server-Side Request Forgery." diff --git a/backend/src/app/util/template.clj b/backend/src/app/util/template.clj index dabad557ac..581a355c87 100644 --- a/backend/src/app/util/template.clj +++ b/backend/src/app/util/template.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.template (:require diff --git a/backend/src/app/util/websocket.clj b/backend/src/app/util/websocket.clj index d62dd6b81f..514de1178c 100644 --- a/backend/src/app/util/websocket.clj +++ b/backend/src/app/util/websocket.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.websocket "A general protocol implementation on top of websockets using vthreads." diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 1280876b32..09fc741ea0 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker "Async tasks abstraction (impl)." diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj index 46067815b4..f61f607bff 100644 --- a/backend/src/app/worker/cron.clj +++ b/backend/src/app/worker/cron.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.cron (:require diff --git a/backend/src/app/worker/dispatcher.clj b/backend/src/app/worker/dispatcher.clj index 7a0ec75362..8c128e177a 100644 --- a/backend/src/app/worker/dispatcher.clj +++ b/backend/src/app/worker/dispatcher.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.dispatcher (:require diff --git a/backend/src/app/worker/executor.clj b/backend/src/app/worker/executor.clj index b536e2709e..144526e269 100644 --- a/backend/src/app/worker/executor.clj +++ b/backend/src/app/worker/executor.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.executor "Async tasks abstraction (impl)." diff --git a/backend/src/app/worker/runner.clj b/backend/src/app/worker/runner.clj index e85144c521..d6211ca770 100644 --- a/backend/src/app/worker/runner.clj +++ b/backend/src/app/worker/runner.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.runner "Async tasks abstraction (impl)." diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index b99de502c4..17423bdf75 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.auth-oidc-test (:require diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index ee0a1943a4..ab09c3f23c 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.binfile-test "Internal binfile test, no RPC involved" diff --git a/backend/test/backend_tests/bounce_handling_test.clj b/backend/test/backend_tests/bounce_handling_test.clj index 9ae3d9a60d..37a339917e 100644 --- a/backend/test/backend_tests/bounce_handling_test.clj +++ b/backend/test/backend_tests/bounce_handling_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.bounce-handling-test (:require diff --git a/backend/test/backend_tests/db_test.clj b/backend/test/backend_tests/db_test.clj index 3d228bc8c1..44893529dc 100644 --- a/backend/test/backend_tests/db_test.clj +++ b/backend/test/backend_tests/db_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.db-test (:require diff --git a/backend/test/backend_tests/email_blacklist_test.clj b/backend/test/backend_tests/email_blacklist_test.clj index 80c3bb4562..632302ab84 100644 --- a/backend/test/backend_tests/email_blacklist_test.clj +++ b/backend/test/backend_tests/email_blacklist_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.email-blacklist-test (:require diff --git a/backend/test/backend_tests/email_sending_test.clj b/backend/test/backend_tests/email_sending_test.clj index 91d2848185..55d1ab5e27 100644 --- a/backend/test/backend_tests/email_sending_test.clj +++ b/backend/test/backend_tests/email_sending_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.email-sending-test (:require diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 0f582497d9..d4d8935418 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.helpers (:require diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index bac3dbfe9a..ac4df258ce 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-assets-test (:require diff --git a/backend/test/backend_tests/http_management_test.clj b/backend/test/backend_tests/http_management_test.clj index ba114a673a..15413eb5ce 100644 --- a/backend/test/backend_tests/http_management_test.clj +++ b/backend/test/backend_tests/http_management_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-management-test (:require diff --git a/backend/test/backend_tests/http_middleware_security.clj b/backend/test/backend_tests/http_middleware_security.clj index 3a4ecc9012..a7a79e97b0 100644 --- a/backend/test/backend_tests/http_middleware_security.clj +++ b/backend/test/backend_tests/http_middleware_security.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-middleware-security (:require diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index bca962d3fc..6ec83924be 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.http-middleware-test (:require diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index 59542040bb..ece038fef7 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.loggers-webhooks-test (:require diff --git a/backend/test/backend_tests/logical_deletion_test.clj b/backend/test/backend_tests/logical_deletion_test.clj index f8dd7e25a7..f9a43e0381 100644 --- a/backend/test/backend_tests/logical_deletion_test.clj +++ b/backend/test/backend_tests/logical_deletion_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.logical-deletion-test (:require diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj index dfa8b16d05..7cc1eae917 100644 --- a/backend/test/backend_tests/media_remote_test.clj +++ b/backend/test/backend_tests/media_remote_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.media-remote-test (:require diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj index 79c98012d2..0ea7296c1f 100644 --- a/backend/test/backend_tests/media_sanitize_test.clj +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.media-sanitize-test (:require diff --git a/backend/test/backend_tests/media_test.clj b/backend/test/backend_tests/media_test.clj index eb2f2517c7..2bb80bf30d 100644 --- a/backend/test/backend_tests/media_test.clj +++ b/backend/test/backend_tests/media_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.media-test (:require diff --git a/backend/test/backend_tests/rpc_access_tokens_test.clj b/backend/test/backend_tests/rpc_access_tokens_test.clj index bdb2f20887..5fa3a1496a 100644 --- a/backend/test/backend_tests/rpc_access_tokens_test.clj +++ b/backend/test/backend_tests/rpc_access_tokens_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-access-tokens-test (:require diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index f4cb76f2a3..9a6e4f198d 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-audit-test (:require diff --git a/backend/test/backend_tests/rpc_auth_test.clj b/backend/test/backend_tests/rpc_auth_test.clj index 94adfb0d3a..0a23c3cab0 100644 --- a/backend/test/backend_tests/rpc_auth_test.clj +++ b/backend/test/backend_tests/rpc_auth_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-auth-test (:require diff --git a/backend/test/backend_tests/rpc_binfile_test.clj b/backend/test/backend_tests/rpc_binfile_test.clj index b1b99762b0..879696fb0e 100644 --- a/backend/test/backend_tests/rpc_binfile_test.clj +++ b/backend/test/backend_tests/rpc_binfile_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-binfile-test (:require diff --git a/backend/test/backend_tests/rpc_commands_error_reports_test.clj b/backend/test/backend_tests/rpc_commands_error_reports_test.clj index c317fe0cbd..868023c79c 100644 --- a/backend/test/backend_tests/rpc_commands_error_reports_test.clj +++ b/backend/test/backend_tests/rpc_commands_error_reports_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en Espana SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-commands-error-reports-test (:require diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj index 8724cbfdfa..ee5c6245e7 100644 --- a/backend/test/backend_tests/rpc_comment_test.clj +++ b/backend/test/backend_tests/rpc_comment_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-comment-test (:require diff --git a/backend/test/backend_tests/rpc_cond_middleware_test.clj b/backend/test/backend_tests/rpc_cond_middleware_test.clj index e6725699e6..21a503aa58 100644 --- a/backend/test/backend_tests/rpc_cond_middleware_test.clj +++ b/backend/test/backend_tests/rpc_cond_middleware_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-cond-middleware-test (:require diff --git a/backend/test/backend_tests/rpc_doc_test.clj b/backend/test/backend_tests/rpc_doc_test.clj index 7a79ea8a18..7398d57949 100644 --- a/backend/test/backend_tests/rpc_doc_test.clj +++ b/backend/test/backend_tests/rpc_doc_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-doc-test "Internal binfile test, no RPC involved" diff --git a/backend/test/backend_tests/rpc_feedback_test.clj b/backend/test/backend_tests/rpc_feedback_test.clj index 13231db61b..b4a6ae650d 100644 --- a/backend/test/backend_tests/rpc_feedback_test.clj +++ b/backend/test/backend_tests/rpc_feedback_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-feedback-test (:require diff --git a/backend/test/backend_tests/rpc_file_snapshot_test.clj b/backend/test/backend_tests/rpc_file_snapshot_test.clj index 5e8bb8ea21..aed2b5ce39 100644 --- a/backend/test/backend_tests/rpc_file_snapshot_test.clj +++ b/backend/test/backend_tests/rpc_file_snapshot_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-snapshot-test (:require diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index da8fbbb1f7..a54774b2c6 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-test (:require diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 4fb4ab12e1..5273180c15 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-file-thumbnails-test (:require diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 0f86a64cb2..68a05bb298 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-font-test (:require diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 455f875fd3..7fff67cad6 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-management-nitrate-test (:require diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index 6191305e26..1148fde042 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-management-test (:require diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index d22eabe64b..75db78c06b 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-media-test (:require diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 90b746e2a1..00dba8c571 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-nitrate-test (:require diff --git a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj index a971c57539..13c7f8120f 100644 --- a/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj +++ b/backend/test/backend_tests/rpc_organization_owner_permissions_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-organization-owner-permissions-test (:require diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index de9ae0aacd..5cec931165 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-profile-test (:require diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj index 3f80f06f6b..f1443ce931 100644 --- a/backend/test/backend_tests/rpc_project_test.clj +++ b/backend/test/backend_tests/rpc_project_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-project-test (:require diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index 94db804e17..ec42d2d79b 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-quotes-test (:require diff --git a/backend/test/backend_tests/rpc_rlimit_test.clj b/backend/test/backend_tests/rpc_rlimit_test.clj index 87ef58244f..a10c6363af 100644 --- a/backend/test/backend_tests/rpc_rlimit_test.clj +++ b/backend/test/backend_tests/rpc_rlimit_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en Espana SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-rlimit-test (:require diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 4bca0fbaa2..552c52eea8 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-team-test (:require diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 14040aeacb..a7fcbaec3d 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-viewer-test (:require diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index df4ae3a622..5b15d46111 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.rpc-webhooks-test (:require diff --git a/backend/test/backend_tests/shell_test.clj b/backend/test/backend_tests/shell_test.clj index c9d1932c44..df8a4336b1 100644 --- a/backend/test/backend_tests/shell_test.clj +++ b/backend/test/backend_tests/shell_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.shell-test (:require diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index 2aca502e9f..adc84e64ac 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.storage-test (:require diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index e3f57647df..e12af0553b 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.tasks-telemetry-test (:require diff --git a/backend/test/backend_tests/util_blob_test.clj b/backend/test/backend_tests/util_blob_test.clj index f6e129f7ce..bdd502de51 100644 --- a/backend/test/backend_tests/util_blob_test.clj +++ b/backend/test/backend_tests/util_blob_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-blob-test (:require diff --git a/backend/test/backend_tests/util_objects_map_test.clj b/backend/test/backend_tests/util_objects_map_test.clj index 6cf9fab519..c73bf5fa86 100644 --- a/backend/test/backend_tests/util_objects_map_test.clj +++ b/backend/test/backend_tests/util_objects_map_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-objects-map-test (:require diff --git a/backend/test/backend_tests/util_pointer_map_test.clj b/backend/test/backend_tests/util_pointer_map_test.clj index 6d206d9f9a..ce39715c4b 100644 --- a/backend/test/backend_tests/util_pointer_map_test.clj +++ b/backend/test/backend_tests/util_pointer_map_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-pointer-map-test (:require diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj index c3b5b435bb..e7bf8fae0d 100644 --- a/backend/test/backend_tests/util_ssrf_test.clj +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns backend-tests.util-ssrf-test (:require diff --git a/common/dev/user.clj b/common/dev/user.clj index cf6d6b5065..5a6a409341 100644 --- a/common/dev/user.clj +++ b/common/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/common/src/app/common/UUIDv8.java b/common/src/app/common/UUIDv8.java index 97485ce89c..0976f55e33 100644 --- a/common/src/app/common/UUIDv8.java +++ b/common/src/app/common/UUIDv8.java @@ -3,7 +3,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL + Copyright (c) KALEIDOS SUBSIDIARY SL This file contains a UUIDv8 with conformance with https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format diff --git a/common/src/app/common/attrs.cljc b/common/src/app/common/attrs.cljc index 57a06f7332..205b02ebd4 100644 --- a/common/src/app/common/attrs.cljc +++ b/common/src/app/common/attrs.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.attrs (:require diff --git a/common/src/app/common/buffer.cljc b/common/src/app/common/buffer.cljc index 16fb3be693..16798f2891 100644 --- a/common/src/app/common/buffer.cljc +++ b/common/src/app/common/buffer.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.buffer "A collection of helpers and macros for work with byte diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index 22705c5980..8d0a4a486e 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.colors (:refer-clojure :exclude [test]) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 090fe8cd69..64da2a7d6a 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data "A collection of helpers for working with data structures and other diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc index 6902e3f2ac..4a6bd5b2a9 100644 --- a/common/src/app/common/data/macros.cljc +++ b/common/src/app/common/data/macros.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data.macros "Data retrieval & manipulation specific macros." diff --git a/common/src/app/common/data/undo_stack.cljc b/common/src/app/common/data/undo_stack.cljc index 192d3a13b8..6ac0827a60 100644 --- a/common/src/app/common/data/undo_stack.cljc +++ b/common/src/app/common/data/undo_stack.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.data.undo-stack (:refer-clojure :exclude [peek]) diff --git a/common/src/app/common/debug.clj b/common/src/app/common/debug.clj index 63d4455eda..8ca06064c0 100644 --- a/common/src/app/common/debug.clj +++ b/common/src/app/common/debug.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.debug (:require diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js index 10cd3d1263..e83b566acd 100644 --- a/common/src/app/common/encoding_impl.js +++ b/common/src/app/common/encoding_impl.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 86785c58ff..7a4b6b1a1e 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.exceptions "A helpers for work with exceptions." diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 216c5fc64f..fdb0fb347e 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.features (:require diff --git a/common/src/app/common/files/builder.cljc b/common/src/app/common/files/builder.cljc index c6bfd833e9..49d93407a0 100644 --- a/common/src/app/common/files/builder.cljc +++ b/common/src/app/common/files/builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.builder "Internal implementation of file builder. Mainly used as base impl diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 7c458aab3f..34148aa0b2 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.changes (:require diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index c0012dfce9..e910978df9 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.changes-builder (:require diff --git a/common/src/app/common/files/comp_processors.cljc b/common/src/app/common/files/comp_processors.cljc index 9c73a2bba3..b74fd0c4b0 100644 --- a/common/src/app/common/files/comp_processors.cljc +++ b/common/src/app/common/files/comp_processors.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.comp-processors "Repair, migration or transformation utilities for components." diff --git a/common/src/app/common/files/defaults.cljc b/common/src/app/common/files/defaults.cljc index 3f4520b5a4..480271c8c3 100644 --- a/common/src/app/common/files/defaults.cljc +++ b/common/src/app/common/files/defaults.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.defaults) diff --git a/common/src/app/common/files/focus.cljc b/common/src/app/common/files/focus.cljc index b33be6e059..cb422630f0 100644 --- a/common/src/app/common/files/focus.cljc +++ b/common/src/app/common/files/focus.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.focus (:require diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc index 3db1cdfba9..fcb7d6824d 100644 --- a/common/src/app/common/files/helpers.cljc +++ b/common/src/app/common/files/helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.helpers (:require diff --git a/common/src/app/common/files/indices.cljc b/common/src/app/common/files/indices.cljc index 5e2f256a2e..a6e1a8a1df 100644 --- a/common/src/app/common/files/indices.cljc +++ b/common/src/app/common/files/indices.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.indices (:require diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 28174ba84f..f59f09ef50 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.migrations (:require diff --git a/common/src/app/common/files/page_diff.cljc b/common/src/app/common/files/page_diff.cljc index f3535e9dae..32279b746e 100644 --- a/common/src/app/common/files/page_diff.cljc +++ b/common/src/app/common/files/page_diff.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.page-diff "Given a page in its old version and the new will retrieve a map with diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 95f3fda22f..0bbec120bb 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.repair (:require diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index d8b2c2fa64..9577fb528d 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.shapes-builder "A SVG to Shapes builder." diff --git a/common/src/app/common/files/shapes_helpers.cljc b/common/src/app/common/files/shapes_helpers.cljc index 6ecbce515e..4540501938 100644 --- a/common/src/app/common/files/shapes_helpers.cljc +++ b/common/src/app/common/files/shapes_helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.shapes-helpers (:require diff --git a/common/src/app/common/files/stats.cljc b/common/src/app/common/files/stats.cljc index 99a2315243..30763ed833 100644 --- a/common/src/app/common/files/stats.cljc +++ b/common/src/app/common/files/stats.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.stats "Pure helpers that compute aggregate statistics for a file data map. diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 95ff5da4df..533fde904e 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.tokens (:require diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 9cb2a5e060..23a7b46957 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.validate (:require diff --git a/common/src/app/common/files/variant.cljc b/common/src/app/common/files/variant.cljc index 649e1d743c..989a80a58b 100644 --- a/common/src/app/common/files/variant.cljc +++ b/common/src/app/common/files/variant.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.files.variant (:require [app.common.data.macros :as dm] diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 3f2150a651..376a26df4f 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.flags "Flags parsing algorithm." diff --git a/common/src/app/common/fonts.clj b/common/src/app/common/fonts.clj index 9b0be1660f..8580abb8dd 100644 --- a/common/src/app/common/fonts.clj +++ b/common/src/app/common/fonts.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.fonts "A fonts loading macros." diff --git a/common/src/app/common/fonts.cljs b/common/src/app/common/fonts.cljs index 5366d6c2c4..237a6b362b 100644 --- a/common/src/app/common/fonts.cljs +++ b/common/src/app/common/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.fonts "Host-agnostic font knowledge shared by every renderer: the google catalog diff --git a/common/src/app/common/fressian.clj b/common/src/app/common/fressian.clj index 3de4cc54a7..3d0f41eb2f 100644 --- a/common/src/app/common/fressian.clj +++ b/common/src/app/common/fressian.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.fressian (:require diff --git a/common/src/app/common/generic_pool.clj b/common/src/app/common/generic_pool.clj index 8a97667713..21481367a2 100644 --- a/common/src/app/common/generic_pool.clj +++ b/common/src/app/common/generic_pool.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.generic-pool (:refer-clojure :exclude [get]) diff --git a/common/src/app/common/geom/align.cljc b/common/src/app/common/geom/align.cljc index 759972e8bf..959cf18018 100644 --- a/common/src/app/common/geom/align.cljc +++ b/common/src/app/common/geom/align.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.align (:require diff --git a/common/src/app/common/geom/bounds_map.cljc b/common/src/app/common/geom/bounds_map.cljc index f9cffe73ab..7a6b272e61 100644 --- a/common/src/app/common/geom/bounds_map.cljc +++ b/common/src/app/common/geom/bounds_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.bounds-map (:require diff --git a/common/src/app/common/geom/grid.cljc b/common/src/app/common/geom/grid.cljc index 298824af61..209869bb06 100644 --- a/common/src/app/common/geom/grid.cljc +++ b/common/src/app/common/geom/grid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.grid (:require diff --git a/common/src/app/common/geom/line.cljc b/common/src/app/common/geom/line.cljc index a20a56b082..55f5151a00 100644 --- a/common/src/app/common/geom/line.cljc +++ b/common/src/app/common/geom/line.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.line) diff --git a/common/src/app/common/geom/matrix.cljc b/common/src/app/common/geom/matrix.cljc index 3b5612bdd5..9e7239f01d 100644 --- a/common/src/app/common/geom/matrix.cljc +++ b/common/src/app/common/geom/matrix.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.matrix (:require diff --git a/common/src/app/common/geom/modif_tree.cljc b/common/src/app/common/geom/modif_tree.cljc index c222d5f89b..1741588889 100644 --- a/common/src/app/common/geom/modif_tree.cljc +++ b/common/src/app/common/geom/modif_tree.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.modif-tree (:require diff --git a/common/src/app/common/geom/modifiers.cljc b/common/src/app/common/geom/modifiers.cljc index 946bcff269..4114ce37e0 100644 --- a/common/src/app/common/geom/modifiers.cljc +++ b/common/src/app/common/geom/modifiers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.modifiers (:require diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc index f281488660..56123e7a39 100644 --- a/common/src/app/common/geom/point.cljc +++ b/common/src/app/common/geom/point.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.point (:refer-clojure :exclude [divide min max abs zero?]) diff --git a/common/src/app/common/geom/proportions.cljc b/common/src/app/common/geom/proportions.cljc index cc6fe27b79..2245af2f0f 100644 --- a/common/src/app/common/geom/proportions.cljc +++ b/common/src/app/common/geom/proportions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.proportions (:require diff --git a/common/src/app/common/geom/rect.cljc b/common/src/app/common/geom/rect.cljc index e9e5e79c9d..3699ae5bd3 100644 --- a/common/src/app/common/geom/rect.cljc +++ b/common/src/app/common/geom/rect.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.rect (:require diff --git a/common/src/app/common/geom/shapes.cljc b/common/src/app/common/geom/shapes.cljc index 66caaa9aed..4f0ac90fe9 100644 --- a/common/src/app/common/geom/shapes.cljc +++ b/common/src/app/common/geom/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes (:require diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc index 794d578c7d..489455a368 100644 --- a/common/src/app/common/geom/shapes/bounds.cljc +++ b/common/src/app/common/geom/shapes/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.bounds (:require diff --git a/common/src/app/common/geom/shapes/common.cljc b/common/src/app/common/geom/shapes/common.cljc index 2f0017b344..d344a5d09f 100644 --- a/common/src/app/common/geom/shapes/common.cljc +++ b/common/src/app/common/geom/shapes/common.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.common (:require diff --git a/common/src/app/common/geom/shapes/constraints.cljc b/common/src/app/common/geom/shapes/constraints.cljc index 954c426306..ca92c93cc7 100644 --- a/common/src/app/common/geom/shapes/constraints.cljc +++ b/common/src/app/common/geom/shapes/constraints.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.constraints (:require diff --git a/common/src/app/common/geom/shapes/corners.cljc b/common/src/app/common/geom/shapes/corners.cljc index d43df249c9..32e63c3d4e 100644 --- a/common/src/app/common/geom/shapes/corners.cljc +++ b/common/src/app/common/geom/shapes/corners.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.corners (:require diff --git a/common/src/app/common/geom/shapes/effects.cljc b/common/src/app/common/geom/shapes/effects.cljc index 7e1096e6b8..f03875a398 100644 --- a/common/src/app/common/geom/shapes/effects.cljc +++ b/common/src/app/common/geom/shapes/effects.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.effects) diff --git a/common/src/app/common/geom/shapes/fit_frame.cljc b/common/src/app/common/geom/shapes/fit_frame.cljc index c93e663496..65d529c420 100644 --- a/common/src/app/common/geom/shapes/fit_frame.cljc +++ b/common/src/app/common/geom/shapes/fit_frame.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.fit-frame (:require diff --git a/common/src/app/common/geom/shapes/flex_layout.cljc b/common/src/app/common/geom/shapes/flex_layout.cljc index 509dce1ba5..e2ed76c5ca 100644 --- a/common/src/app/common/geom/shapes/flex_layout.cljc +++ b/common/src/app/common/geom/shapes/flex_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc index b434d99fc9..4f4cc1895c 100644 --- a/common/src/app/common/geom/shapes/flex_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.bounds (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc index 7f71da31d6..33553d671f 100644 --- a/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/drop_area.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.drop-area (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc index 4144fa7119..dfdd8cd094 100644 --- a/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/layout_data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.layout-data (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc index 5d27cbb69a..9aaadea470 100644 --- a/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/modifiers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.modifiers (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/params.cljc b/common/src/app/common/geom/shapes/flex_layout/params.cljc index ffd75a2e6f..57a1fab63d 100644 --- a/common/src/app/common/geom/shapes/flex_layout/params.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/params.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.params (:require diff --git a/common/src/app/common/geom/shapes/flex_layout/positions.cljc b/common/src/app/common/geom/shapes/flex_layout/positions.cljc index 6251650055..2dcb28d42d 100644 --- a/common/src/app/common/geom/shapes/flex_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/flex_layout/positions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.flex-layout.positions (:require diff --git a/common/src/app/common/geom/shapes/grid_layout.cljc b/common/src/app/common/geom/shapes/grid_layout.cljc index e81de6711c..4ac7b85284 100644 --- a/common/src/app/common/geom/shapes/grid_layout.cljc +++ b/common/src/app/common/geom/shapes/grid_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/areas.cljc b/common/src/app/common/geom/shapes/grid_layout/areas.cljc index 01b56901c6..31837c5692 100644 --- a/common/src/app/common/geom/shapes/grid_layout/areas.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/areas.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Based on the code in: ;; https://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Rectangle_difference diff --git a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc index caadff4766..f92e365db6 100644 --- a/common/src/app/common/geom/shapes/grid_layout/bounds.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/bounds.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.bounds (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc index 93322435c9..756eb2924e 100644 --- a/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/layout_data.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Each track has specified minimum and maximum sizing functions (which may be the same) ;; - Fixed diff --git a/common/src/app/common/geom/shapes/grid_layout/params.cljc b/common/src/app/common/geom/shapes/grid_layout/params.cljc index 6cc1a2f36c..ee3a779942 100644 --- a/common/src/app/common/geom/shapes/grid_layout/params.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/params.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.params (:require diff --git a/common/src/app/common/geom/shapes/grid_layout/positions.cljc b/common/src/app/common/geom/shapes/grid_layout/positions.cljc index 3144e68f82..3b818a56d2 100644 --- a/common/src/app/common/geom/shapes/grid_layout/positions.cljc +++ b/common/src/app/common/geom/shapes/grid_layout/positions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.grid-layout.positions (:require diff --git a/common/src/app/common/geom/shapes/intersect.cljc b/common/src/app/common/geom/shapes/intersect.cljc index 9338a0ad56..f63fc7f740 100644 --- a/common/src/app/common/geom/shapes/intersect.cljc +++ b/common/src/app/common/geom/shapes/intersect.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.intersect (:require diff --git a/common/src/app/common/geom/shapes/min_size_layout.cljc b/common/src/app/common/geom/shapes/min_size_layout.cljc index 57375098e9..49617ce6ea 100644 --- a/common/src/app/common/geom/shapes/min_size_layout.cljc +++ b/common/src/app/common/geom/shapes/min_size_layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.min-size-layout (:require diff --git a/common/src/app/common/geom/shapes/pixel_precision.cljc b/common/src/app/common/geom/shapes/pixel_precision.cljc index 30fdf89f50..82210de222 100644 --- a/common/src/app/common/geom/shapes/pixel_precision.cljc +++ b/common/src/app/common/geom/shapes/pixel_precision.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.pixel-precision (:require diff --git a/common/src/app/common/geom/shapes/points.cljc b/common/src/app/common/geom/shapes/points.cljc index a1646ee44f..1ce8eaec76 100644 --- a/common/src/app/common/geom/shapes/points.cljc +++ b/common/src/app/common/geom/shapes/points.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.points (:require diff --git a/common/src/app/common/geom/shapes/rect.cljc b/common/src/app/common/geom/shapes/rect.cljc index 7a93ab8acb..951a0869d6 100644 --- a/common/src/app/common/geom/shapes/rect.cljc +++ b/common/src/app/common/geom/shapes/rect.cljc @@ -2,6 +2,6 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.rect) diff --git a/common/src/app/common/geom/shapes/strokes.cljc b/common/src/app/common/geom/shapes/strokes.cljc index d5876995ff..567c6a9417 100644 --- a/common/src/app/common/geom/shapes/strokes.cljc +++ b/common/src/app/common/geom/shapes/strokes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.strokes) diff --git a/common/src/app/common/geom/shapes/text.cljc b/common/src/app/common/geom/shapes/text.cljc index 35e2359ef1..1507621e6d 100644 --- a/common/src/app/common/geom/shapes/text.cljc +++ b/common/src/app/common/geom/shapes/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.text (:require diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index d0ff71a609..7ba56f2197 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.transforms (:require diff --git a/common/src/app/common/geom/shapes/tree_seq.cljc b/common/src/app/common/geom/shapes/tree_seq.cljc index 17da34e8f8..2c069815f3 100644 --- a/common/src/app/common/geom/shapes/tree_seq.cljc +++ b/common/src/app/common/geom/shapes/tree_seq.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.shapes.tree-seq (:require diff --git a/common/src/app/common/geom/snap.cljc b/common/src/app/common/geom/snap.cljc index 2b3db1bda6..979b9cfad6 100644 --- a/common/src/app/common/geom/snap.cljc +++ b/common/src/app/common/geom/snap.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.geom.snap (:require diff --git a/common/src/app/common/i18n.cljc b/common/src/app/common/i18n.cljc index 8aa8a61a0f..a542870813 100644 --- a/common/src/app/common/i18n.cljc +++ b/common/src/app/common/i18n.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.i18n "Dummy i18n functions, to be used by code in common that needs translations.") diff --git a/common/src/app/common/json.cljc b/common/src/app/common/json.cljc index 0861c33c1a..079f14073d 100644 --- a/common/src/app/common/json.cljc +++ b/common/src/app/common/json.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.json (:refer-clojure :exclude [read clj->js js->clj]) diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index ffed8cc09f..42e4f84566 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logging "A lightweight and multiplaform (clj & cljs) asynchronous by default diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index b234263468..b8b1274478 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.libraries #?(:cljs (:require-macros [app.common.logic.libraries :refer [shape-log container-log]])) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 831fce4076..638b5abd7c 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.shapes (:require diff --git a/common/src/app/common/logic/tokens.cljc b/common/src/app/common/logic/tokens.cljc index ffec485d54..493d23d1f4 100644 --- a/common/src/app/common/logic/tokens.cljc +++ b/common/src/app/common/logic/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.tokens (:require diff --git a/common/src/app/common/logic/variant_properties.cljc b/common/src/app/common/logic/variant_properties.cljc index 9a39b82400..fe87fa3e5e 100644 --- a/common/src/app/common/logic/variant_properties.cljc +++ b/common/src/app/common/logic/variant_properties.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.logic.variant-properties (:require [app.common.data :as d] diff --git a/common/src/app/common/math.cljc b/common/src/app/common/math.cljc index 839079efee..41e3047880 100644 --- a/common/src/app/common/math.cljc +++ b/common/src/app/common/math.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.math "A collection of math utils." diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index a5a74e6c75..8e6038ea11 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.media "Media assets helpers (images, fonts, etc)" diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 90da658f17..de96093155 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.path-names (:require diff --git a/common/src/app/common/perf.cljc b/common/src/app/common/perf.cljc index 009db14237..16a82126c1 100644 --- a/common/src/app/common/perf.cljc +++ b/common/src/app/common/perf.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.perf (:require diff --git a/common/src/app/common/pprint.cljc b/common/src/app/common/pprint.cljc index 386054ed49..5154fda27d 100644 --- a/common/src/app/common/pprint.cljc +++ b/common/src/app/common/pprint.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.pprint (:refer-clojure :exclude [prn]) diff --git a/common/src/app/common/record.cljc b/common/src/app/common/record.cljc index ee3b191baa..0a7b4a7f97 100644 --- a/common/src/app/common/record.cljc +++ b/common/src/app/common/record.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.record "A collection of helpers and macros for defien a penpot customized record types." diff --git a/common/src/app/common/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs index 6caaac7de8..fe8b7bb264 100644 --- a/common/src/app/common/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.api.props "Browser-free WASM shape property setters, shared by the workspace render diff --git a/common/src/app/common/render_wasm/api/shapes.cljs b/common/src/app/common/render_wasm/api/shapes.cljs index 55f442d0d0..e507c1245e 100644 --- a/common/src/app/common/render_wasm/api/shapes.cljs +++ b/common/src/app/common/render_wasm/api/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.api.shapes "Batched shape property serialization for improved WASM performance. diff --git a/common/src/app/common/render_wasm/enums.clj b/common/src/app/common/render_wasm/enums.clj index 3bc382ec8d..0be29aacf9 100644 --- a/common/src/app/common/render_wasm/enums.clj +++ b/common/src/app/common/render_wasm/enums.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.enums "Serializer enum table from `shared.js`") diff --git a/common/src/app/common/render_wasm/helpers.cljc b/common/src/app/common/render_wasm/helpers.cljc index 236a952248..d96cc9f990 100644 --- a/common/src/app/common/render_wasm/helpers.cljc +++ b/common/src/app/common/render_wasm/helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.helpers #?(:cljs (:require-macros [app.common.render-wasm.helpers])) diff --git a/common/src/app/common/render_wasm/mem.cljs b/common/src/app/common/render_wasm/mem.cljs index 3a64315ba0..43a4435f68 100644 --- a/common/src/app/common/render_wasm/mem.cljs +++ b/common/src/app/common/render_wasm/mem.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.mem (:require diff --git a/common/src/app/common/render_wasm/mem/heap32.cljs b/common/src/app/common/render_wasm/mem/heap32.cljs index bfe7c49053..a07986eb20 100644 --- a/common/src/app/common/render_wasm/mem/heap32.cljs +++ b/common/src/app/common/render_wasm/mem/heap32.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.mem.heap32 "A memory write helpers that uses 32 bits addressed offsets." diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index a0e776a62f..3f4111bb60 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.serialize-shape "Single source of truth for the host-independent part of serializing a whole diff --git a/common/src/app/common/render_wasm/serializers.cljs b/common/src/app/common/render_wasm/serializers.cljs index 862eafae7c..02fe6632d2 100644 --- a/common/src/app/common/render_wasm/serializers.cljs +++ b/common/src/app/common/render_wasm/serializers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.serializers (:require diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index 51ce707fb4..1ffbebb054 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.text-content "Single source of truth for writing a text shape's content into the WASM design diff --git a/common/src/app/common/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs index 657b44047d..816bca617c 100644 --- a/common/src/app/common/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.render-wasm.wasm) diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index fba8169bcd..3ecfd15c37 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema (:refer-clojure :exclude [deref merge parse-uuid parse-long parse-double parse-boolean type keys select-keys]) diff --git a/common/src/app/common/schema/desc_js_like.cljc b/common/src/app/common/schema/desc_js_like.cljc index f3d772541a..c837789224 100644 --- a/common/src/app/common/schema/desc_js_like.cljc +++ b/common/src/app/common/schema/desc_js_like.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.desc-js-like (:require diff --git a/common/src/app/common/schema/desc_native.cljc b/common/src/app/common/schema/desc_native.cljc index 003400e101..b226b5bdc1 100644 --- a/common/src/app/common/schema/desc_native.cljc +++ b/common/src/app/common/schema/desc_native.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.desc-native (:require diff --git a/common/src/app/common/schema/generators.cljc b/common/src/app/common/schema/generators.cljc index 1268d08ae4..c5069f400b 100644 --- a/common/src/app/common/schema/generators.cljc +++ b/common/src/app/common/schema/generators.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.generators (:refer-clojure :exclude [set subseq uuid filter map let boolean vector keyword int double not-empty]) diff --git a/common/src/app/common/schema/messages.cljc b/common/src/app/common/schema/messages.cljc index 912acc7686..90ee3a21ce 100644 --- a/common/src/app/common/schema/messages.cljc +++ b/common/src/app/common/schema/messages.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.messages (:require diff --git a/common/src/app/common/schema/openapi.cljc b/common/src/app/common/schema/openapi.cljc index 03291a24da..d9ccd2be36 100644 --- a/common/src/app/common/schema/openapi.cljc +++ b/common/src/app/common/schema/openapi.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.openapi (:require diff --git a/common/src/app/common/schema/registry.cljc b/common/src/app/common/schema/registry.cljc index ad1fcffdbc..edb262f55c 100644 --- a/common/src/app/common/schema/registry.cljc +++ b/common/src/app/common/schema/registry.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.registry (:require diff --git a/common/src/app/common/schema/test.cljc b/common/src/app/common/schema/test.cljc index b800651cb3..1ad26ddbc6 100644 --- a/common/src/app/common/schema/test.cljc +++ b/common/src/app/common/schema/test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.schema.test (:refer-clojure :exclude [for]) diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index 26f84a9a07..30d97be4c9 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.spec "Data validation & assertion helpers." diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc index 69d35c3ad1..313c2e674f 100644 --- a/common/src/app/common/svg.cljc +++ b/common/src/app/common/svg.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg (:require diff --git a/common/src/app/common/svg/path.cljc b/common/src/app/common/svg/path.cljc index 98dcfc6e04..628c024e6e 100644 --- a/common/src/app/common/svg/path.cljc +++ b/common/src/app/common/svg/path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg.path #?(:clj diff --git a/common/src/app/common/svg/path/Parser.java b/common/src/app/common/svg/path/Parser.java index 482cf1676d..4c653c6349 100644 --- a/common/src/app/common/svg/path/Parser.java +++ b/common/src/app/common/svg/path/Parser.java @@ -2,7 +2,7 @@ * Performance focused pure java implementation of the * SVG path parser. * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MPL-2.0 */ diff --git a/common/src/app/common/svg/path/arc_to_bezier.js b/common/src/app/common/svg/path/arc_to_bezier.js index f7911a8b24..25a102e638 100644 --- a/common/src/app/common/svg/path/arc_to_bezier.js +++ b/common/src/app/common/svg/path/arc_to_bezier.js @@ -5,7 +5,7 @@ * functions by https://github.com/fontello/svgpath used as reference * implementation for tests * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MIT License */ diff --git a/common/src/app/common/svg/path/legacy_parser2.cljc b/common/src/app/common/svg/path/legacy_parser2.cljc index 8af87feb90..3b28178902 100644 --- a/common/src/app/common/svg/path/legacy_parser2.cljc +++ b/common/src/app/common/svg/path/legacy_parser2.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.svg.path.legacy-parser2 "The second SVG Path parser implementation. diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js index dd102f5d55..1007180f6b 100644 --- a/common/src/app/common/svg/path/parser.js +++ b/common/src/app/common/svg/path/parser.js @@ -2,7 +2,7 @@ * Performance focused pure javascript implementation of the * SVG path parser. * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MPL-2.0 */ diff --git a/common/src/app/common/test_helpers/components.cljc b/common/src/app/common/test_helpers/components.cljc index 2d214e8c78..248b08619a 100644 --- a/common/src/app/common/test_helpers/components.cljc +++ b/common/src/app/common/test_helpers/components.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.components (:require diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc index 5089e8b3e6..a4fa6b748b 100644 --- a/common/src/app/common/test_helpers/compositions.cljc +++ b/common/src/app/common/test_helpers/compositions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.compositions (:require diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc index 027b095a5f..448e0d2562 100644 --- a/common/src/app/common/test_helpers/files.cljc +++ b/common/src/app/common/test_helpers/files.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.files (:require diff --git a/common/src/app/common/test_helpers/ids_map.cljc b/common/src/app/common/test_helpers/ids_map.cljc index 60649cf31d..9648b42c37 100644 --- a/common/src/app/common/test_helpers/ids_map.cljc +++ b/common/src/app/common/test_helpers/ids_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.ids-map (:require diff --git a/common/src/app/common/test_helpers/shapes.cljc b/common/src/app/common/test_helpers/shapes.cljc index 11f1a79acf..21e51d377d 100644 --- a/common/src/app/common/test_helpers/shapes.cljc +++ b/common/src/app/common/test_helpers/shapes.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.shapes (:require diff --git a/common/src/app/common/test_helpers/tokens.cljc b/common/src/app/common/test_helpers/tokens.cljc index 02becdc27a..81697ac4b8 100644 --- a/common/src/app/common/test_helpers/tokens.cljc +++ b/common/src/app/common/test_helpers/tokens.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.tokens (:require diff --git a/common/src/app/common/test_helpers/variants.cljc b/common/src/app/common/test_helpers/variants.cljc index 07b4a58be7..8bbadf4c40 100644 --- a/common/src/app/common/test_helpers/variants.cljc +++ b/common/src/app/common/test_helpers/variants.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.test-helpers.variants (:require diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index 6cd32e61b6..45f7b6f534 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.text "Legacy editor helpers (draftjs). diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 5410ee6a49..080d609761 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_{:clj-kondo/ignore [:unused-namespace]} (ns app.common.time diff --git a/common/src/app/common/transit.cljc b/common/src/app/common/transit.cljc index 21b73cf942..dc3e37b380 100644 --- a/common/src/app/common/transit.cljc +++ b/common/src/app/common/transit.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.transit (:require diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index a6938c4a6f..58cd5d970f 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.color (:refer-clojure :exclude [test]) diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index 37a090217b..deb5379e72 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.component (:require diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 0c19718641..cc7ea9bd07 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.container (:require diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index d7e9feb676..ad611b1e41 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.file (:require diff --git a/common/src/app/common/types/fills.cljc b/common/src/app/common/types/fills.cljc index 62bc999b55..1c9e100e4a 100644 --- a/common/src/app/common/types/fills.cljc +++ b/common/src/app/common/types/fills.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.fills (:refer-clojure :exclude [assoc update]) diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index 571b5577fd..32f806568f 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.fills.impl (:require diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc index 0e90a676b8..fb7c737171 100644 --- a/common/src/app/common/types/font.cljc +++ b/common/src/app/common/types/font.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.font (:require diff --git a/common/src/app/common/types/grid.cljc b/common/src/app/common/types/grid.cljc index 41220417bd..4fa1e8bf4e 100644 --- a/common/src/app/common/types/grid.cljc +++ b/common/src/app/common/types/grid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.grid (:require diff --git a/common/src/app/common/types/library.cljc b/common/src/app/common/types/library.cljc index edc441cc91..62e703898a 100644 --- a/common/src/app/common/types/library.cljc +++ b/common/src/app/common/types/library.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.library "Exposes file library type data helpers. diff --git a/common/src/app/common/types/objects_map.cljc b/common/src/app/common/types/objects_map.cljc index ad53f4f594..77289a2b1e 100644 --- a/common/src/app/common/types/objects_map.cljc +++ b/common/src/app/common/types/objects_map.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.objects-map "Implements a specialized map-like data structure for store an UUID => diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 8a79598d74..f601cf2971 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.organization (:require diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 5704c84e87..b4631a3698 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.page (:refer-clojure :exclude [empty?]) diff --git a/common/src/app/common/types/pages_list.cljc b/common/src/app/common/types/pages_list.cljc index f55443287e..85e73c78d3 100644 --- a/common/src/app/common/types/pages_list.cljc +++ b/common/src/app/common/types/pages_list.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.pages-list (:require diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index 2b1188682f..a2e3b14b16 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path (:require diff --git a/common/src/app/common/types/path/bool.cljc b/common/src/app/common/types/path/bool.cljc index d2fdf01cb7..2193798529 100644 --- a/common/src/app/common/types/path/bool.cljc +++ b/common/src/app/common/types/path/bool.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.bool (:require diff --git a/common/src/app/common/types/path/helpers.cljc b/common/src/app/common/types/path/helpers.cljc index bd0db1640b..0cec40d119 100644 --- a/common/src/app/common/types/path/helpers.cljc +++ b/common/src/app/common/types/path/helpers.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.helpers "A collection of path internal helpers that does not depend on other diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 483ebdd54b..449f2cfb17 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.impl "Contains schemas and data type implementation for PathData binary diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index d0742ced93..c26813d1e0 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.segment "A collection of helpers for work with plain segment type" diff --git a/common/src/app/common/types/path/shape_to_path.cljc b/common/src/app/common/types/path/shape_to_path.cljc index ffd8140adf..665eb592fe 100644 --- a/common/src/app/common/types/path/shape_to_path.cljc +++ b/common/src/app/common/types/path/shape_to_path.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.shape-to-path (:require diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc index 50c0055466..3570311312 100644 --- a/common/src/app/common/types/path/subpath.cljc +++ b/common/src/app/common/types/path/subpath.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.path.subpath (:require diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index 7fe8a4c7d4..e90356e0e4 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.plugins (:require diff --git a/common/src/app/common/types/profile.cljc b/common/src/app/common/types/profile.cljc index 742e2dd1c0..9a02d3eb0c 100644 --- a/common/src/app/common/types/profile.cljc +++ b/common/src/app/common/types/profile.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.profile (:require diff --git a/common/src/app/common/types/project.cljc b/common/src/app/common/types/project.cljc index 0b20e2c232..f6f20ea36a 100644 --- a/common/src/app/common/types/project.cljc +++ b/common/src/app/common/types/project.cljc @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.project (:require diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 76f1360ea1..d6b48c6ac7 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape (:require diff --git a/common/src/app/common/types/shape/attrs.cljc b/common/src/app/common/types/shape/attrs.cljc index 0d76f5afa4..eb8dc69193 100644 --- a/common/src/app/common/types/shape/attrs.cljc +++ b/common/src/app/common/types/shape/attrs.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.attrs (:require diff --git a/common/src/app/common/types/shape/background_blur.cljc b/common/src/app/common/types/shape/background_blur.cljc index 214629a580..6eb6b7ad38 100644 --- a/common/src/app/common/types/shape/background_blur.cljc +++ b/common/src/app/common/types/shape/background_blur.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.background-blur (:require diff --git a/common/src/app/common/types/shape/blur.cljc b/common/src/app/common/types/shape/blur.cljc index e0a149d2bc..59decef09e 100644 --- a/common/src/app/common/types/shape/blur.cljc +++ b/common/src/app/common/types/shape/blur.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.blur (:require diff --git a/common/src/app/common/types/shape/export.cljc b/common/src/app/common/types/shape/export.cljc index babc18157b..5c54f0455b 100644 --- a/common/src/app/common/types/shape/export.cljc +++ b/common/src/app/common/types/shape/export.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.export (:require diff --git a/common/src/app/common/types/shape/images.cljs b/common/src/app/common/types/shape/images.cljs index 54e6fef6c3..18186fe065 100644 --- a/common/src/app/common/types/shape/images.cljs +++ b/common/src/app/common/types/shape/images.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.images "Host-agnostic enumeration of the external resources a scene needs to diff --git a/common/src/app/common/types/shape/interactions.cljc b/common/src/app/common/types/shape/interactions.cljc index 99f25fad00..f3e7912717 100644 --- a/common/src/app/common/types/shape/interactions.cljc +++ b/common/src/app/common/types/shape/interactions.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.interactions (:require diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index 03532db4ec..b66aabc27d 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.layout (:require diff --git a/common/src/app/common/types/shape/radius.cljc b/common/src/app/common/types/shape/radius.cljc index 34fdd067c9..46dfa2dacb 100644 --- a/common/src/app/common/types/shape/radius.cljc +++ b/common/src/app/common/types/shape/radius.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.radius (:require diff --git a/common/src/app/common/types/shape/shadow.cljc b/common/src/app/common/types/shape/shadow.cljc index 7ec688ab4a..1398a7b5eb 100644 --- a/common/src/app/common/types/shape/shadow.cljc +++ b/common/src/app/common/types/shape/shadow.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.shadow (:require diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 8c0595daa6..122aae80a8 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape.text (:require diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index f9ce8ded20..92a889a6d5 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.shape-tree (:require diff --git a/common/src/app/common/types/stroke.cljc b/common/src/app/common/types/stroke.cljc index a3a84f6921..792b56244a 100644 --- a/common/src/app/common/types/stroke.cljc +++ b/common/src/app/common/types/stroke.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.stroke (:require diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index fd099d78db..b515ef9be0 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.team (:require diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 6068cfc829..86fa8496cc 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.text (:require diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index 852d2529d5..880149afbb 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.token (:require diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 2376dd7563..1fe5b87658 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.tokens-lib (:require diff --git a/common/src/app/common/types/typographies_list.cljc b/common/src/app/common/types/typographies_list.cljc index 635c1431d3..fb0daec536 100644 --- a/common/src/app/common/types/typographies_list.cljc +++ b/common/src/app/common/types/typographies_list.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.typographies-list (:require diff --git a/common/src/app/common/types/typography.cljc b/common/src/app/common/types/typography.cljc index 241f5f3079..1c3f2fab93 100644 --- a/common/src/app/common/types/typography.cljc +++ b/common/src/app/common/types/typography.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.typography (:require diff --git a/common/src/app/common/types/variant.cljc b/common/src/app/common/types/variant.cljc index db65e42806..8a37939756 100644 --- a/common/src/app/common/types/variant.cljc +++ b/common/src/app/common/types/variant.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.types.variant (:require diff --git a/common/src/app/common/uri.cljc b/common/src/app/common/uri.cljc index c61cc69737..4284b47290 100644 --- a/common/src/app/common/uri.cljc +++ b/common/src/app/common/uri.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.uri (:refer-clojure :exclude [uri?]) diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc index d094d4c06d..e890a595c1 100644 --- a/common/src/app/common/uuid.cljc +++ b/common/src/app/common/uuid.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_:clj-kondo/ignore (ns app.common.uuid diff --git a/common/src/app/common/uuid_impl.js b/common/src/app/common/uuid_impl.js index 9a868cc9a5..07a20269cb 100644 --- a/common/src/app/common/uuid_impl.js +++ b/common/src/app/common/uuid_impl.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/version.cljc b/common/src/app/common/version.cljc index fb4098557b..b5ef8f2c0e 100644 --- a/common/src/app/common/version.cljc +++ b/common/src/app/common/version.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.version "A version parsing helper." diff --git a/common/src/app/common/weak.cljc b/common/src/app/common/weak.cljc index 7733f58ce6..6306d422d9 100644 --- a/common/src/app/common/weak.cljc +++ b/common/src/app/common/weak.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.weak "A collection of helpers for work with weak references and weak diff --git a/common/src/app/common/weak/impl_loadable_weak_value_map.clj b/common/src/app/common/weak/impl_loadable_weak_value_map.clj index a7801d8cec..6e582f26f1 100644 --- a/common/src/app/common/weak/impl_loadable_weak_value_map.clj +++ b/common/src/app/common/weak/impl_loadable_weak_value_map.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.common.weak.impl-loadable-weak-value-map (:import diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js index 1a4d4fc31e..3c900634bd 100644 --- a/common/src/app/common/weak/impl_weak_map.js +++ b/common/src/app/common/weak/impl_weak_map.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/src/app/common/weak/impl_weak_value_map.js b/common/src/app/common/weak/impl_weak_value_map.js index 11eaba8b0d..8f8dc27586 100644 --- a/common/src/app/common/weak/impl_weak_value_map.js +++ b/common/src/app/common/weak/impl_weak_value_map.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/common/test/common_tests/attrs_test.cljc b/common/test/common_tests/attrs_test.cljc index bab8b9fbaf..340a89e0da 100644 --- a/common/test/common_tests/attrs_test.cljc +++ b/common/test/common_tests/attrs_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.attrs-test (:require diff --git a/common/test/common_tests/buffer_test.cljc b/common/test/common_tests/buffer_test.cljc index 612a4248cd..43b35e05d4 100644 --- a/common/test/common_tests/buffer_test.cljc +++ b/common/test/common_tests/buffer_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.buffer-test (:require diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index b79f9c6176..ee13f2e3b0 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.colors-test (:require diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index ffa26a2386..cd3e1c5eae 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.data-test (:require diff --git a/common/test/common_tests/files/comp_processors_test.cljc b/common/test/common_tests/files/comp_processors_test.cljc index 412986ede4..6eb30ad82b 100644 --- a/common/test/common_tests/files/comp_processors_test.cljc +++ b/common/test/common_tests/files/comp_processors_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.comp-processors-test (:require diff --git a/common/test/common_tests/files/helpers_test.cljc b/common/test/common_tests/files/helpers_test.cljc index 4205d562c7..56ef876499 100644 --- a/common/test/common_tests/files/helpers_test.cljc +++ b/common/test/common_tests/files/helpers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.helpers-test (:require diff --git a/common/test/common_tests/files/repair_test.cljc b/common/test/common_tests/files/repair_test.cljc index a5021baf03..ad9532e497 100644 --- a/common/test/common_tests/files/repair_test.cljc +++ b/common/test/common_tests/files/repair_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.repair-test "Tests for the validate / repair functions in app.common.files.validate diff --git a/common/test/common_tests/files/shapes_builder_test.cljc b/common/test/common_tests/files/shapes_builder_test.cljc index f57fe7ac07..614ff7cf77 100644 --- a/common/test/common_tests/files/shapes_builder_test.cljc +++ b/common/test/common_tests/files/shapes_builder_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.shapes-builder-test (:require diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index 63f083ce5e..e196ba4ac1 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.tokens-test (:require diff --git a/common/test/common_tests/files/validate_test.cljc b/common/test/common_tests/files/validate_test.cljc index 271cfa611e..48fd3e3f07 100644 --- a/common/test/common_tests/files/validate_test.cljc +++ b/common/test/common_tests/files/validate_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files.validate-test "Exhaustive tests for the change-scoped partial validation functions in diff --git a/common/test/common_tests/files_builder_test.cljc b/common/test/common_tests/files_builder_test.cljc index 993f5cd827..707d56d3f0 100644 --- a/common/test/common_tests/files_builder_test.cljc +++ b/common/test/common_tests/files_builder_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-builder-test (:require diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 7671f7a787..4d91bf4f7f 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-changes-test (:require diff --git a/common/test/common_tests/files_migrations_0025_test.cljc b/common/test/common_tests/files_migrations_0025_test.cljc index 4d57398953..36480bd3cc 100644 --- a/common/test/common_tests/files_migrations_0025_test.cljc +++ b/common/test/common_tests/files_migrations_0025_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-migrations-0025-test (:require diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 36ff3a09e2..7a8f757c45 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.files-migrations-test (:require diff --git a/common/test/common_tests/fressian_test.clj b/common/test/common_tests/fressian_test.clj index 3eda0f34d4..d405a01e7c 100644 --- a/common/test/common_tests/fressian_test.clj +++ b/common/test/common_tests/fressian_test.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.fressian-test "Exhaustive unit tests for app.common.fressian encode/decode functions. diff --git a/common/test/common_tests/geom_align_test.cljc b/common/test/common_tests/geom_align_test.cljc index dc72fccf5d..5d71bf6030 100644 --- a/common/test/common_tests/geom_align_test.cljc +++ b/common/test/common_tests/geom_align_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-align-test (:require diff --git a/common/test/common_tests/geom_bounds_layout_nil_test.cljc b/common/test/common_tests/geom_bounds_layout_nil_test.cljc index db070ce4f4..dda9fa957f 100644 --- a/common/test/common_tests/geom_bounds_layout_nil_test.cljc +++ b/common/test/common_tests/geom_bounds_layout_nil_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-bounds-layout-nil-test (:require diff --git a/common/test/common_tests/geom_bounds_map_test.cljc b/common/test/common_tests/geom_bounds_map_test.cljc index 23b239eb43..4321c6a13e 100644 --- a/common/test/common_tests/geom_bounds_map_test.cljc +++ b/common/test/common_tests/geom_bounds_map_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-bounds-map-test (:require diff --git a/common/test/common_tests/geom_flex_layout_test.cljc b/common/test/common_tests/geom_flex_layout_test.cljc index bc63b03c8c..c016daeea7 100644 --- a/common/test/common_tests/geom_flex_layout_test.cljc +++ b/common/test/common_tests/geom_flex_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-flex-layout-test (:require diff --git a/common/test/common_tests/geom_grid_layout_test.cljc b/common/test/common_tests/geom_grid_layout_test.cljc index 369406ef38..1769ab634a 100644 --- a/common/test/common_tests/geom_grid_layout_test.cljc +++ b/common/test/common_tests/geom_grid_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-grid-layout-test (:require diff --git a/common/test/common_tests/geom_grid_test.cljc b/common/test/common_tests/geom_grid_test.cljc index 9a3645eb8b..6d3d823882 100644 --- a/common/test/common_tests/geom_grid_test.cljc +++ b/common/test/common_tests/geom_grid_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-grid-test (:require diff --git a/common/test/common_tests/geom_line_test.cljc b/common/test/common_tests/geom_line_test.cljc index d6e6df5360..b1f0f8e659 100644 --- a/common/test/common_tests/geom_line_test.cljc +++ b/common/test/common_tests/geom_line_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-line-test (:require diff --git a/common/test/common_tests/geom_modif_tree_test.cljc b/common/test/common_tests/geom_modif_tree_test.cljc index 80088703ce..1ce29057e1 100644 --- a/common/test/common_tests/geom_modif_tree_test.cljc +++ b/common/test/common_tests/geom_modif_tree_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-modif-tree-test (:require diff --git a/common/test/common_tests/geom_modifiers_test.cljc b/common/test/common_tests/geom_modifiers_test.cljc index 8784ff25f7..8494975e53 100644 --- a/common/test/common_tests/geom_modifiers_test.cljc +++ b/common/test/common_tests/geom_modifiers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-modifiers-test (:require diff --git a/common/test/common_tests/geom_point_test.cljc b/common/test/common_tests/geom_point_test.cljc index 1691c06757..d2742741bd 100644 --- a/common/test/common_tests/geom_point_test.cljc +++ b/common/test/common_tests/geom_point_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-point-test (:require diff --git a/common/test/common_tests/geom_proportions_test.cljc b/common/test/common_tests/geom_proportions_test.cljc index 3cb94c99fd..8aa05bd693 100644 --- a/common/test/common_tests/geom_proportions_test.cljc +++ b/common/test/common_tests/geom_proportions_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-proportions-test (:require diff --git a/common/test/common_tests/geom_rect_test.cljc b/common/test/common_tests/geom_rect_test.cljc index 8abfb76854..2f6e8e80f7 100644 --- a/common/test/common_tests/geom_rect_test.cljc +++ b/common/test/common_tests/geom_rect_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-rect-test (:require diff --git a/common/test/common_tests/geom_shapes_common_test.cljc b/common/test/common_tests/geom_shapes_common_test.cljc index 0a9e47f21d..1d7d47112b 100644 --- a/common/test/common_tests/geom_shapes_common_test.cljc +++ b/common/test/common_tests/geom_shapes_common_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-common-test (:require diff --git a/common/test/common_tests/geom_shapes_constraints_test.cljc b/common/test/common_tests/geom_shapes_constraints_test.cljc index 175cc6f77b..4f6cd3ce4c 100644 --- a/common/test/common_tests/geom_shapes_constraints_test.cljc +++ b/common/test/common_tests/geom_shapes_constraints_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-constraints-test (:require diff --git a/common/test/common_tests/geom_shapes_corners_test.cljc b/common/test/common_tests/geom_shapes_corners_test.cljc index 80efad7679..46692a28dd 100644 --- a/common/test/common_tests/geom_shapes_corners_test.cljc +++ b/common/test/common_tests/geom_shapes_corners_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-corners-test (:require diff --git a/common/test/common_tests/geom_shapes_effects_test.cljc b/common/test/common_tests/geom_shapes_effects_test.cljc index eeccde458a..af669686c9 100644 --- a/common/test/common_tests/geom_shapes_effects_test.cljc +++ b/common/test/common_tests/geom_shapes_effects_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-effects-test (:require diff --git a/common/test/common_tests/geom_shapes_intersect_test.cljc b/common/test/common_tests/geom_shapes_intersect_test.cljc index a670d938c4..ee1c082534 100644 --- a/common/test/common_tests/geom_shapes_intersect_test.cljc +++ b/common/test/common_tests/geom_shapes_intersect_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-intersect-test (:require diff --git a/common/test/common_tests/geom_shapes_strokes_test.cljc b/common/test/common_tests/geom_shapes_strokes_test.cljc index 1993add116..1ba89f00cb 100644 --- a/common/test/common_tests/geom_shapes_strokes_test.cljc +++ b/common/test/common_tests/geom_shapes_strokes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-strokes-test (:require diff --git a/common/test/common_tests/geom_shapes_test.cljc b/common/test/common_tests/geom_shapes_test.cljc index 87805559e6..e60ded0fe7 100644 --- a/common/test/common_tests/geom_shapes_test.cljc +++ b/common/test/common_tests/geom_shapes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-test (:require diff --git a/common/test/common_tests/geom_shapes_text_test.cljc b/common/test/common_tests/geom_shapes_text_test.cljc index 17185a67a1..7c1eef6bf3 100644 --- a/common/test/common_tests/geom_shapes_text_test.cljc +++ b/common/test/common_tests/geom_shapes_text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-text-test (:require diff --git a/common/test/common_tests/geom_shapes_tree_seq_test.cljc b/common/test/common_tests/geom_shapes_tree_seq_test.cljc index b2df798e41..58c5c535f8 100644 --- a/common/test/common_tests/geom_shapes_tree_seq_test.cljc +++ b/common/test/common_tests/geom_shapes_tree_seq_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-shapes-tree-seq-test (:require diff --git a/common/test/common_tests/geom_snap_test.cljc b/common/test/common_tests/geom_snap_test.cljc index 14776e1293..a7af7443b1 100644 --- a/common/test/common_tests/geom_snap_test.cljc +++ b/common/test/common_tests/geom_snap_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-snap-test (:require diff --git a/common/test/common_tests/geom_test.cljc b/common/test/common_tests/geom_test.cljc index ff14261eb7..98e80f7302 100644 --- a/common/test/common_tests/geom_test.cljc +++ b/common/test/common_tests/geom_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.geom-test (:require diff --git a/common/test/common_tests/helpers_test.cljc b/common/test/common_tests/helpers_test.cljc index 96c5b37fa0..437874b019 100644 --- a/common/test/common_tests/helpers_test.cljc +++ b/common/test/common_tests/helpers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.helpers-test (:require diff --git a/common/test/common_tests/logic/chained_propagation_test.cljc b/common/test/common_tests/logic/chained_propagation_test.cljc index be9a05487e..32a98e68dc 100644 --- a/common/test/common_tests/logic/chained_propagation_test.cljc +++ b/common/test/common_tests/logic/chained_propagation_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.chained-propagation-test (:require diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index 4021d3138b..d28e5f3b31 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-creation-test (:require diff --git a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc index c4020a48f2..3e06167e35 100644 --- a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc +++ b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-detach-with-nested-test (:require diff --git a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc index 03ceb6b8ee..d02a06b9e0 100644 --- a/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc +++ b/common/test/common_tests/logic/comp_main_edit_breaks_copy_slots_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-main-edit-breaks-copy-slots-test (:require diff --git a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc index 08c852bfc3..da766cd71d 100644 --- a/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc +++ b/common/test/common_tests/logic/comp_remove_swap_slots_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-remove-swap-slots-test (:require diff --git a/common/test/common_tests/logic/comp_reset_test.cljc b/common/test/common_tests/logic/comp_reset_test.cljc index 23b1136657..d9491271a3 100644 --- a/common/test/common_tests/logic/comp_reset_test.cljc +++ b/common/test/common_tests/logic/comp_reset_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-reset-test (:require diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index 8a7b652328..a929c7a616 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-sync-test (:require diff --git a/common/test/common_tests/logic/comp_touched_test.cljc b/common/test/common_tests/logic/comp_touched_test.cljc index 5d91fcbc1a..b31874d128 100644 --- a/common/test/common_tests/logic/comp_touched_test.cljc +++ b/common/test/common_tests/logic/comp_touched_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.comp-touched-test (:require diff --git a/common/test/common_tests/logic/copying_and_duplicating_test.cljc b/common/test/common_tests/logic/copying_and_duplicating_test.cljc index 784c3d81ff..2c8f1161ee 100644 --- a/common/test/common_tests/logic/copying_and_duplicating_test.cljc +++ b/common/test/common_tests/logic/copying_and_duplicating_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.copying-and-duplicating-test (:require diff --git a/common/test/common_tests/logic/duplicated_pages_test.cljc b/common/test/common_tests/logic/duplicated_pages_test.cljc index 70a0e9d206..34222703ab 100644 --- a/common/test/common_tests/logic/duplicated_pages_test.cljc +++ b/common/test/common_tests/logic/duplicated_pages_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.duplicated-pages-test (:require diff --git a/common/test/common_tests/logic/move_shapes_test.cljc b/common/test/common_tests/logic/move_shapes_test.cljc index 09ec4c09db..846f9c98f3 100644 --- a/common/test/common_tests/logic/move_shapes_test.cljc +++ b/common/test/common_tests/logic/move_shapes_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.move-shapes-test (:require diff --git a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc index 01544ce3f2..f1afd06b9d 100644 --- a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc +++ b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.multiple-nesting-levels-test (:require diff --git a/common/test/common_tests/logic/swap_and_reset_test.cljc b/common/test/common_tests/logic/swap_and_reset_test.cljc index c9cad989cc..6e1fa2e3da 100644 --- a/common/test/common_tests/logic/swap_and_reset_test.cljc +++ b/common/test/common_tests/logic/swap_and_reset_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-and-reset-test (:require diff --git a/common/test/common_tests/logic/swap_as_override_test.cljc b/common/test/common_tests/logic/swap_as_override_test.cljc index 6a0a0b0491..4c41b030e9 100644 --- a/common/test/common_tests/logic/swap_as_override_test.cljc +++ b/common/test/common_tests/logic/swap_as_override_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-as-override-test (:require diff --git a/common/test/common_tests/logic/swap_keeps_id_test.cljc b/common/test/common_tests/logic/swap_keeps_id_test.cljc index c3d001a0b6..d94981ff55 100644 --- a/common/test/common_tests/logic/swap_keeps_id_test.cljc +++ b/common/test/common_tests/logic/swap_keeps_id_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.swap-keeps-id-test (:require diff --git a/common/test/common_tests/logic/text_sync_test.cljc b/common/test/common_tests/logic/text_sync_test.cljc index 335994c05d..0863b3e75e 100644 --- a/common/test/common_tests/logic/text_sync_test.cljc +++ b/common/test/common_tests/logic/text_sync_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.text-sync-test (:require diff --git a/common/test/common_tests/logic/text_touched_test.cljc b/common/test/common_tests/logic/text_touched_test.cljc index f102880ba6..a82c635b5f 100644 --- a/common/test/common_tests/logic/text_touched_test.cljc +++ b/common/test/common_tests/logic/text_touched_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.text-touched-test (:require diff --git a/common/test/common_tests/logic/token_apply_test.cljc b/common/test/common_tests/logic/token_apply_test.cljc index 9715b7f235..b4a2c92c1f 100644 --- a/common/test/common_tests/logic/token_apply_test.cljc +++ b/common/test/common_tests/logic/token_apply_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.token-apply-test (:require diff --git a/common/test/common_tests/logic/token_test.cljc b/common/test/common_tests/logic/token_test.cljc index 6d54874ae4..16258b8c8f 100644 --- a/common/test/common_tests/logic/token_test.cljc +++ b/common/test/common_tests/logic/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.token-test (:require diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index 375b6e6c6e..06e8b5ac34 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.variants-switch-test (:require diff --git a/common/test/common_tests/logic/variants_test.cljc b/common/test/common_tests/logic/variants_test.cljc index 43cb163c1e..c4cfc1b95a 100644 --- a/common/test/common_tests/logic/variants_test.cljc +++ b/common/test/common_tests/logic/variants_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.logic.variants-test (:require diff --git a/common/test/common_tests/math_test.cljc b/common/test/common_tests/math_test.cljc index e644101895..524963df9b 100644 --- a/common/test/common_tests/math_test.cljc +++ b/common/test/common_tests/math_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.math-test (:require diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index c6916e3216..24302a02d1 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.media-test (:require diff --git a/common/test/common_tests/path_names_test.cljc b/common/test/common_tests/path_names_test.cljc index bddb94fc13..9e0d3fb873 100644 --- a/common/test/common_tests/path_names_test.cljc +++ b/common/test/common_tests/path_names_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.path-names-test (:require diff --git a/common/test/common_tests/record_test.cljc b/common/test/common_tests/record_test.cljc index 6878978414..2a5e6206de 100644 --- a/common/test/common_tests/record_test.cljc +++ b/common/test/common_tests/record_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.record-test (:require diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b24b045e1a..04232089ab 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.runner (:require diff --git a/common/test/common_tests/schema_test.cljc b/common/test/common_tests/schema_test.cljc index b14f1df0f5..041e9a2670 100644 --- a/common/test/common_tests/schema_test.cljc +++ b/common/test/common_tests/schema_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.schema-test (:require diff --git a/common/test/common_tests/spec_test.cljc b/common/test/common_tests/spec_test.cljc index 30eda62fc0..e40fa52157 100644 --- a/common/test/common_tests/spec_test.cljc +++ b/common/test/common_tests/spec_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.spec-test (:require diff --git a/common/test/common_tests/svg_path_test.cljc b/common/test/common_tests/svg_path_test.cljc index a59ed8c521..b82f1838a8 100644 --- a/common/test/common_tests/svg_path_test.cljc +++ b/common/test/common_tests/svg_path_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.svg-path-test (:require diff --git a/common/test/common_tests/svg_test.cljc b/common/test/common_tests/svg_test.cljc index 89fa30eae7..ed2c345037 100644 --- a/common/test/common_tests/svg_test.cljc +++ b/common/test/common_tests/svg_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.svg-test (:require diff --git a/common/test/common_tests/text_test.cljc b/common/test/common_tests/text_test.cljc index dcb07bca15..c3db06c5b9 100644 --- a/common/test/common_tests/text_test.cljc +++ b/common/test/common_tests/text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.text-test (:require diff --git a/common/test/common_tests/time_test.cljc b/common/test/common_tests/time_test.cljc index 3015c4fd36..a80ed8f16b 100644 --- a/common/test/common_tests/time_test.cljc +++ b/common/test/common_tests/time_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.time-test (:require diff --git a/common/test/common_tests/token_test.cljc b/common/test/common_tests/token_test.cljc index 5065bfc396..0a998939dd 100644 --- a/common/test/common_tests/token_test.cljc +++ b/common/test/common_tests/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.token-test (:require diff --git a/common/test/common_tests/types/absorb_assets_test.cljc b/common/test/common_tests/types/absorb_assets_test.cljc index f115e1accc..8e8ee2ee36 100644 --- a/common/test/common_tests/types/absorb_assets_test.cljc +++ b/common/test/common_tests/types/absorb_assets_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.absorb-assets-test (:require diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc index ad0155adf3..0679d7130f 100644 --- a/common/test/common_tests/types/color_test.cljc +++ b/common/test/common_tests/types/color_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.color-test (:require diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc index 9a63464ce3..4d42df92f8 100644 --- a/common/test/common_tests/types/components_test.cljc +++ b/common/test/common_tests/types/components_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.components-test (:require diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc index 6ab45c4f0a..e00b1fb9c6 100644 --- a/common/test/common_tests/types/container_test.cljc +++ b/common/test/common_tests/types/container_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.container-test (:require diff --git a/common/test/common_tests/types/fill_test.cljc b/common/test/common_tests/types/fill_test.cljc index 0a22c1e866..147da3f098 100644 --- a/common/test/common_tests/types/fill_test.cljc +++ b/common/test/common_tests/types/fill_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.fill-test (:require diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc index c381c14893..b9281e7fd5 100644 --- a/common/test/common_tests/types/font_test.cljc +++ b/common/test/common_tests/types/font_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.font-test (:require diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 405da89935..c427f6e93f 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.modifiers-test (:require diff --git a/common/test/common_tests/types/objects_map_test.cljc b/common/test/common_tests/types/objects_map_test.cljc index c9999a6260..7eae046e3a 100644 --- a/common/test/common_tests/types/objects_map_test.cljc +++ b/common/test/common_tests/types/objects_map_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.objects-map-test (:require diff --git a/common/test/common_tests/types/organization_test.cljc b/common/test/common_tests/types/organization_test.cljc index e6a24aeb00..1d2c48be01 100644 --- a/common/test/common_tests/types/organization_test.cljc +++ b/common/test/common_tests/types/organization_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.organization-test (:require diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 714270cb62..6205e22a14 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.path-data-test (:require diff --git a/common/test/common_tests/types/shape_decode_encode_test.cljc b/common/test/common_tests/types/shape_decode_encode_test.cljc index 044c48ede3..8d24cff871 100644 --- a/common/test/common_tests/types/shape_decode_encode_test.cljc +++ b/common/test/common_tests/types/shape_decode_encode_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-decode-encode-test (:require diff --git a/common/test/common_tests/types/shape_interactions_test.cljc b/common/test/common_tests/types/shape_interactions_test.cljc index 7daac0ab8a..6dbf7115c0 100644 --- a/common/test/common_tests/types/shape_interactions_test.cljc +++ b/common/test/common_tests/types/shape_interactions_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-interactions-test (:require diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc index e655c66713..308dd758a6 100644 --- a/common/test/common_tests/types/shape_layout_test.cljc +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.shape-layout-test (:require diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index b63a6db6e1..6fa69360f3 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.text-test (:require diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index 6f9106b6ad..3cded0dc4f 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.token-test (:require diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index bb2d5cf204..3a37c82a51 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.tokens-lib-test (:require diff --git a/common/test/common_tests/types/tokens_migrations_test.cljc b/common/test/common_tests/types/tokens_migrations_test.cljc index 04bbd9d7a1..779c24395f 100644 --- a/common/test/common_tests/types/tokens_migrations_test.cljc +++ b/common/test/common_tests/types/tokens_migrations_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.tokens-migrations-test (:require diff --git a/common/test/common_tests/types/variant_test.cljc b/common/test/common_tests/types/variant_test.cljc index 6261798946..f477da5bd9 100644 --- a/common/test/common_tests/types/variant_test.cljc +++ b/common/test/common_tests/types/variant_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.types.variant-test (:require diff --git a/common/test/common_tests/undo_stack_test.cljc b/common/test/common_tests/undo_stack_test.cljc index f751506228..10b65ff66c 100644 --- a/common/test/common_tests/undo_stack_test.cljc +++ b/common/test/common_tests/undo_stack_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.undo-stack-test (:require diff --git a/common/test/common_tests/uuid_test.cljc b/common/test/common_tests/uuid_test.cljc index ec6f14ceee..d30a8e8a09 100644 --- a/common/test/common_tests/uuid_test.cljc +++ b/common/test/common_tests/uuid_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.uuid-test (:require diff --git a/common/test/common_tests/variant_test.cljc b/common/test/common_tests/variant_test.cljc index f097e112b1..d0a7099dc7 100644 --- a/common/test/common_tests/variant_test.cljc +++ b/common/test/common_tests/variant_test.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns common-tests.variant-test (:require diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index fc364494e1..9a02e0a5ae 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.browser (:require diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index 127a28fa99..df88aae0e6 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:refer-clojure :exclude [get]) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index 65828acd7a..a69d0a63c9 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.core (:require diff --git a/exporter/src/app/handlers.cljs b/exporter/src/app/handlers.cljs index cc97d93cdb..b40911fbf1 100644 --- a/exporter/src/app/handlers.cljs +++ b/exporter/src/app/handlers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers (:require diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index 658456059d..f13a389eaa 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.export-frames (:require diff --git a/exporter/src/app/handlers/export_shapes.cljs b/exporter/src/app/handlers/export_shapes.cljs index 6213281453..a07b58ad72 100644 --- a/exporter/src/app/handlers/export_shapes.cljs +++ b/exporter/src/app/handlers/export_shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.export-shapes (:require diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index 4c4bb7225c..d703821f00 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.handlers.resources "Temporal resources management." diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index 824f22d5cd..e025ca2584 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.http (:require diff --git a/exporter/src/app/redis.cljs b/exporter/src/app/redis.cljs index 68c74a04c8..8dfb144f39 100644 --- a/exporter/src/app/redis.cljs +++ b/exporter/src/app/redis.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.redis (:require diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index 135c8e5925..01093f5da6 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer "Common renderer interface." diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index e04c60076b..ece18e2e2b 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.bitmap "A bitmap renderer." diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index ba4118c1e8..5d983d80c0 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.pdf "A pdf renderer." diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 0db4bc0cf8..fdd9b7c0de 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.svg (:require diff --git a/exporter/src/app/renderer/svg_gradient.cljs b/exporter/src/app/renderer/svg_gradient.cljs index 2efaca2c0d..897ed80304 100644 --- a/exporter/src/app/renderer/svg_gradient.cljs +++ b/exporter/src/app/renderer/svg_gradient.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.svg-gradient) diff --git a/exporter/src/app/renderer/wasm.cljs b/exporter/src/app/renderer/wasm.cljs index 902ba17a9a..54ad486bee 100644 --- a/exporter/src/app/renderer/wasm.cljs +++ b/exporter/src/app/renderer/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.wasm "Headless renderer backend: renders exports with the render-wasm Skia diff --git a/exporter/src/app/util/mime.cljs b/exporter/src/app/util/mime.cljs index bd8b885a2c..3f6ce62fbe 100644 --- a/exporter/src/app/util/mime.cljs +++ b/exporter/src/app/util/mime.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.mime "Mimetype and file extension helpers." diff --git a/exporter/src/app/util/object.cljs b/exporter/src/app/util/object.cljs index bf38ec4475..8c22bcfe92 100644 --- a/exporter/src/app/util/object.cljs +++ b/exporter/src/app/util/object.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.object "A collection of helpers for work with javascript objects." diff --git a/exporter/src/app/util/shell.cljs b/exporter/src/app/util/shell.cljs index 8331888b00..929924899b 100644 --- a/exporter/src/app/util/shell.cljs +++ b/exporter/src/app/util/shell.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shell "Shell & FS utilities." diff --git a/exporter/src/app/util/transit.cljs b/exporter/src/app/util/transit.cljs index 97cc02ca4e..859606e3ca 100644 --- a/exporter/src/app/util/transit.cljs +++ b/exporter/src/app/util/transit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.transit (:require diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index 8a06b9b4f2..31e6bbbe67 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm "Headless driver for the render-wasm module under Node: the GPU-free diff --git a/exporter/src/app/wasm/enums.cljs b/exporter/src/app/wasm/enums.cljs index 9c1a429522..5ecd5ebb43 100644 --- a/exporter/src/app/wasm/enums.cljs +++ b/exporter/src/app/wasm/enums.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.enums "Binds this build's generated enums into the shared bridge. diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs index cbb08c27f3..f3edb39a6a 100644 --- a/exporter/src/app/wasm/serialize.cljs +++ b/exporter/src/app/wasm/serialize.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.serialize "Browser-free shape serialization for the headless exporter: the counterpart diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs index 714542d7f1..13b037652b 100644 --- a/exporter/src/app/wasm/text.cljs +++ b/exporter/src/app/wasm/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.wasm.text "Browser-free text-content serialization for the headless exporter. Only the diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs index d680b344ab..3c841a2d77 100644 --- a/exporter/test/exporter_tests/renderer_svg_test.cljs +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.renderer-svg-test (:require diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs index a0aa4481e0..a0b7c3020d 100644 --- a/exporter/test/exporter_tests/runner.cljs +++ b/exporter/test/exporter_tests/runner.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.runner (:require diff --git a/exporter/test/exporter_tests/shell_test.cljs b/exporter/test/exporter_tests/shell_test.cljs index e232a4ed67..5858328eb3 100644 --- a/exporter/test/exporter_tests/shell_test.cljs +++ b/exporter/test/exporter_tests/shell_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns exporter-tests.shell-test "Tests to verify GHSA-4f36-m4hj-cv86 is fixed: OS Command Injection in SVG exporter. diff --git a/frontend/dev/user.clj b/frontend/dev/user.clj index ae9f11904f..30d3a811f1 100644 --- a/frontend/dev/user.clj +++ b/frontend/dev/user.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns user (:require diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index eae0a5f3c0..5372d7ee40 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import pkg from "draft-js"; diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 41d79a0bf4..595ddb0dac 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // TODO: Legacy sass vars. We should use DS tokens. $color-gray-50: #303236; diff --git a/frontend/resources/styles/common/dependencies/fonts.scss b/frontend/resources/styles/common/dependencies/fonts.scss index dd95f85bff..35fc55a872 100644 --- a/frontend/resources/styles/common/dependencies/fonts.scss +++ b/frontend/resources/styles/common/dependencies/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:string"; diff --git a/frontend/resources/styles/common/dependencies/highlight.scss b/frontend/resources/styles/common/dependencies/highlight.scss index f457cd247a..b09dd0d4cf 100644 --- a/frontend/resources/styles/common/dependencies/highlight.scss +++ b/frontend/resources/styles/common/dependencies/highlight.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/dependencies/storybook.scss b/frontend/resources/styles/common/dependencies/storybook.scss index 951409ac93..07cb2a472c 100644 --- a/frontend/resources/styles/common/dependencies/storybook.scss +++ b/frontend/resources/styles/common/dependencies/storybook.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .sb-show-main.sb-main-fullscreen, .sb-show-main.sb-main-padded { diff --git a/frontend/resources/styles/common/refactor/animations.scss b/frontend/resources/styles/common/refactor/animations.scss index acfcef89d3..7816078825 100644 --- a/frontend/resources/styles/common/refactor/animations.scss +++ b/frontend/resources/styles/common/refactor/animations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @mixin animation($delay, $duration, $animation) { animation-delay: $delay; diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index 35d74a25e8..af5717743d 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; @use "./borders.scss" as *; diff --git a/frontend/resources/styles/common/refactor/borders.scss b/frontend/resources/styles/common/refactor/borders.scss index ab55b9445e..9a70d87d22 100644 --- a/frontend/resources/styles/common/refactor/borders.scss +++ b/frontend/resources/styles/common/refactor/borders.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Border radius $br-0: 0; diff --git a/frontend/resources/styles/common/refactor/color-defs.scss b/frontend/resources/styles/common/refactor/color-defs.scss index fdaf3bf2fb..df8731a053 100644 --- a/frontend/resources/styles/common/refactor/color-defs.scss +++ b/frontend/resources/styles/common/refactor/color-defs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; diff --git a/frontend/resources/styles/common/refactor/common-dashboard.scss b/frontend/resources/styles/common/refactor/common-dashboard.scss index 4952ef4b10..ba38cd0b78 100644 --- a/frontend/resources/styles/common/refactor/common-dashboard.scss +++ b/frontend/resources/styles/common/refactor/common-dashboard.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor" as *; diff --git a/frontend/resources/styles/common/refactor/common-refactor.scss b/frontend/resources/styles/common/refactor/common-refactor.scss index 9aa9de0be6..dbf8b0faf2 100644 --- a/frontend/resources/styles/common/refactor/common-refactor.scss +++ b/frontend/resources/styles/common/refactor/common-refactor.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // ################################################# // MAIN STYLES diff --git a/frontend/resources/styles/common/refactor/design-tokens.scss b/frontend/resources/styles/common/refactor/design-tokens.scss index d3f1df4034..f39dad7bfb 100644 --- a/frontend/resources/styles/common/refactor/design-tokens.scss +++ b/frontend/resources/styles/common/refactor/design-tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; .light, diff --git a/frontend/resources/styles/common/refactor/focus.scss b/frontend/resources/styles/common/refactor/focus.scss index 970efaee2a..7bfb05d6c1 100644 --- a/frontend/resources/styles/common/refactor/focus.scss +++ b/frontend/resources/styles/common/refactor/focus.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/fonts.scss b/frontend/resources/styles/common/refactor/fonts.scss index 5740b02538..b100b70f84 100644 --- a/frontend/resources/styles/common/refactor/fonts.scss +++ b/frontend/resources/styles/common/refactor/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/resources/styles/common/refactor/mixins.scss b/frontend/resources/styles/common/refactor/mixins.scss index 52de45d319..b5b4770f7a 100644 --- a/frontend/resources/styles/common/refactor/mixins.scss +++ b/frontend/resources/styles/common/refactor/mixins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./fonts.scss" as *; @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/opacity.scss b/frontend/resources/styles/common/refactor/opacity.scss index 3122c338c2..944e52354c 100644 --- a/frontend/resources/styles/common/refactor/opacity.scss +++ b/frontend/resources/styles/common/refactor/opacity.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Opacity values $op-0: 0; diff --git a/frontend/resources/styles/common/refactor/shadows.scss b/frontend/resources/styles/common/refactor/shadows.scss index a332677a00..6a2e081dea 100644 --- a/frontend/resources/styles/common/refactor/shadows.scss +++ b/frontend/resources/styles/common/refactor/shadows.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./spacing.scss" as *; diff --git a/frontend/resources/styles/common/refactor/spacing.scss b/frontend/resources/styles/common/refactor/spacing.scss index 3158dd2b79..5bafe8512e 100644 --- a/frontend/resources/styles/common/refactor/spacing.scss +++ b/frontend/resources/styles/common/refactor/spacing.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/resources/styles/common/refactor/themes.scss b/frontend/resources/styles/common/refactor/themes.scss index 155bfe14cf..24f15d6125 100644 --- a/frontend/resources/styles/common/refactor/themes.scss +++ b/frontend/resources/styles/common/refactor/themes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @forward "./themes/default-theme"; @forward "./themes/light-theme"; diff --git a/frontend/resources/styles/common/refactor/themes/default-theme.scss b/frontend/resources/styles/common/refactor/themes/default-theme.scss index d40242d626..6382f8003f 100644 --- a/frontend/resources/styles/common/refactor/themes/default-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/default-theme.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/refactor/themes/light-theme.scss b/frontend/resources/styles/common/refactor/themes/light-theme.scss index c0faf81096..249437360b 100644 --- a/frontend/resources/styles/common/refactor/themes/light-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/light-theme.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:meta"; diff --git a/frontend/resources/styles/common/refactor/z-index.scss b/frontend/resources/styles/common/refactor/z-index.scss index efbb31f8bc..52f0759bed 100644 --- a/frontend/resources/styles/common/refactor/z-index.scss +++ b/frontend/resources/styles/common/refactor/z-index.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $z-index-1: 1; // floating elements $z-index-2: 2; // sidebars diff --git a/frontend/resources/styles/debug.scss b/frontend/resources/styles/debug.scss index 65910ec5db..fc08132a10 100644 --- a/frontend/resources/styles/debug.scss +++ b/frontend/resources/styles/debug.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // NOTE: This CSS only gets included when the NODE_ENV env var // is *not* set to `production`. diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss index dbec87e820..e1735cbd8b 100644 --- a/frontend/resources/styles/main-default.scss +++ b/frontend/resources/styles/main-default.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // ################################################# // MAIN STYLES diff --git a/frontend/scripts/build-fonts-preview.js b/frontend/scripts/build-fonts-preview.js index 6c609252b4..d61e644ac2 100644 --- a/frontend/scripts/build-fonts-preview.js +++ b/frontend/scripts/build-fonts-preview.js @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // Builds one SVG sprite previewing every catalog (built-in + Google) font name, // outlined in its own typeface, so the picker loads it once instead of one diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index dc2c5a237a..fa04d4096c 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.config (:require diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 83d0eda803..b6fbfb3ca5 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main (:require diff --git a/frontend/src/app/main/broadcast.cljs b/frontend/src/app/main/broadcast.cljs index fe9f4e123c..a76272a262 100644 --- a/frontend/src/app/main/broadcast.cljs +++ b/frontend/src/app/main/broadcast.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.broadcast "BroadcastChannel API." diff --git a/frontend/src/app/main/constants.cljs b/frontend/src/app/main/constants.cljs index 40390e2ee4..4aeaa0461e 100644 --- a/frontend/src/app/main/constants.cljs +++ b/frontend/src/app/main/constants.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.constants) diff --git a/frontend/src/app/main/data/auth.cljs b/frontend/src/app/main/data/auth.cljs index 2339c02452..a79e372401 100644 --- a/frontend/src/app/main/data/auth.cljs +++ b/frontend/src/app/main/data/auth.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.auth "Auth related data events" diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 74a2d97659..1ecb0f67b9 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.changes (:require diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index a69d759272..c156536de5 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.comments (:require diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index f74874d297..fca2d0f567 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.common "A general purpose events." diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 95b9f06039..4d9619d630 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard (:require diff --git a/frontend/src/app/main/data/dashboard/shortcuts.cljs b/frontend/src/app/main/data/dashboard/shortcuts.cljs index 2e737a660a..061e53d30f 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard.shortcuts (:require diff --git a/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs b/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs index a9178efb1a..8828037028 100644 --- a/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs +++ b/frontend/src/app/main/data/dashboard/shortcuts/customize.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.dashboard.shortcuts.customize (:require diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index 676937fd47..0c507b3f0c 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.event (:require diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 9a22fe25b9..0ca870db09 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.assets (:require diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 73917747be..1acefa5880 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.files "The file exportation API and events" diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 4cbd5285f8..25d1405c8d 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.exports.wasm (:require diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 9b0f26fdfb..2ec056487d 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.fonts (:require diff --git a/frontend/src/app/main/data/helpers.cljs b/frontend/src/app/main/data/helpers.cljs index 3681e821de..a0f46e30e1 100644 --- a/frontend/src/app/main/data/helpers.cljs +++ b/frontend/src/app/main/data/helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.helpers (:require diff --git a/frontend/src/app/main/data/media.cljs b/frontend/src/app/main/data/media.cljs index 74eec0ac56..22b2f9f13b 100644 --- a/frontend/src/app/main/data/media.cljs +++ b/frontend/src/app/main/data/media.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.media (:require diff --git a/frontend/src/app/main/data/modal.cljs b/frontend/src/app/main/data/modal.cljs index 052080f3fb..458eba37ad 100644 --- a/frontend/src/app/main/data/modal.cljs +++ b/frontend/src/app/main/data/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.modal (:refer-clojure :exclude [update]) diff --git a/frontend/src/app/main/data/nitrate_audit.cljs b/frontend/src/app/main/data/nitrate_audit.cljs index 619f56d3ec..0d75d0014e 100644 --- a/frontend/src/app/main/data/nitrate_audit.cljs +++ b/frontend/src/app/main/data/nitrate_audit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.nitrate-audit (:require diff --git a/frontend/src/app/main/data/notifications.cljs b/frontend/src/app/main/data/notifications.cljs index 7e4f46f91d..2a9e823612 100644 --- a/frontend/src/app/main/data/notifications.cljs +++ b/frontend/src/app/main/data/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.notifications (:require diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 03fbdb319a..55eac8177d 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.persistence (:require diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index 4f7e8b7ceb..bbdba8ee20 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.plugins (:require diff --git a/frontend/src/app/main/data/preview.cljs b/frontend/src/app/main/data/preview.cljs index c300c0f88f..ce28ab8b31 100644 --- a/frontend/src/app/main/data/preview.cljs +++ b/frontend/src/app/main/data/preview.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.preview (:require diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 9d6db2101d..f0814a2098 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.profile (:require diff --git a/frontend/src/app/main/data/project.cljs b/frontend/src/app/main/data/project.cljs index 41a1ce0c9e..1d7db839c6 100644 --- a/frontend/src/app/main/data/project.cljs +++ b/frontend/src/app/main/data/project.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.project (:require diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index fa5e86e547..6ed22e5fb1 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.shortcuts (:refer-clojure :exclude [meta reset!]) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index ba1f2dd67f..e101cea063 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.style-dictionary (:require diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index f6173b3876..bff83ff03a 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.team (:require diff --git a/frontend/src/app/main/data/tinycolor.cljs b/frontend/src/app/main/data/tinycolor.cljs index 5b3b36c2f0..a0d32374c7 100644 --- a/frontend/src/app/main/data/tinycolor.cljs +++ b/frontend/src/app/main/data/tinycolor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.tinycolor "Bindings for tinycolor2 which supports a wide range of css compatible colors. diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs index 7d3ee4f071..5ae47c477a 100644 --- a/frontend/src/app/main/data/uploads.cljs +++ b/frontend/src/app/main/data/uploads.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.uploads "Generic chunked-upload helpers. diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 1d9c49f9d3..9516efef7c 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.viewer (:require diff --git a/frontend/src/app/main/data/viewer/shortcuts.cljs b/frontend/src/app/main/data/viewer/shortcuts.cljs index f16dcb6d3a..e50142e424 100644 --- a/frontend/src/app/main/data/viewer/shortcuts.cljs +++ b/frontend/src/app/main/data/viewer/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.viewer.shortcuts (:require diff --git a/frontend/src/app/main/data/websocket.cljs b/frontend/src/app/main/data/websocket.cljs index df55f55dce..9b28df7448 100644 --- a/frontend/src/app/main/data/websocket.cljs +++ b/frontend/src/app/main/data/websocket.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.websocket (:require diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 3fb06a8d7f..b246a6a737 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace (:require diff --git a/frontend/src/app/main/data/workspace/assets.cljs b/frontend/src/app/main/data/workspace/assets.cljs index dd1b3befc2..c48db42832 100644 --- a/frontend/src/app/main/data/workspace/assets.cljs +++ b/frontend/src/app/main/data/workspace/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.assets "Workspace assets management events and helpers." diff --git a/frontend/src/app/main/data/workspace/bool.cljs b/frontend/src/app/main/data/workspace/bool.cljs index 066dae66c1..a961299f5d 100644 --- a/frontend/src/app/main/data/workspace/bool.cljs +++ b/frontend/src/app/main/data/workspace/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.bool (:require diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index ac803897d7..a8c1dc179e 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.clipboard (:require diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index b84e71c62b..28850820ac 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.collapse (:require diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index a844bb5a40..d4e7f587f8 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.colors (:require diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs index 77735a7a42..a441a01c58 100644 --- a/frontend/src/app/main/data/workspace/comments.cljs +++ b/frontend/src/app/main/data/workspace/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.comments (:require diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index cd50d38b11..d682bbfe0e 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.common (:require diff --git a/frontend/src/app/main/data/workspace/drawing.cljs b/frontend/src/app/main/data/workspace/drawing.cljs index 4812cf32a0..acd35a52fc 100644 --- a/frontend/src/app/main/data/workspace/drawing.cljs +++ b/frontend/src/app/main/data/workspace/drawing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing "Drawing interactions." diff --git a/frontend/src/app/main/data/workspace/drawing/box.cljs b/frontend/src/app/main/data/workspace/drawing/box.cljs index 32fcc08b4e..18f510f722 100644 --- a/frontend/src/app/main/data/workspace/drawing/box.cljs +++ b/frontend/src/app/main/data/workspace/drawing/box.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.box (:require diff --git a/frontend/src/app/main/data/workspace/drawing/common.cljs b/frontend/src/app/main/data/workspace/drawing/common.cljs index 10a0164c9c..7a93af1289 100644 --- a/frontend/src/app/main/data/workspace/drawing/common.cljs +++ b/frontend/src/app/main/data/workspace/drawing/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.common (:require diff --git a/frontend/src/app/main/data/workspace/drawing/curve.cljs b/frontend/src/app/main/data/workspace/drawing/curve.cljs index 2324ae59fa..8275c95050 100644 --- a/frontend/src/app/main/data/workspace/drawing/curve.cljs +++ b/frontend/src/app/main/data/workspace/drawing/curve.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.curve (:require diff --git a/frontend/src/app/main/data/workspace/drawing/line.cljs b/frontend/src/app/main/data/workspace/drawing/line.cljs index 8b2995fb43..10fc76f6b7 100644 --- a/frontend/src/app/main/data/workspace/drawing/line.cljs +++ b/frontend/src/app/main/data/workspace/drawing/line.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.drawing.line "Drawing handler for the Line (L) and Arrow (Shift+L) tools. diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs index 58815458a2..08ea46ca4a 100644 --- a/frontend/src/app/main/data/workspace/edition.cljs +++ b/frontend/src/app/main/data/workspace/edition.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.edition (:require diff --git a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs index 374c163938..0f90250ca0 100644 --- a/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs +++ b/frontend/src/app/main/data/workspace/fix_deleted_fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.fix-deleted-fonts (:require diff --git a/frontend/src/app/main/data/workspace/grid.cljs b/frontend/src/app/main/data/workspace/grid.cljs index b2043ed71e..ed4c2d7ecc 100644 --- a/frontend/src/app/main/data/workspace/grid.cljs +++ b/frontend/src/app/main/data/workspace/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid (:require 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 8546b1178a..6187305fd0 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/editor.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid-layout.editor (:require diff --git a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs index 326ffe93c3..fc6f2c7132 100644 --- a/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/grid_layout/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.grid-layout.shortcuts (:require diff --git a/frontend/src/app/main/data/workspace/groups.cljs b/frontend/src/app/main/data/workspace/groups.cljs index 3e0f254e4a..f60ae89ba8 100644 --- a/frontend/src/app/main/data/workspace/groups.cljs +++ b/frontend/src/app/main/data/workspace/groups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.groups (:require diff --git a/frontend/src/app/main/data/workspace/guides.cljs b/frontend/src/app/main/data/workspace/guides.cljs index 1d1c4c8059..0fd638411e 100644 --- a/frontend/src/app/main/data/workspace/guides.cljs +++ b/frontend/src/app/main/data/workspace/guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.guides (:require diff --git a/frontend/src/app/main/data/workspace/highlight.cljs b/frontend/src/app/main/data/workspace/highlight.cljs index e8f080127b..6e985238e2 100644 --- a/frontend/src/app/main/data/workspace/highlight.cljs +++ b/frontend/src/app/main/data/workspace/highlight.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.highlight (:require diff --git a/frontend/src/app/main/data/workspace/history.cljs b/frontend/src/app/main/data/workspace/history.cljs index 5c3ac52d60..a5059f8c64 100644 --- a/frontend/src/app/main/data/workspace/history.cljs +++ b/frontend/src/app/main/data/workspace/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.history (:require diff --git a/frontend/src/app/main/data/workspace/interactions.cljs b/frontend/src/app/main/data/workspace/interactions.cljs index 673697a74d..2ef326cfbc 100644 --- a/frontend/src/app/main/data/workspace/interactions.cljs +++ b/frontend/src/app/main/data/workspace/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.interactions (:require diff --git a/frontend/src/app/main/data/workspace/layers.cljs b/frontend/src/app/main/data/workspace/layers.cljs index 79fe42e501..7165851b84 100644 --- a/frontend/src/app/main/data/workspace/layers.cljs +++ b/frontend/src/app/main/data/workspace/layers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.layers "Events related with layers transformations" diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index fa8208c0ff..6c8aed33cc 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.layout "Workspace layout management events and helpers." diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index fd2250c379..daf0876088 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.libraries (:require diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 8931690c2b..bc7ef868eb 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.mcp (:require diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index b0fc60445e..f3a395a504 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.media (:require diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index cb8be131cd..bb29d773b9 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.modifiers "Events related with shapes transformations" diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index d2b41889d8..55f9735814 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.notifications (:require diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index 260ebef9e1..44c3ccab94 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.pages (:require diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index 8948147c02..c2e043f58a 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path (:require diff --git a/frontend/src/app/main/data/workspace/path/changes.cljs b/frontend/src/app/main/data/workspace/path/changes.cljs index 5680b9402f..2ed96b3e7b 100644 --- a/frontend/src/app/main/data/workspace/path/changes.cljs +++ b/frontend/src/app/main/data/workspace/path/changes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.changes (:require diff --git a/frontend/src/app/main/data/workspace/path/common.cljs b/frontend/src/app/main/data/workspace/path/common.cljs index cfb59be0a8..ffdeeac51c 100644 --- a/frontend/src/app/main/data/workspace/path/common.cljs +++ b/frontend/src/app/main/data/workspace/path/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.common (:require diff --git a/frontend/src/app/main/data/workspace/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 4caa22959a..6154aa8451 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.drawing (:require diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index d70fff5cb6..9648756d71 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.edition (:require diff --git a/frontend/src/app/main/data/workspace/path/helpers.cljs b/frontend/src/app/main/data/workspace/path/helpers.cljs index 4432cb0b08..1800850bf4 100644 --- a/frontend/src/app/main/data/workspace/path/helpers.cljs +++ b/frontend/src/app/main/data/workspace/path/helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.helpers (:require diff --git a/frontend/src/app/main/data/workspace/path/selection.cljs b/frontend/src/app/main/data/workspace/path/selection.cljs index e4d28c3dd6..0de46d1049 100644 --- a/frontend/src/app/main/data/workspace/path/selection.cljs +++ b/frontend/src/app/main/data/workspace/path/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.selection (:require diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index 6c55397514..7dfb6f4c7e 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.shapes-to-path (:require diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index 71403d731f..af910e3cd8 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.shortcuts (:require diff --git a/frontend/src/app/main/data/workspace/path/state.cljs b/frontend/src/app/main/data/workspace/path/state.cljs index 87a0ebe64b..af1878f790 100644 --- a/frontend/src/app/main/data/workspace/path/state.cljs +++ b/frontend/src/app/main/data/workspace/path/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.state (:require diff --git a/frontend/src/app/main/data/workspace/path/streams.cljs b/frontend/src/app/main/data/workspace/path/streams.cljs index 530cb6a977..e567763d45 100644 --- a/frontend/src/app/main/data/workspace/path/streams.cljs +++ b/frontend/src/app/main/data/workspace/path/streams.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.streams (:require diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index 56a752df03..62ea5eed39 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.tools (:require diff --git a/frontend/src/app/main/data/workspace/path/undo.cljs b/frontend/src/app/main/data/workspace/path/undo.cljs index 76a9f35f62..ad5ef82d43 100644 --- a/frontend/src/app/main/data/workspace/path/undo.cljs +++ b/frontend/src/app/main/data/workspace/path/undo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.path.undo (:require diff --git a/frontend/src/app/main/data/workspace/reflow.cljs b/frontend/src/app/main/data/workspace/reflow.cljs index 1fc4842672..b6326d0e14 100644 --- a/frontend/src/app/main/data/workspace/reflow.cljs +++ b/frontend/src/app/main/data/workspace/reflow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.reflow "Tracks the ids that have layout/reflow work in flight, broken down by the diff --git a/frontend/src/app/main/data/workspace/reflow/signals.cljs b/frontend/src/app/main/data/workspace/reflow/signals.cljs index d2c145676e..fe92de3ea9 100644 --- a/frontend/src/app/main/data/workspace/reflow/signals.cljs +++ b/frontend/src/app/main/data/workspace/reflow/signals.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.reflow.signals "Decides which reflow signals a shape update raises: `:layout/update` for the diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 838d6cc02e..aaab6e6802 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.selection (:require diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index d4c09ec75e..9d03bdbec1 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shape-layout (:require diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 3530e35f3b..86ae4eff8c 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shapes (:require diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 192258f0d7..0923a24f80 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.shortcuts (:require diff --git a/frontend/src/app/main/data/workspace/specialized_panel.cljs b/frontend/src/app/main/data/workspace/specialized_panel.cljs index f3dd744c21..f32ed5f751 100644 --- a/frontend/src/app/main/data/workspace/specialized_panel.cljs +++ b/frontend/src/app/main/data/workspace/specialized_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.specialized-panel (:require diff --git a/frontend/src/app/main/data/workspace/svg_upload.cljs b/frontend/src/app/main/data/workspace/svg_upload.cljs index 2c292ca7a8..a65b232363 100644 --- a/frontend/src/app/main/data/workspace/svg_upload.cljs +++ b/frontend/src/app/main/data/workspace/svg_upload.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.svg-upload (:require diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index 9943c809e0..aff322c95d 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.text.shortcuts (:require diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 41dc5e87ba..526c31236b 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.texts (:require diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs index c0b27abe95..d9c288eac3 100644 --- a/frontend/src/app/main/data/workspace/texts_v3.cljs +++ b/frontend/src/app/main/data/workspace/texts_v3.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.texts-v3 (:require diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 3526c1ed99..ebc6e79d9e 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.thumbnails (:require diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index edef890e1e..4bcfe857f6 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.thumbnails-wasm "WASM-based component thumbnail rendering. diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 15592b3b13..dd96ebf89c 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.application (:require diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index 4c6035bee5..a713e76d0f 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.color (:require diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index 8ca54af835..bb4d06ded7 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.errors (:require diff --git a/frontend/src/app/main/data/workspace/tokens/import_export.cljs b/frontend/src/app/main/data/workspace/tokens/import_export.cljs index 5c7c9151b7..38abfe710b 100644 --- a/frontend/src/app/main/data/workspace/tokens/import_export.cljs +++ b/frontend/src/app/main/data/workspace/tokens/import_export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.import-export (:require diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 5ccf4c2a31..b5b39a467e 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.library-edit (:require diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index 3685e77970..9440b32c81 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.propagation (:require diff --git a/frontend/src/app/main/data/workspace/tokens/remapping.cljs b/frontend/src/app/main/data/workspace/tokens/remapping.cljs index c9c15224eb..e36b9d1b1d 100644 --- a/frontend/src/app/main/data/workspace/tokens/remapping.cljs +++ b/frontend/src/app/main/data/workspace/tokens/remapping.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.remapping "Core logic for token remapping functionality" diff --git a/frontend/src/app/main/data/workspace/tokens/selected_set.cljs b/frontend/src/app/main/data/workspace/tokens/selected_set.cljs index 5189f03cab..29036cd8cb 100644 --- a/frontend/src/app/main/data/workspace/tokens/selected_set.cljs +++ b/frontend/src/app/main/data/workspace/tokens/selected_set.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.selected-set "The user selected token set in the ui, stored by the `:name` of the set. diff --git a/frontend/src/app/main/data/workspace/tokens/typography.cljs b/frontend/src/app/main/data/workspace/tokens/typography.cljs index 1172ad60c5..a33249835f 100644 --- a/frontend/src/app/main/data/workspace/tokens/typography.cljs +++ b/frontend/src/app/main/data/workspace/tokens/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.typography (:require diff --git a/frontend/src/app/main/data/workspace/tokens/warnings.cljs b/frontend/src/app/main/data/workspace/tokens/warnings.cljs index 594d20380d..9782d99320 100644 --- a/frontend/src/app/main/data/workspace/tokens/warnings.cljs +++ b/frontend/src/app/main/data/workspace/tokens/warnings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.tokens.warnings (:require diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index f53cf8b2ee..f74dacdda8 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.transforms "Events related with shapes transformations" diff --git a/frontend/src/app/main/data/workspace/undo.cljs b/frontend/src/app/main/data/workspace/undo.cljs index 640df965b4..156bc5b7cb 100644 --- a/frontend/src/app/main/data/workspace/undo.cljs +++ b/frontend/src/app/main/data/workspace/undo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.undo "Undo management for the workspace. diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 2d224facc2..37a245dc3c 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.variants (:require diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 7dc4bfbb1f..ae08f31808 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.versions (:require diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 948ce72d4a..f7570534e3 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.viewport (:require diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs index 1c1caa801b..e8bf9b6885 100644 --- a/frontend/src/app/main/data/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.viewport-wasm (:require diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index bb98793dc3..edbb7803e0 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.wasm-text "Helpers/events to resize wasm text shapes without depending on workspace.texts. diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 1a5afc7d0b..8345951f82 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.data.workspace.zoom (:require diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index f8b5ad9dc4..d2cdf3a211 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.errors "Generic error handling" diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index 7e7890c435..ca87fb7e1a 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.features "A thin, frontend centric abstraction layer and collection of diff --git a/frontend/src/app/main/features/pointer_map.cljs b/frontend/src/app/main/features/pointer_map.cljs index 56fbf34799..176cbbb8c3 100644 --- a/frontend/src/app/main/features/pointer_map.cljs +++ b/frontend/src/app/main/features/pointer_map.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.features.pointer-map "A frontend specific helpers for work with pointer-map feature" diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index e72f9fd0cc..2781e1553f 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.fonts "Fonts management and loading logic." diff --git a/frontend/src/app/main/rasterizer.cljs b/frontend/src/app/main/rasterizer.cljs index 066b845344..9e32471c38 100644 --- a/frontend/src/app/main/rasterizer.cljs +++ b/frontend/src/app/main/rasterizer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.rasterizer "A main entry point for the rasterizer API interface. diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index a73ae9b872..c75ac18697 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.refs "A collection of derived refs." diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 31108fa9f0..5c185f83c4 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.render "Rendering utilities and components for penpot SVG. diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 1d4cd89506..9a6bca0d99 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.render-viewer-wasm "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index dcfac7bc22..722c9a882a 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.repo (:require diff --git a/frontend/src/app/main/router.cljs b/frontend/src/app/main/router.cljs index ff7e6abbbd..f95f5f521e 100644 --- a/frontend/src/app/main/router.cljs +++ b/frontend/src/app/main/router.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.router (:refer-clojure :exclude [resolve]) diff --git a/frontend/src/app/main/snap.cljs b/frontend/src/app/main/snap.cljs index bf68dcae5f..c67cddf054 100644 --- a/frontend/src/app/main/snap.cljs +++ b/frontend/src/app/main/snap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.snap (:require diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs index 8bdff34a65..8a0d330ca5 100644 --- a/frontend/src/app/main/store.cljs +++ b/frontend/src/app/main/store.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.store (:require diff --git a/frontend/src/app/main/streams.cljs b/frontend/src/app/main/streams.cljs index a2fce0a4e6..be71c210ff 100644 --- a/frontend/src/app/main/streams.cljs +++ b/frontend/src/app/main/streams.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.streams "User interaction events and streams." diff --git a/frontend/src/app/main/style.clj b/frontend/src/app/main/style.clj index 19282d71b1..d98f251e24 100644 --- a/frontend/src/app/main/style.clj +++ b/frontend/src/app/main/style.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.style "A fonts loading macros." diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 58175de604..cb7fe8c87a 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui (:require diff --git a/frontend/src/app/main/ui/alert.cljs b/frontend/src/app/main/ui/alert.cljs index 46e825a078..8804a8f6ff 100644 --- a/frontend/src/app/main/ui/alert.cljs +++ b/frontend/src/app/main/ui/alert.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.alert (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/alert.scss b/frontend/src/app/main/ui/alert.scss index cf4b9e6c37..53165b8d7c 100644 --- a/frontend/src/app/main/ui/alert.scss +++ b/frontend/src/app/main/ui/alert.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index 790d8b0a3e..c0eebbf0ad 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index 7d593ce4fa..50d1c90d25 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/auth/common.scss b/frontend/src/app/main/ui/auth/common.scss index 951ea442a6..43642e145e 100644 --- a/frontend/src/app/main/ui/auth/common.scss +++ b/frontend/src/app/main/ui/auth/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index c23cbf1c53..28aece5a74 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.login (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/auth/login.scss b/frontend/src/app/main/ui/auth/login.scss index 5aa37946bb..4a4dd37046 100644 --- a/frontend/src/app/main/ui/auth/login.scss +++ b/frontend/src/app/main/ui/auth/login.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./common"; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index d91f200db9..5a88a0b010 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.recovery (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/auth/recovery.scss b/frontend/src/app/main/ui/auth/recovery.scss index 4d0d4750bd..f32f519fc8 100644 --- a/frontend/src/app/main/ui/auth/recovery.scss +++ b/frontend/src/app/main/ui/auth/recovery.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./common"; diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 78caa421ff..324dc0b6eb 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.recovery-request (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/auth/recovery_request.scss b/frontend/src/app/main/ui/auth/recovery_request.scss index 11d45df27b..1b0ea1612f 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.scss +++ b/frontend/src/app/main/ui/auth/recovery_request.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index be5f3f280b..c1fcba7368 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.register (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/auth/register.scss b/frontend/src/app/main/ui/auth/register.scss index 1b238cb353..c88986ce87 100644 --- a/frontend/src/app/main/ui/auth/register.scss +++ b/frontend/src/app/main/ui/auth/register.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 52a17761c6..ff08a3679f 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.auth.verify-token (:require diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 550b4b8e37..2d6b061ea0 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index a54ad7d48e..547d6e95ef 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/button_link.cljs b/frontend/src/app/main/ui/components/button_link.cljs index 67929b9ce6..b6931dacd7 100644 --- a/frontend/src/app/main/ui/components/button_link.cljs +++ b/frontend/src/app/main/ui/components/button_link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.button-link (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/button_link.scss b/frontend/src/app/main/ui/components/button_link.scss index 895ed0bc97..d080ee2c69 100644 --- a/frontend/src/app/main/ui/components/button_link.scss +++ b/frontend/src/app/main/ui/components/button_link.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/components/code_block.cljs b/frontend/src/app/main/ui/components/code_block.cljs index 0639e28ef0..a6e170ee51 100644 --- a/frontend/src/app/main/ui/components/code_block.cljs +++ b/frontend/src/app/main/ui/components/code_block.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.code-block (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/code_block.scss b/frontend/src/app/main/ui/components/code_block.scss index 101a889970..b71e8d75d9 100644 --- a/frontend/src/app/main/ui/components/code_block.scss +++ b/frontend/src/app/main/ui/components/code_block.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/components/color_bullet.cljs b/frontend/src/app/main/ui/components/color_bullet.cljs index 0230a3de21..111d96cb90 100644 --- a/frontend/src/app/main/ui/components/color_bullet.cljs +++ b/frontend/src/app/main/ui/components/color_bullet.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.color-bullet (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/color_bullet.scss b/frontend/src/app/main/ui/components/color_bullet.scss index 8ddd352a1a..da1c5b4059 100644 --- a/frontend/src/app/main/ui/components/color_bullet.scss +++ b/frontend/src/app/main/ui/components/color_bullet.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/color_input.cljs b/frontend/src/app/main/ui/components/color_input.cljs index e8a1075392..d9b80fa8e2 100644 --- a/frontend/src/app/main/ui/components/color_input.cljs +++ b/frontend/src/app/main/ui/components/color_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.color-input (:require diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs index e2e824c19e..925faf4972 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.cljs +++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.context-menu-a11y (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.scss b/frontend/src/app/main/ui/components/context_menu_a11y.scss index 4d117b3ea0..c1e211b549 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.scss +++ b/frontend/src/app/main/ui/components/context_menu_a11y.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/components/copy_button.cljs b/frontend/src/app/main/ui/components/copy_button.cljs index d7e69f9066..b3d5c0f1f6 100644 --- a/frontend/src/app/main/ui/components/copy_button.cljs +++ b/frontend/src/app/main/ui/components/copy_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.copy-button (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 32a3d131f7..3173698c92 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index 4a0a1b0590..316aadc842 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.dropdown (:require diff --git a/frontend/src/app/main/ui/components/dropdown_menu.cljs b/frontend/src/app/main/ui/components/dropdown_menu.cljs index 681682fdae..a5936bae07 100644 --- a/frontend/src/app/main/ui/components/dropdown_menu.cljs +++ b/frontend/src/app/main/ui/components/dropdown_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.dropdown-menu (:require diff --git a/frontend/src/app/main/ui/components/editable_label.cljs b/frontend/src/app/main/ui/components/editable_label.cljs index 020a9276b0..4abae09d28 100644 --- a/frontend/src/app/main/ui/components/editable_label.cljs +++ b/frontend/src/app/main/ui/components/editable_label.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.editable-label (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/editable_label.scss b/frontend/src/app/main/ui/components/editable_label.scss index 7033160fdd..8c998ee8ca 100644 --- a/frontend/src/app/main/ui/components/editable_label.scss +++ b/frontend/src/app/main/ui/components/editable_label.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/editable_select.cljs b/frontend/src/app/main/ui/components/editable_select.cljs index a3c43866fe..470f95025c 100644 --- a/frontend/src/app/main/ui/components/editable_select.cljs +++ b/frontend/src/app/main/ui/components/editable_select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.editable-select (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 76fda98365..4d6a17e001 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // FIXME: we need this import for %asset-element @use "refactor/basic-rules.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/file_uploader.cljs b/frontend/src/app/main/ui/components/file_uploader.cljs index e4723cf334..e7d8769bca 100644 --- a/frontend/src/app/main/ui/components/file_uploader.cljs +++ b/frontend/src/app/main/ui/components/file_uploader.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.file-uploader (:require diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 73632ea4eb..e11f4a3cd0 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.forms (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/forms.scss b/frontend/src/app/main/ui/components/forms.scss index a9aed792e3..445fce340b 100644 --- a/frontend/src/app/main/ui/components/forms.scss +++ b/frontend/src/app/main/ui/components/forms.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/components/link.cljs b/frontend/src/app/main/ui/components/link.cljs index cb41b90031..feeb5a5c98 100644 --- a/frontend/src/app/main/ui/components/link.cljs +++ b/frontend/src/app/main/ui/components/link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.link (:require diff --git a/frontend/src/app/main/ui/components/link_button.cljs b/frontend/src/app/main/ui/components/link_button.cljs index b9add8c91f..9e4ba9f768 100644 --- a/frontend/src/app/main/ui/components/link_button.cljs +++ b/frontend/src/app/main/ui/components/link_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.link-button (:require diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 9225674adf..b933c8f1ae 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.numeric-input (:require diff --git a/frontend/src/app/main/ui/components/organization_avatar.cljs b/frontend/src/app/main/ui/components/organization_avatar.cljs index 8b883ca56d..d6c635f005 100644 --- a/frontend/src/app/main/ui/components/organization_avatar.cljs +++ b/frontend/src/app/main/ui/components/organization_avatar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.organization-avatar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/organization_avatar.scss b/frontend/src/app/main/ui/components/organization_avatar.scss index 4bc1d991d1..5009c20f86 100644 --- a/frontend/src/app/main/ui/components/organization_avatar.scss +++ b/frontend/src/app/main/ui/components/organization_avatar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index b767297a41..a8c1e4fe1d 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.portal (:require diff --git a/frontend/src/app/main/ui/components/progress.cljs b/frontend/src/app/main/ui/components/progress.cljs index 00c5e3028d..52fb0b2393 100644 --- a/frontend/src/app/main/ui/components/progress.cljs +++ b/frontend/src/app/main/ui/components/progress.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.progress "Assets exportation common components." diff --git a/frontend/src/app/main/ui/components/progress.scss b/frontend/src/app/main/ui/components/progress.scss index c49c11b6a8..333ec8be1c 100644 --- a/frontend/src/app/main/ui/components/progress.scss +++ b/frontend/src/app/main/ui/components/progress.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/radio_buttons.cljs b/frontend/src/app/main/ui/components/radio_buttons.cljs index 923dd0267d..73c778db83 100644 --- a/frontend/src/app/main/ui/components/radio_buttons.cljs +++ b/frontend/src/app/main/ui/components/radio_buttons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.radio-buttons (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/radio_buttons.scss b/frontend/src/app/main/ui/components/radio_buttons.scss index 40abfe173b..431b5db75a 100644 --- a/frontend/src/app/main/ui/components/radio_buttons.scss +++ b/frontend/src/app/main/ui/components/radio_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/reorder_handler.cljs b/frontend/src/app/main/ui/components/reorder_handler.cljs index 92273ede8d..2d1ddd36a2 100644 --- a/frontend/src/app/main/ui/components/reorder_handler.cljs +++ b/frontend/src/app/main/ui/components/reorder_handler.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.reorder-handler (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/reorder_handler.scss b/frontend/src/app/main/ui/components/reorder_handler.scss index a4249bd0dc..bd12353604 100644 --- a/frontend/src/app/main/ui/components/reorder_handler.scss +++ b/frontend/src/app/main/ui/components/reorder_handler.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .reorder { cursor: grab; diff --git a/frontend/src/app/main/ui/components/search_bar.cljs b/frontend/src/app/main/ui/components/search_bar.cljs index 8fbf4faf96..6727587004 100644 --- a/frontend/src/app/main/ui/components/search_bar.cljs +++ b/frontend/src/app/main/ui/components/search_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.search-bar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/search_bar.scss b/frontend/src/app/main/ui/components/search_bar.scss index 59f37812b2..49019e46ae 100644 --- a/frontend/src/app/main/ui/components/search_bar.scss +++ b/frontend/src/app/main/ui/components/search_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/components/select.cljs b/frontend/src/app/main/ui/components/select.cljs index 858dfb3ecf..e1373717f0 100644 --- a/frontend/src/app/main/ui/components/select.cljs +++ b/frontend/src/app/main/ui/components/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.select (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index 438e0873fa..3774b11719 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/components/title_bar.cljs b/frontend/src/app/main/ui/components/title_bar.cljs index d294504dd4..ee5cbea2ea 100644 --- a/frontend/src/app/main/ui/components/title_bar.cljs +++ b/frontend/src/app/main/ui/components/title_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.components.title-bar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/components/title_bar.scss b/frontend/src/app/main/ui/components/title_bar.scss index 3f8045feeb..198ca4b657 100644 --- a/frontend/src/app/main/ui/components/title_bar.scss +++ b/frontend/src/app/main/ui/components/title_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index 66621e0d07..04b7b797e4 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.confirm (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index afcffa7a48..2f61a5bb16 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 0ba58aedb6..3e1312c417 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.context (:require diff --git a/frontend/src/app/main/ui/cursors.clj b/frontend/src/app/main/ui/cursors.clj index b55b560766..c99514f515 100644 --- a/frontend/src/app/main/ui/cursors.clj +++ b/frontend/src/app/main/ui/cursors.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.cursors (:require diff --git a/frontend/src/app/main/ui/cursors.cljs b/frontend/src/app/main/ui/cursors.cljs index e618b6014a..e8c3589914 100644 --- a/frontend/src/app/main/ui/cursors.cljs +++ b/frontend/src/app/main/ui/cursors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.cursors (:require-macros [app.main.ui.cursors :refer [cursor-ref cursor-fn collect-cursors]])) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 17c6cfe69a..c83a33e97d 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard.scss b/frontend/src/app/main/ui/dashboard.scss index c2ca60b679..7db58cd983 100644 --- a/frontend/src/app/main/ui/dashboard.scss +++ b/frontend/src/app/main/ui/dashboard.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index 05d9990b69..b6eb800030 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.change-owner (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index 01c8d875bd..df51c6e86a 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/dashboard/comments.cljs b/frontend/src/app/main/ui/dashboard/comments.cljs index 1090ba768d..7f0da6b00e 100644 --- a/frontend/src/app/main/ui/dashboard/comments.cljs +++ b/frontend/src/app/main/ui/dashboard/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index e814e94fd8..4c2b2b86a5 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/deleted.cljs b/frontend/src/app/main/ui/dashboard/deleted.cljs index a66714209d..2f41c028e6 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.cljs +++ b/frontend/src/app/main/ui/dashboard/deleted.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.deleted (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index bfa78241db..d6e9b376ea 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index c3d466e3d3..5d0c7dcdaf 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.file-menu (:require diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index cf2b4fce35..436691f5a1 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.files (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 48676efd47..8f69e352e9 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 6c248beb04..6793bbbbda 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.fonts (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index 23829bb299..c8cbcdf808 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 5588805f7e..20f9117316 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.grid (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index a64e898019..bd90a72894 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index a279c62420..85481d2460 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.import (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7ae3efc2c6..6550c84c2a 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.cljs b/frontend/src/app/main/ui/dashboard/inline_edition.cljs index 496574c484..18f5930c60 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.cljs +++ b/frontend/src/app/main/ui/dashboard/inline_edition.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.inline-edition (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index e12b9110e2..0414167696 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/layout_toggle.cljs b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs index 75bd938cbe..f62f29870c 100644 --- a/frontend/src/app/main/ui/dashboard/layout_toggle.cljs +++ b/frontend/src/app/main/ui/dashboard/layout_toggle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.layout-toggle "Reactive, persisted preference for how dashboard files are laid out diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs index f907d04b53..ce7aadbc5e 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.cljs +++ b/frontend/src/app/main/ui/dashboard/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.libraries (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/libraries.scss b/frontend/src/app/main/ui/dashboard/libraries.scss index 18d886db99..adf09d7af1 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.scss +++ b/frontend/src/app/main/ui/dashboard/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/pin_button.cljs b/frontend/src/app/main/ui/dashboard/pin_button.cljs index 9823665849..f1250f6356 100644 --- a/frontend/src/app/main/ui/dashboard/pin_button.cljs +++ b/frontend/src/app/main/ui/dashboard/pin_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.pin-button (:require-macros diff --git a/frontend/src/app/main/ui/dashboard/pin_button.scss b/frontend/src/app/main/ui/dashboard/pin_button.scss index 50592b0e9b..097e7e634d 100644 --- a/frontend/src/app/main/ui/dashboard/pin_button.scss +++ b/frontend/src/app/main/ui/dashboard/pin_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs index 4161db7658..100f1ed4f3 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.cljs +++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.placeholder (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/placeholder.scss b/frontend/src/app/main/ui/dashboard/placeholder.scss index cf17521433..43d95e77d7 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.scss +++ b/frontend/src/app/main/ui/dashboard/placeholder.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./grid.scss" as g; diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs index 00db6a7918..ea0a1ab8e9 100644 --- a/frontend/src/app/main/ui/dashboard/project_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.project-menu (:require diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 441bf5cc5c..fb67e612dc 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.projects (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index 774d8ee3fa..ffadadf286 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bd52a5fa57..9d3b1f3e22 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.search (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/search.scss b/frontend/src/app/main/ui/dashboard/search.scss index f3b185aaad..8eea1a9f6e 100644 --- a/frontend/src/app/main/ui/dashboard/search.scss +++ b/frontend/src/app/main/ui/dashboard/search.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index ed39edaaf1..57844a6d9b 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 3dcc3b8ba8..0bf4b474e3 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 3886e15fc7..6314349710 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -1,4 +1,4 @@ -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.subscription (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index 16fa856de9..c4635b94f1 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 80d010c75b..25c718f0c6 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.team (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 3408a4697e..a5fa5b4822 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index d69c108183..81803fa4a1 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 67a2f68d69..946057fa95 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index b5a12d2dcf..cecb4db1aa 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.dashboard.templates (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 3d80caf43e..eabb3c8976 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/delete_shared.cljs b/frontend/src/app/main/ui/delete_shared.cljs index 5f62c1930c..6e88fd41ab 100644 --- a/frontend/src/app/main/ui/delete_shared.cljs +++ b/frontend/src/app/main/ui/delete_shared.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.delete-shared (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/delete_shared.scss b/frontend/src/app/main/ui/delete_shared.scss index bacba02d59..ab77bb5fd1 100644 --- a/frontend/src/app/main/ui/delete_shared.scss +++ b/frontend/src/app/main/ui/delete_shared.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/basic-rules.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index ec122ac47f..319a4c8178 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds (:require diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss index 20e3952f98..438cb30f0b 100644 --- a/frontend/src/app/main/ui/ds/_borders.scss +++ b/frontend/src/app/main/ui/ds/_borders.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss index 80ea1af6c8..ad0682c195 100644 --- a/frontend/src/app/main/ui/ds/_sizes.scss +++ b/frontend/src/app/main/ui/ds/_sizes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/_utils.scss b/frontend/src/app/main/ui/ds/_utils.scss index bf075be1c2..314087e614 100644 --- a/frontend/src/app/main/ui/ds/_utils.scss +++ b/frontend/src/app/main/ui/ds/_utils.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:math"; diff --git a/frontend/src/app/main/ui/ds/buttons/_buttons.scss b/frontend/src/app/main/ui/ds/buttons/_buttons.scss index f34198d621..27bd625edf 100644 --- a/frontend/src/app/main/ui/ds/buttons/_buttons.scss +++ b/frontend/src/app/main/ui/ds/buttons/_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/button.cljs b/frontend/src/app/main/ui/ds/buttons/button.cljs index 73b6a65180..c7ea4245ab 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.buttons.button (:require-macros diff --git a/frontend/src/app/main/ui/ds/buttons/button.scss b/frontend/src/app/main/ui/ds/buttons/button.scss index e646758b7b..347eb26aaf 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.scss +++ b/frontend/src/app/main/ui/ds/buttons/button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "./buttons" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx index d96d80ccdb..f030431e12 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/button.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/buttons/buttons.mdx b/frontend/src/app/main/ui/ds/buttons/buttons.mdx index 6ca37ebd7c..5eaf8ca5b5 100644 --- a/frontend/src/app/main/ui/ds/buttons/buttons.mdx +++ b/frontend/src/app/main/ui/ds/buttons/buttons.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as ButtonStories from "./button.stories"; diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 272d4288a3..2eeb775288 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.buttons.icon-button (:require-macros diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 233bb8bfe7..291c352081 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx index de37e42a02..9f0c411f83 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index 9440b47f85..aec1a8b4c3 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "sass:color"; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.cljs b/frontend/src/app/main/ui/ds/controls/checkbox.cljs index 97d656040d..c93cb391f4 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.cljs +++ b/frontend/src/app/main/ui/ds/controls/checkbox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.checkbox (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.mdx b/frontend/src/app/main/ui/ds/controls/checkbox.mdx index 1292b704fb..3ab83376b0 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.mdx +++ b/frontend/src/app/main/ui/ds/controls/checkbox.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Checkbox from "./checkbox.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.scss b/frontend/src/app/main/ui/ds/controls/checkbox.scss index a8046e2ebd..be90b3bb53 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.scss +++ b/frontend/src/app/main/ui/ds/controls/checkbox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx b/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx index 4c4513cd02..17d0bb3792 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/checkbox.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index d90577fa73..2174d84a78 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.combobox (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/combobox.mdx b/frontend/src/app/main/ui/ds/controls/combobox.mdx index 8b30516547..86b8e11119 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.mdx +++ b/frontend/src/app/main/ui/ds/controls/combobox.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as ComboboxStories from "./combobox.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index fd27555983..b11d7b8330 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx index 8188b49e24..63882556a9 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs index 0fa5ca9e26..291b0144dd 100644 --- a/frontend/src/app/main/ui/ds/controls/input.cljs +++ b/frontend/src/app/main/ui/ds/controls/input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.input (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/input.mdx b/frontend/src/app/main/ui/ds/controls/input.mdx index d15b98ebe1..952f38625d 100644 --- a/frontend/src/app/main/ui/ds/controls/input.mdx +++ b/frontend/src/app/main/ui/ds/controls/input.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputStories from "./input.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/input.scss b/frontend/src/app/main/ui/ds/controls/input.scss index a83f6be088..5dcad36017 100644 --- a/frontend/src/app/main/ui/ds/controls/input.scss +++ b/frontend/src/app/main/ui/ds/controls/input.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/input.stories.jsx b/frontend/src/app/main/ui/ds/controls/input.stories.jsx index 86e7b57848..f64d97b3e6 100644 --- a/frontend/src/app/main/ui/ds/controls/input.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/input.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx b/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx index ffb5684c99..bef96135cc 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/numeric-input.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; import { userEvent, within, expect } from "storybook/test"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index 44694a708f..5013edccaa 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.numeric-input (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.mdx b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx index d9ef739476..b5ee8b12f0 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.mdx +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputStories from "./numeric-input.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index d9fb372647..a529315711 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs index 84cbaec699..28967a8991 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.radio-buttons (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx index d40f74d910..6f7db4dfc0 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as RadioButtons from "./radio_buttons.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss index 2026629ed9..154d6517b3 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx index 46766d92e6..946784c938 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index 337d9f3b51..cea8878201 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.select (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/select.mdx b/frontend/src/app/main/ui/ds/controls/select.mdx index 828ce69875..0469a64294 100644 --- a/frontend/src/app/main/ui/ds/controls/select.mdx +++ b/frontend/src/app/main/ui/ds/controls/select.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as SelectStories from "./select.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/select.scss b/frontend/src/app/main/ui/ds/controls/select.scss index ea60fc4813..2a6f428fd9 100644 --- a/frontend/src/app/main/ui/ds/controls/select.scss +++ b/frontend/src/app/main/ui/ds/controls/select.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/select.stories.jsx b/frontend/src/app/main/ui/ds/controls/select.stories.jsx index ba21289ddc..81627493bc 100644 --- a/frontend/src/app/main/ui/ds/controls/select.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/select.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs index 3f9fc2fa8b..c06047043c 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.dropdown-navigation (:require [app.util.dom :as dom] diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs index ffed06adbb..8f2ad13319 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.option (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index 43eb3bc294..c604eb0542 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 48a1ef84bf..a556f00f0a 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.options-dropdown (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss index ee38eb1124..c961b9e06c 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs index 39020f88a5..c2cbfe2fbe 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.render-option (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.scss b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss index 232efb42a5..df6ab611e8 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs index c90df863c8..73a78da212 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.searchable-options-dropdown (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss index cbeae912d8..b018359955 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index ce8d98c3ab..4de3bdf3c1 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.shared.token-option (:require-macros 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 e6e6e017be..50f33638c9 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 @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/switch.cljs b/frontend/src/app/main/ui/ds/controls/switch.cljs index 0593881ab0..cdd1eba259 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.cljs +++ b/frontend/src/app/main/ui/ds/controls/switch.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.switch (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/switch.mdx b/frontend/src/app/main/ui/ds/controls/switch.mdx index 6a935720aa..c71a06ebea 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.mdx +++ b/frontend/src/app/main/ui/ds/controls/switch.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Switch from "./switch.stories"; diff --git a/frontend/src/app/main/ui/ds/controls/switch.scss b/frontend/src/app/main/ui/ds/controls/switch.scss index b1f1f39f93..3e0b23312e 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.scss +++ b/frontend/src/app/main/ui/ds/controls/switch.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/switch.stories.jsx b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx index 449aa1c5c6..eac96529b6 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/switch.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs index b1f0d54e5f..bafb1f872f 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.hint-message (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss index 08979c8be3..11b5e4b580 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs index e3d7162b50..a8e827be21 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.input-field (:require-macros diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index a295655f26..4bf26e5b4a 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/label.cljs b/frontend/src/app/main/ui/ds/controls/utilities/label.cljs index 1295af1433..77b3ddf3d3 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/label.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/label.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.label (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/label.scss b/frontend/src/app/main/ui/ds/controls/utilities/label.scss index e37b70e456..c579c122b6 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/label.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/label.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/colors.scss" as *; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs index 0b6958069b..befe593d16 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.controls.utilities.token-field (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss index 765e6d5eae..da5022275c 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/elevations.scss b/frontend/src/app/main/ui/ds/elevations.scss index 65bb63ac23..2172136c3d 100644 --- a/frontend/src/app/main/ui/ds/elevations.scss +++ b/frontend/src/app/main/ui/ds/elevations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $el-shadow-dark: 0 0 10px 0 var(--color-shadow-dark); diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.clj b/frontend/src/app/main/ui/ds/foundations/assets/icon.clj index c4ffc47c98..6bbbbee49d 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.clj +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.icon (:require diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index d0634e21b1..c4d697fd47 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.icon (:refer-clojure :exclude [mask drop filter remove]) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx b/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx index 203669991f..b81bcd16a1 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as IconStories from "./icon.stories" diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.scss b/frontend/src/app/main/ui/ds/foundations/assets/icon.scss index 3525f862f9..c8e2c590e7 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.scss +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .icon { fill: var(--icon-fill-color, none); diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj index 29abd280fa..47e88056c8 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.raw-svg (:require diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs index e856a2a1c5..ccd893f19f 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.assets.raw-svg (:refer-clojure :exclude [mask]) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx index bb599648da..9b859b2173 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as RawSvgStories from "./raw_svg.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography.cljs b/frontend/src/app/main/ui/ds/foundations/typography.cljs index a9822ad674..041a716034 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography) diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs index 579f4c9b63..46c06df814 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography.heading (:require-macros diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx b/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx index deda28980d..8f3c96eff9 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as HeadingStories from "./heading.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography/heading.scss b/frontend/src/app/main/ui/ds/foundations/typography/heading.scss index e4ac6c4d7a..49d51b4c6c 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/heading.scss +++ b/frontend/src/app/main/ui/ds/foundations/typography/heading.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; .display-typography { diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.cljs b/frontend/src/app/main/ui/ds/foundations/typography/text.cljs index 74a1f8bb45..e5c476f2fb 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.cljs +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.foundations.typography.text (:require-macros diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.mdx b/frontend/src/app/main/ui/ds/foundations/typography/text.mdx index 1ebd42906e..4ad209ab31 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as TextStories from "./text.stories"; diff --git a/frontend/src/app/main/ui/ds/foundations/typography/text.scss b/frontend/src/app/main/ui/ds/foundations/typography/text.scss index e4ac6c4d7a..49d51b4c6c 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/text.scss +++ b/frontend/src/app/main/ui/ds/foundations/typography/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; .display-typography { diff --git a/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx b/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx index 6b8328fc0b..c526a29d53 100644 --- a/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx +++ b/frontend/src/app/main/ui/ds/foundations/typography/typography.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss index 3b8be0b18b..626ebe5110 100644 --- a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss +++ b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .token-icon { fill: currentcolor; diff --git a/frontend/src/app/main/ui/ds/helpers.cljs b/frontend/src/app/main/ui/ds/helpers.cljs index 85d2c7ab41..f26294437b 100644 --- a/frontend/src/app/main/ui/ds/helpers.cljs +++ b/frontend/src/app/main/ui/ds/helpers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.helpers "A collection of helpers for exporting them to be used on storybook code." diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.cljs b/frontend/src/app/main/ui/ds/layers/layer_button.cljs index 65e33bea70..cc63d805bd 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.cljs +++ b/frontend/src/app/main/ui/ds/layers/layer_button.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layers.layer-button (:require-macros diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.scss b/frontend/src/app/main/ui/ds/layers/layer_button.scss index 2564e59070..08006e6968 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.scss +++ b/frontend/src/app/main/ui/ds/layers/layer_button.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/layout/modal.cljs b/frontend/src/app/main/ui/ds/layout/modal.cljs index b808e7040c..173db626c4 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.cljs +++ b/frontend/src/app/main/ui/ds/layout/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layout.modal (:require-macros diff --git a/frontend/src/app/main/ui/ds/layout/modal.mdx b/frontend/src/app/main/ui/ds/layout/modal.mdx index 5898e730d5..c2e637f241 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.mdx +++ b/frontend/src/app/main/ui/ds/layout/modal.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as Modal from "./modal.stories"; diff --git a/frontend/src/app/main/ui/ds/layout/modal.scss b/frontend/src/app/main/ui/ds/layout/modal.scss index c08df6810d..cb0e16fa1f 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.scss +++ b/frontend/src/app/main/ui/ds/layout/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/mixins" as *; diff --git a/frontend/src/app/main/ui/ds/layout/modal.stories.jsx b/frontend/src/app/main/ui/ds/layout/modal.stories.jsx index fbb996a3f5..399747cbeb 100644 --- a/frontend/src/app/main/ui/ds/layout/modal.stories.jsx +++ b/frontend/src/app/main/ui/ds/layout/modal.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs index d83164ab98..69c4af899c 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.layout.tab-switcher (:require-macros diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx b/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx index df31a26968..e0881707f6 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as TabSwitcher from "./tab_switcher.stories"; diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index c03a03e047..f9000c6146 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx b/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx index a8242247a5..6506ff1669 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/mixins.scss b/frontend/src/app/main/ui/ds/mixins.scss index 40adfa2c9e..c285bd6295 100644 --- a/frontend/src/app/main/ui/ds/mixins.scss +++ b/frontend/src/app/main/ui/ds/mixins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/actionable.cljs b/frontend/src/app/main/ui/ds/notifications/actionable.cljs index 45bd6563f6..31f45e865a 100644 --- a/frontend/src/app/main/ui/ds/notifications/actionable.cljs +++ b/frontend/src/app/main/ui/ds/notifications/actionable.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.actionable (:require-macros diff --git a/frontend/src/app/main/ui/ds/notifications/actionable.scss b/frontend/src/app/main/ui/ds/notifications/actionable.scss index ace5123964..63e95b8471 100644 --- a/frontend/src/app/main/ui/ds/notifications/actionable.scss +++ b/frontend/src/app/main/ui/ds/notifications/actionable.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx b/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx index 54b5a3f3a5..3efa4b4c60 100644 --- a/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx +++ b/frontend/src/app/main/ui/ds/notifications/context-notification.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/notifications/context_notification.cljs b/frontend/src/app/main/ui/ds/notifications/context_notification.cljs index 3505fe7e1a..eb6425fc23 100644 --- a/frontend/src/app/main/ui/ds/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/ds/notifications/context_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.context-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/ds/notifications/notifications.mdx b/frontend/src/app/main/ui/ds/notifications/notifications.mdx index 7ffe6a184e..29ecb01afa 100644 --- a/frontend/src/app/main/ui/ds/notifications/notifications.mdx +++ b/frontend/src/app/main/ui/ds/notifications/notifications.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as ToastStories from "./toast.stories"; diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs index c14696c988..8cb380297b 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.shared.notification-pill (:require-macros diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss index a0229548a6..c2bce2ac57 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/toast.cljs b/frontend/src/app/main/ui/ds/notifications/toast.cljs index e1aac97c61..fe64f50514 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.cljs +++ b/frontend/src/app/main/ui/ds/notifications/toast.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.notifications.toast (:require-macros diff --git a/frontend/src/app/main/ui/ds/notifications/toast.scss b/frontend/src/app/main/ui/ds/notifications/toast.scss index e1a4f18df9..9e5f048411 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.scss +++ b/frontend/src/app/main/ui/ds/notifications/toast.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx b/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx index cb6052fcea..7dabedd57e 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx +++ b/frontend/src/app/main/ui/ds/notifications/toast.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/avatar.cljs b/frontend/src/app/main/ui/ds/product/avatar.cljs index 8259d348dc..9c20fc4c91 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.cljs +++ b/frontend/src/app/main/ui/ds/product/avatar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.avatar (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/avatar.scss b/frontend/src/app/main/ui/ds/product/avatar.scss index b5c41c1a26..45c103508e 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.scss +++ b/frontend/src/app/main/ui/ds/product/avatar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/cta.cljs b/frontend/src/app/main/ui/ds/product/cta.cljs index 1efc556bfd..7f0a426cf0 100644 --- a/frontend/src/app/main/ui/ds/product/cta.cljs +++ b/frontend/src/app/main/ui/ds/product/cta.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.cta (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/cta.scss b/frontend/src/app/main/ui/ds/product/cta.scss index 46b46cf52f..c1c4952067 100644 --- a/frontend/src/app/main/ui/ds/product/cta.scss +++ b/frontend/src/app/main/ui/ds/product/cta.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/colors.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs index a31e1d1c5d..14fb12612b 100644 --- a/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs +++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.empty-placeholder (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss index 98d7482268..7431a8aefe 100644 --- a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss +++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.cljs b/frontend/src/app/main/ui/ds/product/empty_state.cljs index 3dd6ef739f..bad9db3781 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.cljs +++ b/frontend/src/app/main/ui/ds/product/empty_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.empty-state (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/empty_state.mdx b/frontend/src/app/main/ui/ds/product/empty_state.mdx index 445af4c157..aaaa54af4b 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.mdx +++ b/frontend/src/app/main/ui/ds/product/empty_state.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as EmptyState from "./empty_state.stories"; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.scss b/frontend/src/app/main/ui/ds/product/empty_state.scss index 7674b2b7bc..a2eb4f6a5e 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.scss +++ b/frontend/src/app/main/ui/ds/product/empty_state.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx b/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx index 4d29eeeda7..37043e14b3 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/empty_state.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.cljs b/frontend/src/app/main/ui/ds/product/input_with_meta.cljs index 0b6285a6df..396d724fcb 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.cljs +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.input-with-meta (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.mdx b/frontend/src/app/main/ui/ds/product/input_with_meta.mdx index cf1c266f3b..68a22a5f4c 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.mdx +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as InputWithMetaStories from "./input_with_meta.stories"; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.scss b/frontend/src/app/main/ui/ds/product/input_with_meta.scss index cb81177ac0..97de9e2c5f 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.scss +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx b/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx index 8d7dd175d7..899fc2acc9 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/product/loader.cljs b/frontend/src/app/main/ui/ds/product/loader.cljs index 5d5f597545..386d4f2914 100644 --- a/frontend/src/app/main/ui/ds/product/loader.cljs +++ b/frontend/src/app/main/ui/ds/product/loader.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.loader (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/loader.scss b/frontend/src/app/main/ui/ds/product/loader.scss index 938faadd8f..586bf13aaa 100644 --- a/frontend/src/app/main/ui/ds/product/loader.scss +++ b/frontend/src/app/main/ui/ds/product/loader.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/milestone.cljs b/frontend/src/app/main/ui/ds/product/milestone.cljs index 92a7f32082..cf18456f51 100644 --- a/frontend/src/app/main/ui/ds/product/milestone.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.milestone (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/milestone.scss b/frontend/src/app/main/ui/ds/product/milestone.scss index 1ed5492667..18b88b4dba 100644 --- a/frontend/src/app/main/ui/ds/product/milestone.scss +++ b/frontend/src/app/main/ui/ds/product/milestone.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.cljs b/frontend/src/app/main/ui/ds/product/milestone_group.cljs index 243d7f8ce1..843d85edec 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.milestone-group (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.scss b/frontend/src/app/main/ui/ds/product/milestone_group.scss index 0643a1a15d..2326c3a378 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.scss +++ b/frontend/src/app/main/ui/ds/product/milestone_group.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/product/panel_title.cljs b/frontend/src/app/main/ui/ds/product/panel_title.cljs index e69813b8e8..79724cf29e 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.cljs +++ b/frontend/src/app/main/ui/ds/product/panel_title.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.product.panel-title (:require-macros diff --git a/frontend/src/app/main/ui/ds/product/panel_title.mdx b/frontend/src/app/main/ui/ds/product/panel_title.mdx index a6d7a118b8..89564d3fa6 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.mdx +++ b/frontend/src/app/main/ui/ds/product/panel_title.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as PanelTitle from "./panel_title.stories"; diff --git a/frontend/src/app/main/ui/ds/product/panel_title.scss b/frontend/src/app/main/ui/ds/product/panel_title.scss index 4bdca9aba8..ae45164878 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.scss +++ b/frontend/src/app/main/ui/ds/product/panel_title.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/spacing.scss b/frontend/src/app/main/ui/ds/spacing.scss index a9bfdedc2b..ec418ea768 100644 --- a/frontend/src/app/main/ui/ds/spacing.scss +++ b/frontend/src/app/main/ui/ds/spacing.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/storybook.cljs b/frontend/src/app/main/ui/ds/storybook.cljs index 53418eb36b..96fd851d6b 100644 --- a/frontend/src/app/main/ui/ds/storybook.cljs +++ b/frontend/src/app/main/ui/ds/storybook.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.storybook (:require-macros diff --git a/frontend/src/app/main/ui/ds/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip.cljs index f1ca9df6d1..2d9b2c9db7 100644 --- a/frontend/src/app/main/ui/ds/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.tooltip (:require diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index e3d6b153fb..0c35334b4f 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.tooltip.tooltip (:require-macros diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx index 38d52bce41..f64b489d7f 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import * as Tooltip from "./tooltip.stories"; diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 0647a74c3c..7a4daaabe7 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx index 0b19d08d6f..3868ff196a 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/typography.scss b/frontend/src/app/main/ui/ds/typography.scss index 5c63090243..3f1126411e 100644 --- a/frontend/src/app/main/ui/ds/typography.scss +++ b/frontend/src/app/main/ui/ds/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/ds/utilities/date.cljs b/frontend/src/app/main/ui/ds/utilities/date.cljs index e9048e78c7..d82f0c7470 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.cljs +++ b/frontend/src/app/main/ui/ds/utilities/date.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.utilities.date (:require-macros diff --git a/frontend/src/app/main/ui/ds/utilities/date.scss b/frontend/src/app/main/ui/ds/utilities/date.scss index e73fb62164..380496676e 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.scss +++ b/frontend/src/app/main/ui/ds/utilities/date.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .date { color: var(--date-color, var(--color-foreground-secondary)); diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.cljs b/frontend/src/app/main/ui/ds/utilities/swatch.cljs index 63c652bd21..652c5429c4 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.cljs +++ b/frontend/src/app/main/ui/ds/utilities/swatch.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.ds.utilities.swatch (:require-macros diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.mdx b/frontend/src/app/main/ui/ds/utilities/swatch.mdx index f76629dc99..665613cbae 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.mdx +++ b/frontend/src/app/main/ui/ds/utilities/swatch.mdx @@ -2,7 +2,7 @@ License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. - Copyright (c) KALEIDOS INC Sucursal en España SL */ } + Copyright (c) KALEIDOS SUBSIDIARY SL */ } import { Canvas, Meta } from "@storybook/addon-docs/blocks"; import * as SwatchStories from "./swatch.stories"; diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.scss b/frontend/src/app/main/ui/ds/utilities/swatch.scss index 9778c923f7..25d8e04019 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.scss +++ b/frontend/src/app/main/ui/ds/utilities/swatch.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx index 7b24d7a693..ab6c838993 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx +++ b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL import * as React from "react"; import Components from "@target/components"; diff --git a/frontend/src/app/main/ui/ds/z-index.scss b/frontend/src/app/main/ui/ds/z-index.scss index 513ff65454..b30f908580 100644 --- a/frontend/src/app/main/ui/ds/z-index.scss +++ b/frontend/src/app/main/ui/ds/z-index.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL $z-index-auto: auto; $z-index-100: 100; diff --git a/frontend/src/app/main/ui/error_boundary.cljs b/frontend/src/app/main/ui/error_boundary.cljs index 226b87369b..6838ace9c3 100644 --- a/frontend/src/app/main/ui/error_boundary.cljs +++ b/frontend/src/app/main/ui/error_boundary.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.error-boundary "React error boundary components" diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index d50e2bf236..052910c404 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; FIXME: rename (ns app.main.ui.exports.assets diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index c9764632a2..2780d53ea7 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 526e05cfef..cd8fe2465c 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.exports.files "The files export dialog/modal" diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index 8959807951..ad4da9b955 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/flex_controls.cljs b/frontend/src/app/main/ui/flex_controls.cljs index baa8207df3..66e2ca27fb 100644 --- a/frontend/src/app/main/ui/flex_controls.cljs +++ b/frontend/src/app/main/ui/flex_controls.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls (:require diff --git a/frontend/src/app/main/ui/flex_controls/gap.cljs b/frontend/src/app/main/ui/flex_controls/gap.cljs index 6bcb237821..c297807e2d 100644 --- a/frontend/src/app/main/ui/flex_controls/gap.cljs +++ b/frontend/src/app/main/ui/flex_controls/gap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.gap (:require diff --git a/frontend/src/app/main/ui/flex_controls/margin.cljs b/frontend/src/app/main/ui/flex_controls/margin.cljs index b8d39c73fb..8638776132 100644 --- a/frontend/src/app/main/ui/flex_controls/margin.cljs +++ b/frontend/src/app/main/ui/flex_controls/margin.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.margin (:require diff --git a/frontend/src/app/main/ui/flex_controls/padding.cljs b/frontend/src/app/main/ui/flex_controls/padding.cljs index 1b125717bd..eb4a9680ef 100644 --- a/frontend/src/app/main/ui/flex_controls/padding.cljs +++ b/frontend/src/app/main/ui/flex_controls/padding.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.flex-controls.padding (:require diff --git a/frontend/src/app/main/ui/formats.cljs b/frontend/src/app/main/ui/formats.cljs index d16c134a30..0655b77de7 100644 --- a/frontend/src/app/main/ui/formats.cljs +++ b/frontend/src/app/main/ui/formats.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.formats (:require diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index f6a870f618..b664d5dfe8 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.forms (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/forms.scss b/frontend/src/app/main/ui/forms.scss index c576dfcda2..58872f37ed 100644 --- a/frontend/src/app/main/ui/forms.scss +++ b/frontend/src/app/main/ui/forms.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/frame_preview.cljs b/frontend/src/app/main/ui/frame_preview.cljs index 06f55579d4..a2705ea4da 100644 --- a/frontend/src/app/main/ui/frame_preview.cljs +++ b/frontend/src/app/main/ui/frame_preview.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.frame-preview (:require diff --git a/frontend/src/app/main/ui/hooks/floating_drag.cljs b/frontend/src/app/main/ui/hooks/floating_drag.cljs index f5f4481923..c6318c2ea2 100644 --- a/frontend/src/app/main/ui/hooks/floating_drag.cljs +++ b/frontend/src/app/main/ui/hooks/floating_drag.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.floating-drag "Pointer drag hook for floating panels, mirroring the plugin modal drag diff --git a/frontend/src/app/main/ui/hooks/mutable_observer.cljs b/frontend/src/app/main/ui/hooks/mutable_observer.cljs index 41146ef178..00927d115d 100644 --- a/frontend/src/app/main/ui/hooks/mutable_observer.cljs +++ b/frontend/src/app/main/ui/hooks/mutable_observer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.mutable-observer (:require diff --git a/frontend/src/app/main/ui/hooks/resize.cljs b/frontend/src/app/main/ui/hooks/resize.cljs index f666b2126e..c8dc452194 100644 --- a/frontend/src/app/main/ui/hooks/resize.cljs +++ b/frontend/src/app/main/ui/hooks/resize.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.hooks.resize (:require diff --git a/frontend/src/app/main/ui/icons.clj b/frontend/src/app/main/ui/icons.clj index 47b55b14ab..4115c38339 100644 --- a/frontend/src/app/main/ui/icons.clj +++ b/frontend/src/app/main/ui/icons.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.icons (:require diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index ee405fa7f8..3ec978b0d6 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.icons (:refer-clojure :exclude [import mask]) diff --git a/frontend/src/app/main/ui/inspect/annotation.cljs b/frontend/src/app/main/ui/inspect/annotation.cljs index e24d705bea..f9d8dfd3c0 100644 --- a/frontend/src/app/main/ui/inspect/annotation.cljs +++ b/frontend/src/app/main/ui/inspect/annotation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.annotation (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/annotation.scss b/frontend/src/app/main/ui/inspect/annotation.scss index 5a8cac76cd..5171c994f3 100644 --- a/frontend/src/app/main/ui/inspect/annotation.scss +++ b/frontend/src/app/main/ui/inspect/annotation.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/attributes.cljs b/frontend/src/app/main/ui/inspect/attributes.cljs index 7535709328..2818edf9c1 100644 --- a/frontend/src/app/main/ui/inspect/attributes.cljs +++ b/frontend/src/app/main/ui/inspect/attributes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes.scss b/frontend/src/app/main/ui/inspect/attributes.scss index 10d7943670..8e81c72295 100644 --- a/frontend/src/app/main/ui/inspect/attributes.scss +++ b/frontend/src/app/main/ui/inspect/attributes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.cljs b/frontend/src/app/main/ui/inspect/attributes/blur.cljs index ba50e6be32..2190bbeb1e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.blur (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.scss b/frontend/src/app/main/ui/inspect/attributes/blur.scss index fc60a1c3d5..8a5856555b 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.scss +++ b/frontend/src/app/main/ui/inspect/attributes/blur.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/common.cljs b/frontend/src/app/main/ui/inspect/attributes/common.cljs index 573c4cd1c1..28e251b2cf 100644 --- a/frontend/src/app/main/ui/inspect/attributes/common.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/common.scss b/frontend/src/app/main/ui/inspect/attributes/common.scss index b385025fad..8bbc6c7cd5 100644 --- a/frontend/src/app/main/ui/inspect/attributes/common.scss +++ b/frontend/src/app/main/ui/inspect/attributes/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.cljs b/frontend/src/app/main/ui/inspect/attributes/fill.cljs index 17d3d477dd..3d0e72e523 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.scss b/frontend/src/app/main/ui/inspect/attributes/fill.scss index 8725a1d364..ee7dc9c607 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.scss +++ b/frontend/src/app/main/ui/inspect/attributes/fill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs index 8a6c255d50..4f99f5dba8 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.geometry (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.scss b/frontend/src/app/main/ui/inspect/attributes/geometry.scss index 8b31afdb0c..64a5aabc1e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.scss +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.cljs b/frontend/src/app/main/ui/inspect/attributes/layout.cljs index 8c2d227f6f..fa0d3dfa0c 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.layout (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.scss b/frontend/src/app/main/ui/inspect/attributes/layout.scss index bcf1afbafc..453903e8c3 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; 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 11b904d1a7..24b46adcf6 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.layout-element (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss index 8c4af89efa..d7994312e7 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs index 0f092d9b6c..0480178ba4 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.scss b/frontend/src/app/main/ui/inspect/attributes/shadow.scss index 7d641d619a..e0638524b0 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.scss +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs index da2d1acb6a..a153926ffd 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.stroke (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.scss b/frontend/src/app/main/ui/inspect/attributes/stroke.scss index 790a48dc41..3d512f159f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.scss +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.cljs b/frontend/src/app/main/ui/inspect/attributes/svg.cljs index 5ccb3dacee..8344208afc 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.svg (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.scss b/frontend/src/app/main/ui/inspect/attributes/svg.scss index ff14274daf..07424f056e 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.scss +++ b/frontend/src/app/main/ui/inspect/attributes/svg.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/text.cljs b/frontend/src/app/main/ui/inspect/attributes/text.cljs index a8565fde26..031647fe39 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.text (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/text.scss b/frontend/src/app/main/ui/inspect/attributes/text.scss index 786ebcc1bf..d76ee08e11 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.scss +++ b/frontend/src/app/main/ui/inspect/attributes/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/variant.cljs b/frontend/src/app/main/ui/inspect/attributes/variant.cljs index 4ff196cf29..b6cfec0b5d 100644 --- a/frontend/src/app/main/ui/inspect/attributes/variant.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/variant.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.variant (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/variant.scss b/frontend/src/app/main/ui/inspect/attributes/variant.scss index 7cb734eaad..13472fb2e5 100644 --- a/frontend/src/app/main/ui/inspect/attributes/variant.scss +++ b/frontend/src/app/main/ui/inspect/attributes/variant.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/attributes/visibility.cljs b/frontend/src/app/main/ui/inspect/attributes/visibility.cljs index aa01245091..def01b78b4 100644 --- a/frontend/src/app/main/ui/inspect/attributes/visibility.cljs +++ b/frontend/src/app/main/ui/inspect/attributes/visibility.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.attributes.visibility (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/attributes/visibility.scss b/frontend/src/app/main/ui/inspect/attributes/visibility.scss index 1942875929..de375cbc7f 100644 --- a/frontend/src/app/main/ui/inspect/attributes/visibility.scss +++ b/frontend/src/app/main/ui/inspect/attributes/visibility.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index 57f6ea10b8..78b748a064 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.code (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/code.scss b/frontend/src/app/main/ui/inspect/code.scss index f2497e835d..58acf7b110 100644 --- a/frontend/src/app/main/ui/inspect/code.scss +++ b/frontend/src/app/main/ui/inspect/code.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/common/colors.cljs b/frontend/src/app/main/ui/inspect/common/colors.cljs index 8875c57325..083bd36aaf 100644 --- a/frontend/src/app/main/ui/inspect/common/colors.cljs +++ b/frontend/src/app/main/ui/inspect/common/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.common.colors (:require diff --git a/frontend/src/app/main/ui/inspect/common/typography.cljs b/frontend/src/app/main/ui/inspect/common/typography.cljs index 85f25aa3d6..a9897162fc 100644 --- a/frontend/src/app/main/ui/inspect/common/typography.cljs +++ b/frontend/src/app/main/ui/inspect/common/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.common.typography (:require diff --git a/frontend/src/app/main/ui/inspect/exports.cljs b/frontend/src/app/main/ui/inspect/exports.cljs index d431a5aeeb..f9a7636166 100644 --- a/frontend/src/app/main/ui/inspect/exports.cljs +++ b/frontend/src/app/main/ui/inspect/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.exports (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/exports.scss b/frontend/src/app/main/ui/inspect/exports.scss index cec1335be5..1feb282b4c 100644 --- a/frontend/src/app/main/ui/inspect/exports.scss +++ b/frontend/src/app/main/ui/inspect/exports.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.cljs b/frontend/src/app/main/ui/inspect/left_sidebar.cljs index 43c8a766ff..971a3d8062 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/left_sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.left-sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/left_sidebar.scss b/frontend/src/app/main/ui/inspect/left_sidebar.scss index 3cd97d6345..280cca938e 100644 --- a/frontend/src/app/main/ui/inspect/left_sidebar.scss +++ b/frontend/src/app/main/ui/inspect/left_sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/inspect/render.cljs b/frontend/src/app/main/ui/inspect/render.cljs index 25e223f9e5..751c1f4237 100644 --- a/frontend/src/app/main/ui/inspect/render.cljs +++ b/frontend/src/app/main/ui/inspect/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.render "The main container for a frame in inspect mode" diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/inspect/right_sidebar.cljs index 3f0fdbe0b7..1216c02d8a 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/right_sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.right-sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.scss b/frontend/src/app/main/ui/inspect/right_sidebar.scss index 19fe574ec7..6d1ea82b4b 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.scss +++ b/frontend/src/app/main/ui/inspect/right_sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/selection_feedback.cljs b/frontend/src/app/main/ui/inspect/selection_feedback.cljs index 3679be0283..8be253b3ca 100644 --- a/frontend/src/app/main/ui/inspect/selection_feedback.cljs +++ b/frontend/src/app/main/ui/inspect/selection_feedback.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.selection-feedback (:require diff --git a/frontend/src/app/main/ui/inspect/styles.cljs b/frontend/src/app/main/ui/inspect/styles.cljs index df61a66778..1d1cb2cc1b 100644 --- a/frontend/src/app/main/ui/inspect/styles.cljs +++ b/frontend/src/app/main/ui/inspect/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles.scss b/frontend/src/app/main/ui/inspect/styles.scss index b820c6843f..865542c3f2 100644 --- a/frontend/src/app/main/ui/inspect/styles.scss +++ b/frontend/src/app/main/ui/inspect/styles.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; 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 6f089cf256..98a4e6e77f 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.blur (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs b/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs index 8ee04a012f..793c347964 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs index d008bf6f84..53d95fb00e 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/geometry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.geometry (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs b/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs index 0dd36cde3a..b75b31696f 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/layout.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.layout (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs b/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs index a3f5717dfc..1a941c75b9 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/layout_element.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.layout-element (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs b/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs index 59c006ebdd..b9693bbc4d 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs index 2398eaede9..53ac6d5b8c 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.stroke (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs b/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs index 1cc72bbbc7..8e9f89e3c1 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.svg (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs index 23073d0c9c..f68c764913 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.text (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.scss b/frontend/src/app/main/ui/inspect/styles/panels/text.scss index 63dbe27892..06d16a0e47 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.scss +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs index 2dbbb3f97e..2a86d196ca 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.tokens-panel (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss index 93e7790729..facf963877 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss +++ b/frontend/src/app/main/ui/inspect/styles/panels/tokens_panel.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .token-theme, .token-sets { diff --git a/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs b/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs index 5d5a852b0f..aef2847adf 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/variants_panel.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.variants-panel (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs b/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs index cdeee9a7d1..edebf000e3 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs +++ b/frontend/src/app/main/ui/inspect/styles/panels/visibility.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.panels.visibility (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs index e6cb18f37a..1b5e92158c 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.property-detail-copiable (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss index f7fccb5580..79129aca67 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs index 8420ad5873..4acd771ca1 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.rows.color-properties-row (:require-macros [app.main.style :as stl]) (:require diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss index 9469ca51d8..74a7ec79d9 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs index 3c08dd55f3..ca8b02e629 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.rows.properties-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss index 30df727a10..3b55fdd065 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.cljs b/frontend/src/app/main/ui/inspect/styles/style_box.cljs index c106197fa3..0ac17b5411 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.cljs +++ b/frontend/src/app/main/ui/inspect/styles/style_box.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.inspect.styles.style-box (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.scss b/frontend/src/app/main/ui/inspect/styles/style_box.scss index 3ae3ba14bd..102cb312d2 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.scss +++ b/frontend/src/app/main/ui/inspect/styles/style_box.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index b775e7d1bb..f2fdb2f639 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.measurements (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/measurements.scss b/frontend/src/app/main/ui/measurements.scss index 4babfe43dc..09c70ca53b 100644 --- a/frontend/src/app/main/ui/measurements.scss +++ b/frontend/src/app/main/ui/measurements.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 75ecfdbe28..dcba96e327 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/modal.scss b/frontend/src/app/main/ui/modal.scss index be7675a9a1..f690745fdb 100644 --- a/frontend/src/app/main/ui/modal.scss +++ b/frontend/src/app/main/ui/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/nitrate/entry.cljs b/frontend/src/app/main/ui/nitrate/entry.cljs index 4bcadf3216..c1defa2cb0 100644 --- a/frontend/src/app/main/ui/nitrate/entry.cljs +++ b/frontend/src/app/main/ui/nitrate/entry.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.entry (:require diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs index 8489cdec4a..abe9db8bfc 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-activation-success-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss index 5f1e8dd483..215d01a2b4 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 876852198e..1ede96a4d6 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-code-activation-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss index a26030f410..0cdeebf33e 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index c9c34bfb73..93e3a61eda 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.nitrate.nitrate-form (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.scss b/frontend/src/app/main/ui/nitrate/nitrate_form.scss index 5de76305e5..5ca982d0f6 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index a7cead63cc..24e96e11dd 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications (:require diff --git a/frontend/src/app/main/ui/notifications/badge.cljs b/frontend/src/app/main/ui/notifications/badge.cljs index 33f33c8e8c..36f428e044 100644 --- a/frontend/src/app/main/ui/notifications/badge.cljs +++ b/frontend/src/app/main/ui/notifications/badge.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.badge (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/badge.scss b/frontend/src/app/main/ui/notifications/badge.scss index 6741eef522..6e003ad0dd 100644 --- a/frontend/src/app/main/ui/notifications/badge.scss +++ b/frontend/src/app/main/ui/notifications/badge.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/notifications/context_notification.cljs b/frontend/src/app/main/ui/notifications/context_notification.cljs index 2c07d5218d..923a4958a3 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.cljs +++ b/frontend/src/app/main/ui/notifications/context_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.context-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index 7f6f5079ad..e4cb80b794 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/notifications/inline_notification.cljs b/frontend/src/app/main/ui/notifications/inline_notification.cljs index 2934b71236..c4fa5b70e8 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.cljs +++ b/frontend/src/app/main/ui/notifications/inline_notification.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.notifications.inline-notification (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/notifications/inline_notification.scss b/frontend/src/app/main/ui/notifications/inline_notification.scss index 65f912abeb..21c73edc04 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.scss +++ b/frontend/src/app/main/ui/notifications/inline_notification.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/onboarding/questions.cljs b/frontend/src/app/main/ui/onboarding/questions.cljs index f281503bc1..a3c19b32fc 100644 --- a/frontend/src/app/main/ui/onboarding/questions.cljs +++ b/frontend/src/app/main/ui/onboarding/questions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.questions "External form for onboarding questions." diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index d9363415f7..9a1adaccc6 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index fbd57b9826..d91f9fa4ea 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.team-choice (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/onboarding/team_choice.scss b/frontend/src/app/main/ui/onboarding/team_choice.scss index a86e316e21..92cdcfb6e5 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.scss +++ b/frontend/src/app/main/ui/onboarding/team_choice.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/onboarding/templates.cljs b/frontend/src/app/main/ui/onboarding/templates.cljs index 3fccd1dc60..5ef15a2546 100644 --- a/frontend/src/app/main/ui/onboarding/templates.cljs +++ b/frontend/src/app/main/ui/onboarding/templates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.onboarding.templates (:require diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index eb00d11eed..d7ee44c17a 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases (:require diff --git a/frontend/src/app/main/ui/releases/common.cljs b/frontend/src/app/main/ui/releases/common.cljs index 239cb129fc..5c57968702 100644 --- a/frontend/src/app/main/ui/releases/common.cljs +++ b/frontend/src/app/main/ui/releases/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/common.scss b/frontend/src/app/main/ui/releases/common.scss index 84b9f80278..9aa02e3d36 100644 --- a/frontend/src/app/main/ui/releases/common.scss +++ b/frontend/src/app/main/ui/releases/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v1_10.cljs b/frontend/src/app/main/ui/releases/v1_10.cljs index 40ce722ef4..e42930f4b4 100644 --- a/frontend/src/app/main/ui/releases/v1_10.cljs +++ b/frontend/src/app/main/ui/releases/v1_10.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-10 (:require diff --git a/frontend/src/app/main/ui/releases/v1_11.cljs b/frontend/src/app/main/ui/releases/v1_11.cljs index 88061340b8..11236c2360 100644 --- a/frontend/src/app/main/ui/releases/v1_11.cljs +++ b/frontend/src/app/main/ui/releases/v1_11.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-11 (:require diff --git a/frontend/src/app/main/ui/releases/v1_12.cljs b/frontend/src/app/main/ui/releases/v1_12.cljs index 8cfcb9c84b..0ef0fd7fbc 100644 --- a/frontend/src/app/main/ui/releases/v1_12.cljs +++ b/frontend/src/app/main/ui/releases/v1_12.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-12 (:require diff --git a/frontend/src/app/main/ui/releases/v1_13.cljs b/frontend/src/app/main/ui/releases/v1_13.cljs index a921d6d163..6edb6562fe 100644 --- a/frontend/src/app/main/ui/releases/v1_13.cljs +++ b/frontend/src/app/main/ui/releases/v1_13.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-13 (:require diff --git a/frontend/src/app/main/ui/releases/v1_14.cljs b/frontend/src/app/main/ui/releases/v1_14.cljs index fb59584875..eb103c34b6 100644 --- a/frontend/src/app/main/ui/releases/v1_14.cljs +++ b/frontend/src/app/main/ui/releases/v1_14.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-14 (:require diff --git a/frontend/src/app/main/ui/releases/v1_15.cljs b/frontend/src/app/main/ui/releases/v1_15.cljs index 8747d9f84c..5b62d0b6e2 100644 --- a/frontend/src/app/main/ui/releases/v1_15.cljs +++ b/frontend/src/app/main/ui/releases/v1_15.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-15 (:require diff --git a/frontend/src/app/main/ui/releases/v1_16.cljs b/frontend/src/app/main/ui/releases/v1_16.cljs index fcbf2a5d28..0b9adc096e 100644 --- a/frontend/src/app/main/ui/releases/v1_16.cljs +++ b/frontend/src/app/main/ui/releases/v1_16.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-16 (:require diff --git a/frontend/src/app/main/ui/releases/v1_17.cljs b/frontend/src/app/main/ui/releases/v1_17.cljs index 961e4f31f7..a25cbecbf0 100644 --- a/frontend/src/app/main/ui/releases/v1_17.cljs +++ b/frontend/src/app/main/ui/releases/v1_17.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-17 (:require diff --git a/frontend/src/app/main/ui/releases/v1_18.cljs b/frontend/src/app/main/ui/releases/v1_18.cljs index 970b3dd20d..350f69889a 100644 --- a/frontend/src/app/main/ui/releases/v1_18.cljs +++ b/frontend/src/app/main/ui/releases/v1_18.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-18 (:require diff --git a/frontend/src/app/main/ui/releases/v1_19.cljs b/frontend/src/app/main/ui/releases/v1_19.cljs index e17ab646a3..706bc543ec 100644 --- a/frontend/src/app/main/ui/releases/v1_19.cljs +++ b/frontend/src/app/main/ui/releases/v1_19.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-19 (:require diff --git a/frontend/src/app/main/ui/releases/v1_4.cljs b/frontend/src/app/main/ui/releases/v1_4.cljs index e914e31161..6cf2ebe489 100644 --- a/frontend/src/app/main/ui/releases/v1_4.cljs +++ b/frontend/src/app/main/ui/releases/v1_4.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-4 (:require diff --git a/frontend/src/app/main/ui/releases/v1_5.cljs b/frontend/src/app/main/ui/releases/v1_5.cljs index 3470033bf9..f7ccaef100 100644 --- a/frontend/src/app/main/ui/releases/v1_5.cljs +++ b/frontend/src/app/main/ui/releases/v1_5.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-5 (:require diff --git a/frontend/src/app/main/ui/releases/v1_6.cljs b/frontend/src/app/main/ui/releases/v1_6.cljs index 46ab5c54f7..5cedab7a02 100644 --- a/frontend/src/app/main/ui/releases/v1_6.cljs +++ b/frontend/src/app/main/ui/releases/v1_6.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-6 (:require diff --git a/frontend/src/app/main/ui/releases/v1_7.cljs b/frontend/src/app/main/ui/releases/v1_7.cljs index db8724fb6a..3f1b83f11d 100644 --- a/frontend/src/app/main/ui/releases/v1_7.cljs +++ b/frontend/src/app/main/ui/releases/v1_7.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-7 (:require diff --git a/frontend/src/app/main/ui/releases/v1_8.cljs b/frontend/src/app/main/ui/releases/v1_8.cljs index 5e097fa1a5..a46f475121 100644 --- a/frontend/src/app/main/ui/releases/v1_8.cljs +++ b/frontend/src/app/main/ui/releases/v1_8.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-8 (:require diff --git a/frontend/src/app/main/ui/releases/v1_9.cljs b/frontend/src/app/main/ui/releases/v1_9.cljs index 2d43e309fb..01904afeb3 100644 --- a/frontend/src/app/main/ui/releases/v1_9.cljs +++ b/frontend/src/app/main/ui/releases/v1_9.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v1-9 (:require diff --git a/frontend/src/app/main/ui/releases/v2_0.cljs b/frontend/src/app/main/ui/releases/v2_0.cljs index 1bb988d0bf..a26b9001f4 100644 --- a/frontend/src/app/main/ui/releases/v2_0.cljs +++ b/frontend/src/app/main/ui/releases/v2_0.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-0 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_0.scss b/frontend/src/app/main/ui/releases/v2_0.scss index c759917f4f..55fd39b307 100644 --- a/frontend/src/app/main/ui/releases/v2_0.scss +++ b/frontend/src/app/main/ui/releases/v2_0.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_1.cljs b/frontend/src/app/main/ui/releases/v2_1.cljs index 98c205d8ad..8e848e43e5 100644 --- a/frontend/src/app/main/ui/releases/v2_1.cljs +++ b/frontend/src/app/main/ui/releases/v2_1.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-1 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_1.scss b/frontend/src/app/main/ui/releases/v2_1.scss index 32416f9f9c..e20bb773b1 100644 --- a/frontend/src/app/main/ui/releases/v2_1.scss +++ b/frontend/src/app/main/ui/releases/v2_1.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_10.cljs b/frontend/src/app/main/ui/releases/v2_10.cljs index 0723a65286..b6b3d6b699 100644 --- a/frontend/src/app/main/ui/releases/v2_10.cljs +++ b/frontend/src/app/main/ui/releases/v2_10.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-10 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_10.scss b/frontend/src/app/main/ui/releases/v2_10.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_10.scss +++ b/frontend/src/app/main/ui/releases/v2_10.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_11.cljs b/frontend/src/app/main/ui/releases/v2_11.cljs index 7e2f32df16..508d63b043 100644 --- a/frontend/src/app/main/ui/releases/v2_11.cljs +++ b/frontend/src/app/main/ui/releases/v2_11.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-11 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_11.scss b/frontend/src/app/main/ui/releases/v2_11.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_11.scss +++ b/frontend/src/app/main/ui/releases/v2_11.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_12.cljs b/frontend/src/app/main/ui/releases/v2_12.cljs index 0f709c553c..2f540eff71 100644 --- a/frontend/src/app/main/ui/releases/v2_12.cljs +++ b/frontend/src/app/main/ui/releases/v2_12.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-12 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_12.scss b/frontend/src/app/main/ui/releases/v2_12.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_12.scss +++ b/frontend/src/app/main/ui/releases/v2_12.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_13.cljs b/frontend/src/app/main/ui/releases/v2_13.cljs index 569279cabf..ba3e7f04ce 100644 --- a/frontend/src/app/main/ui/releases/v2_13.cljs +++ b/frontend/src/app/main/ui/releases/v2_13.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-13 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_13.scss b/frontend/src/app/main/ui/releases/v2_13.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_13.scss +++ b/frontend/src/app/main/ui/releases/v2_13.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_14.cljs b/frontend/src/app/main/ui/releases/v2_14.cljs index 925d6ebaa6..22382554ae 100644 --- a/frontend/src/app/main/ui/releases/v2_14.cljs +++ b/frontend/src/app/main/ui/releases/v2_14.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-14 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_14.scss b/frontend/src/app/main/ui/releases/v2_14.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_14.scss +++ b/frontend/src/app/main/ui/releases/v2_14.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs index f5032dd573..6c6c130af1 100644 --- a/frontend/src/app/main/ui/releases/v2_15.cljs +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-15 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_15.scss +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_16.cljs b/frontend/src/app/main/ui/releases/v2_16.cljs index 833beaf100..c6a4e5d953 100644 --- a/frontend/src/app/main/ui/releases/v2_16.cljs +++ b/frontend/src/app/main/ui/releases/v2_16.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-16 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_16.scss b/frontend/src/app/main/ui/releases/v2_16.scss index 40c8f5316f..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_16.scss +++ b/frontend/src/app/main/ui/releases/v2_16.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_17.cljs b/frontend/src/app/main/ui/releases/v2_17.cljs index 246c19174d..78390fd2d7 100644 --- a/frontend/src/app/main/ui/releases/v2_17.cljs +++ b/frontend/src/app/main/ui/releases/v2_17.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-17 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_17.scss b/frontend/src/app/main/ui/releases/v2_17.scss index 40c8f5316f..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_17.scss +++ b/frontend/src/app/main/ui/releases/v2_17.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_2.cljs b/frontend/src/app/main/ui/releases/v2_2.cljs index fc99bf0392..a1eae34086 100644 --- a/frontend/src/app/main/ui/releases/v2_2.cljs +++ b/frontend/src/app/main/ui/releases/v2_2.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-2 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_2.scss b/frontend/src/app/main/ui/releases/v2_2.scss index 3da79041af..52a7da75f0 100644 --- a/frontend/src/app/main/ui/releases/v2_2.scss +++ b/frontend/src/app/main/ui/releases/v2_2.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_3.cljs b/frontend/src/app/main/ui/releases/v2_3.cljs index 6771455a97..a6afd823f1 100644 --- a/frontend/src/app/main/ui/releases/v2_3.cljs +++ b/frontend/src/app/main/ui/releases/v2_3.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-3 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_3.scss b/frontend/src/app/main/ui/releases/v2_3.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_3.scss +++ b/frontend/src/app/main/ui/releases/v2_3.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_4.cljs b/frontend/src/app/main/ui/releases/v2_4.cljs index cee512c6c0..40c6e6998b 100644 --- a/frontend/src/app/main/ui/releases/v2_4.cljs +++ b/frontend/src/app/main/ui/releases/v2_4.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-4 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_4.scss b/frontend/src/app/main/ui/releases/v2_4.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_4.scss +++ b/frontend/src/app/main/ui/releases/v2_4.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_5.cljs b/frontend/src/app/main/ui/releases/v2_5.cljs index e195fc92c8..76c87de90b 100644 --- a/frontend/src/app/main/ui/releases/v2_5.cljs +++ b/frontend/src/app/main/ui/releases/v2_5.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-5 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_5.scss b/frontend/src/app/main/ui/releases/v2_5.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_5.scss +++ b/frontend/src/app/main/ui/releases/v2_5.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_6.cljs b/frontend/src/app/main/ui/releases/v2_6.cljs index bbb456f6ac..17ce515507 100644 --- a/frontend/src/app/main/ui/releases/v2_6.cljs +++ b/frontend/src/app/main/ui/releases/v2_6.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-6 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_6.scss b/frontend/src/app/main/ui/releases/v2_6.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_6.scss +++ b/frontend/src/app/main/ui/releases/v2_6.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_7.cljs b/frontend/src/app/main/ui/releases/v2_7.cljs index 056744c572..7e845db312 100644 --- a/frontend/src/app/main/ui/releases/v2_7.cljs +++ b/frontend/src/app/main/ui/releases/v2_7.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-7 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_7.scss b/frontend/src/app/main/ui/releases/v2_7.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_7.scss +++ b/frontend/src/app/main/ui/releases/v2_7.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_8.cljs b/frontend/src/app/main/ui/releases/v2_8.cljs index dc343efe0c..b701b72ec0 100644 --- a/frontend/src/app/main/ui/releases/v2_8.cljs +++ b/frontend/src/app/main/ui/releases/v2_8.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-8 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_8.scss b/frontend/src/app/main/ui/releases/v2_8.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_8.scss +++ b/frontend/src/app/main/ui/releases/v2_8.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/releases/v2_9.cljs b/frontend/src/app/main/ui/releases/v2_9.cljs index a4f26fd45d..d9b9e4e3b8 100644 --- a/frontend/src/app/main/ui/releases/v2_9.cljs +++ b/frontend/src/app/main/ui/releases/v2_9.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.releases.v2-9 (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/releases/v2_9.scss b/frontend/src/app/main/ui/releases/v2_9.scss index 11df4e1bb5..1937faa438 100644 --- a/frontend/src/app/main/ui/releases/v2_9.scss +++ b/frontend/src/app/main/ui/releases/v2_9.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index 0a898d7992..6502958f1e 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.routes (:require diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs index 3a4b98aed4..97043fae0a 100644 --- a/frontend/src/app/main/ui/settings.cljs +++ b/frontend/src/app/main/ui/settings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings.scss b/frontend/src/app/main/ui/settings.scss index b553370331..5898dbac3b 100644 --- a/frontend/src/app/main/ui/settings.scss +++ b/frontend/src/app/main/ui/settings.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-dashboard"; @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index deec07de43..98e15e2c58 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.change-email (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 51d505e6e9..4795cf6ac6 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/settings/delete_account.cljs b/frontend/src/app/main/ui/settings/delete_account.cljs index b6d0a0e4c8..65d67d0e6e 100644 --- a/frontend/src/app/main/ui/settings/delete_account.cljs +++ b/frontend/src/app/main/ui/settings/delete_account.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.delete-account (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index ff4a0849e9..4d0c1efafc 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/settings/feedback.cljs b/frontend/src/app/main/ui/settings/feedback.cljs index ee7fd553e9..b5a1a88373 100644 --- a/frontend/src/app/main/ui/settings/feedback.cljs +++ b/frontend/src/app/main/ui/settings/feedback.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.feedback "Feedback form." diff --git a/frontend/src/app/main/ui/settings/feedback.scss b/frontend/src/app/main/ui/settings/feedback.scss index 2a5c50b65d..aee1490e20 100644 --- a/frontend/src/app/main/ui/settings/feedback.scss +++ b/frontend/src/app/main/ui/settings/feedback.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-refactor" as *; @use "./profile"; diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index bb0c8a81af..1afd0e768f 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.import-shortcuts-diff-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index 0d42b9d1a6..e810578008 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/integrations.cljs b/frontend/src/app/main/ui/settings/integrations.cljs index c87b99ed3b..334ca32806 100644 --- a/frontend/src/app/main/ui/settings/integrations.cljs +++ b/frontend/src/app/main/ui/settings/integrations.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.integrations (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss index 25e12a65b1..174cf8a73d 100644 --- a/frontend/src/app/main/ui/settings/integrations.scss +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/notifications.cljs b/frontend/src/app/main/ui/settings/notifications.cljs index fdc8408006..6bf42e5849 100644 --- a/frontend/src/app/main/ui/settings/notifications.cljs +++ b/frontend/src/app/main/ui/settings/notifications.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.notifications (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/notifications.scss b/frontend/src/app/main/ui/settings/notifications.scss index 333c49f1c8..93f1bd7edf 100644 --- a/frontend/src/app/main/ui/settings/notifications.scss +++ b/frontend/src/app/main/ui/settings/notifications.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./profile" as *; diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index 23932ba133..83ecd648d0 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.options (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss index d5755e6062..cdae1e185b 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "./profile" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/settings/password.cljs b/frontend/src/app/main/ui/settings/password.cljs index 96d0003001..434cd9de4e 100644 --- a/frontend/src/app/main/ui/settings/password.cljs +++ b/frontend/src/app/main/ui/settings/password.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.password (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/password.scss b/frontend/src/app/main/ui/settings/password.scss index f1eaf13872..2ca11674b9 100644 --- a/frontend/src/app/main/ui/settings/password.scss +++ b/frontend/src/app/main/ui/settings/password.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "./profile" as *; diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 1fd133d794..1c1add422b 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.profile (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 5d42d44c1e..1a6fca03c6 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "common/refactor/common-refactor" as *; diff --git a/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss b/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss index acfe0ee27f..78677d7d4b 100644 --- a/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss +++ b/frontend/src/app/main/ui/settings/restore_shortcuts_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/shortcuts.scss b/frontend/src/app/main/ui/settings/shortcuts.scss index a3bfa3df78..522147f61e 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.scss +++ b/frontend/src/app/main/ui/settings/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs index 99313c13b4..652842ff26 100644 --- a/frontend/src/app/main/ui/settings/sidebar.cljs +++ b/frontend/src/app/main/ui/settings/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.settings.sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index daf1e5f93b..e63882a113 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/mixins.scss" as *; diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 6130d7e3a3..ff7885506d 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 118812762e..20defd27f0 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.attrs (:require diff --git a/frontend/src/app/main/ui/shapes/bool.cljs b/frontend/src/app/main/ui/shapes/bool.cljs index dc8ce832e0..0066b4ca11 100644 --- a/frontend/src/app/main/ui/shapes/bool.cljs +++ b/frontend/src/app/main/ui/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.bool (:require diff --git a/frontend/src/app/main/ui/shapes/circle.cljs b/frontend/src/app/main/ui/shapes/circle.cljs index ffceef78fb..8dc5452b36 100644 --- a/frontend/src/app/main/ui/shapes/circle.cljs +++ b/frontend/src/app/main/ui/shapes/circle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.circle (:require diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index dfb0a5a1b8..aee07c11c8 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.custom-stroke (:require diff --git a/frontend/src/app/main/ui/shapes/embed.cljs b/frontend/src/app/main/ui/shapes/embed.cljs index 0e396fcf4e..0665138730 100644 --- a/frontend/src/app/main/ui/shapes/embed.cljs +++ b/frontend/src/app/main/ui/shapes/embed.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.embed (:require diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 46c9856ef6..f32b042779 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.export "Components that generates penpot specific svg nodes with diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs index c1be67a728..3b7c29da0b 100644 --- a/frontend/src/app/main/ui/shapes/fills.cljs +++ b/frontend/src/app/main/ui/shapes/fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.fills (:require diff --git a/frontend/src/app/main/ui/shapes/filters.cljs b/frontend/src/app/main/ui/shapes/filters.cljs index 9c3c9c3350..a4468d649d 100644 --- a/frontend/src/app/main/ui/shapes/filters.cljs +++ b/frontend/src/app/main/ui/shapes/filters.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.filters (:require diff --git a/frontend/src/app/main/ui/shapes/frame.cljs b/frontend/src/app/main/ui/shapes/frame.cljs index 9b181c4caa..d736635701 100644 --- a/frontend/src/app/main/ui/shapes/frame.cljs +++ b/frontend/src/app/main/ui/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.frame (:require diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index f0f8bb620d..0845217dae 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.gradients (:require diff --git a/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs b/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs index 0e34c276f9..3a72e4592a 100644 --- a/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs +++ b/frontend/src/app/main/ui/shapes/grid_layout_viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.grid-layout-viewer (:require diff --git a/frontend/src/app/main/ui/shapes/group.cljs b/frontend/src/app/main/ui/shapes/group.cljs index ae836f2547..da7015297d 100644 --- a/frontend/src/app/main/ui/shapes/group.cljs +++ b/frontend/src/app/main/ui/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.group (:require diff --git a/frontend/src/app/main/ui/shapes/image.cljs b/frontend/src/app/main/ui/shapes/image.cljs index 04b6e0332d..9ec6223717 100644 --- a/frontend/src/app/main/ui/shapes/image.cljs +++ b/frontend/src/app/main/ui/shapes/image.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.image (:require diff --git a/frontend/src/app/main/ui/shapes/mask.cljs b/frontend/src/app/main/ui/shapes/mask.cljs index f84a040e95..90b53d8e40 100644 --- a/frontend/src/app/main/ui/shapes/mask.cljs +++ b/frontend/src/app/main/ui/shapes/mask.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.mask (:require diff --git a/frontend/src/app/main/ui/shapes/path.cljs b/frontend/src/app/main/ui/shapes/path.cljs index bec235c8d0..a39de86a98 100644 --- a/frontend/src/app/main/ui/shapes/path.cljs +++ b/frontend/src/app/main/ui/shapes/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.path (:require diff --git a/frontend/src/app/main/ui/shapes/rect.cljs b/frontend/src/app/main/ui/shapes/rect.cljs index 799d830b3f..6d205fca38 100644 --- a/frontend/src/app/main/ui/shapes/rect.cljs +++ b/frontend/src/app/main/ui/shapes/rect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.rect (:require diff --git a/frontend/src/app/main/ui/shapes/shape.cljs b/frontend/src/app/main/ui/shapes/shape.cljs index ad30274607..c3600dc2d5 100644 --- a/frontend/src/app/main/ui/shapes/shape.cljs +++ b/frontend/src/app/main/ui/shapes/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.shape (:require diff --git a/frontend/src/app/main/ui/shapes/svg_defs.cljs b/frontend/src/app/main/ui/shapes/svg_defs.cljs index b61bfad252..32d6a0b6c5 100644 --- a/frontend/src/app/main/ui/shapes/svg_defs.cljs +++ b/frontend/src/app/main/ui/shapes/svg_defs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.svg-defs (:require diff --git a/frontend/src/app/main/ui/shapes/svg_raw.cljs b/frontend/src/app/main/ui/shapes/svg_raw.cljs index 12db38a3af..f5ad88abe3 100644 --- a/frontend/src/app/main/ui/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index e7581b6965..62e902ce78 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text (:require diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 9a78b482e3..13927d2fce 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.fo-text (:require diff --git a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs index 07160a45cc..e8c6776bb8 100644 --- a/frontend/src/app/main/ui/shapes/text/fontfaces.cljs +++ b/frontend/src/app/main/ui/shapes/text/fontfaces.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.fontfaces (:require diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 5bf0dc150a..299ee49de7 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.html-text (:require diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index edf5329ddf..657857f667 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.styles (:require diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index aad09c18ba..a2fbdd1ea5 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shapes.text.svg-text (:require diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 4499eac9ed..212f296c95 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/shortcuts.scss b/frontend/src/app/main/ui/shortcuts.scss index b92405e5c4..4576fe8d50 100644 --- a/frontend/src/app/main/ui/shortcuts.scss +++ b/frontend/src/app/main/ui/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 95b73d0191..24357f8aac 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.static (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 1708e23cd7..8c0e8d4516 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index b1ae49b3b6..030216915b 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer.scss b/frontend/src/app/main/ui/viewer.scss index 15f0d46492..d727764672 100644 --- a/frontend/src/app/main/ui/viewer.scss +++ b/frontend/src/app/main/ui/viewer.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs index e08d50cb5a..91ab7f82f2 100644 --- a/frontend/src/app/main/ui/viewer/comments.cljs +++ b/frontend/src/app/main/ui/viewer/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index 68169126f5..7fcd840b9e 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 26369ae3af..3d1404373a 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/header.scss b/frontend/src/app/main/ui/viewer/header.scss index 329a96a079..ffe476cbda 100644 --- a/frontend/src/app/main/ui/viewer/header.scss +++ b/frontend/src/app/main/ui/viewer/header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/inspect.cljs b/frontend/src/app/main/ui/viewer/inspect.cljs index db8da10057..3ffed2dc99 100644 --- a/frontend/src/app/main/ui/viewer/inspect.cljs +++ b/frontend/src/app/main/ui/viewer/inspect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.inspect (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/inspect.scss b/frontend/src/app/main/ui/viewer/inspect.scss index 38e12b01dd..5193c50f60 100644 --- a/frontend/src/app/main/ui/viewer/inspect.scss +++ b/frontend/src/app/main/ui/viewer/inspect.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index 19059f5fde..bd96266d37 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.interactions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/interactions.scss b/frontend/src/app/main/ui/viewer/interactions.scss index 104d39b707..b17ac08b84 100644 --- a/frontend/src/app/main/ui/viewer/interactions.scss +++ b/frontend/src/app/main/ui/viewer/interactions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index 0371714419..8da7fd857a 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.login (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/viewer/login.scss index 11cb81d678..6902bedfa0 100644 --- a/frontend/src/app/main/ui/viewer/login.scss +++ b/frontend/src/app/main/ui/viewer/login.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index 425ed01d18..4f3f0aa3d5 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.shapes "The main container for a frame in viewer mode" diff --git a/frontend/src/app/main/ui/viewer/share_link.cljs b/frontend/src/app/main/ui/viewer/share_link.cljs index 5b21805626..1711997594 100644 --- a/frontend/src/app/main/ui/viewer/share_link.cljs +++ b/frontend/src/app/main/ui/viewer/share_link.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.share-link (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index c3856361b4..43e2ba8910 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index ab6b4838fd..d7af5b5d41 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.thumbnails (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/viewer/thumbnails.scss b/frontend/src/app/main/ui/viewer/thumbnails.scss index 20f569227d..63dc586a67 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.scss +++ b/frontend/src/app/main/ui/viewer/thumbnails.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/viewer/viewport_common.cljs b/frontend/src/app/main/ui/viewer/viewport_common.cljs index e2ebb1b881..efb5748a27 100644 --- a/frontend/src/app/main/ui/viewer/viewport_common.cljs +++ b/frontend/src/app/main/ui/viewer/viewport_common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.viewport-common "Shared object preparation for viewer viewports (SVG and WASM)." diff --git a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs index 23d5b13490..e690edfdbf 100644 --- a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.viewer.viewport-wasm (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 278663bbcb..9105d954b6 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index c0630368b8..afb89be6d2 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 16425b2f83..1c95587fc4 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.color-palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/color_palette.scss b/frontend/src/app/main/ui/workspace/color_palette.scss index 44d41b6304..fb0af2c527 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.scss +++ b/frontend/src/app/main/ui/workspace/color_palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs index cf6e143f9b..4a67f0cdb1 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.color-palette-ctx-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss index 16b0428b4d..48fc511406 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index b468b71038..37fec9e299 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index 366eecbdd7..1e6541042e 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index c7ce9d351c..5d7d72cf71 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.color-inputs (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss index d27bd7da31..cf7f1f9202 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs index 56042f6285..8fcf108bb7 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.color-tokens (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss index 1b0eb76dad..aca7b3b4b9 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 7d1c7f14cc..8338544f73 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.gradients (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 5539820871..0ce4019fd4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs index 3b0800645b..c63c69ba45 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.harmony (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss index 6f05eb1179..d84eb19ae8 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 3532cfbc12..5194488b5e 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.hsva (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss index 99ba2dfd59..9bb07a1725 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index 5e01c95785..474c40d60d 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.libraries (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss index 63b7d44d63..ad51293d73 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs index 01804c409c..fc4861678e 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.ramp (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss index 63a2b4973c..afac0687cc 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs index e8f3855d6d..684cdd6d45 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.shortcuts (:require diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index 14c4e1b820..8aff65e5d5 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.colorpicker.slider-selector (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss index fc34824198..1f134745cc 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs index 64882acde7..a6596bf3b6 100644 --- a/frontend/src/app/main/ui/workspace/comments.cljs +++ b/frontend/src/app/main/ui/workspace/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 5fb9de3068..685ac5c0df 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/components_debugger.cljs b/frontend/src/app/main/ui/workspace/components_debugger.cljs index d4aee9d90b..911ecb3cd5 100644 --- a/frontend/src/app/main/ui/workspace/components_debugger.cljs +++ b/frontend/src/app/main/ui/workspace/components_debugger.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.components-debugger (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/components_debugger.scss b/frontend/src/app/main/ui/workspace/components_debugger.scss index 2dd49527a7..cb02282db8 100644 --- a/frontend/src/app/main/ui/workspace/components_debugger.scss +++ b/frontend/src/app/main/ui/workspace/components_debugger.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index bd2758a02c..4f469b04ae 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.context-menu "A workspace specific context menu (mouse right click)." diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index a2fe8c1b73..3061e59a4b 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils" as *; @use "ds/_sizes" as *; diff --git a/frontend/src/app/main/ui/workspace/coordinates.cljs b/frontend/src/app/main/ui/workspace/coordinates.cljs index 3948479ac1..f626c8549c 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.cljs +++ b/frontend/src/app/main/ui/workspace/coordinates.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.coordinates (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/coordinates.scss b/frontend/src/app/main/ui/workspace/coordinates.scss index b5664d49d6..b81087f712 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.scss +++ b/frontend/src/app/main/ui/workspace/coordinates.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 218b9df4b3..51fd5c0a3e 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.left-header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 20c731dd86..63edc7ba5e 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 0a2f5908f2..17a896c286 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.libraries (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/libraries.scss b/frontend/src/app/main/ui/workspace/libraries.scss index 01cbed12aa..21a7ad9a10 100644 --- a/frontend/src/app/main/ui/workspace/libraries.scss +++ b/frontend/src/app/main/ui/workspace/libraries.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index e08de97b03..f4106c4f06 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.main-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 83a724622e..1ff9364c6c 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/nudge.cljs b/frontend/src/app/main/ui/workspace/nudge.cljs index 52975c33d9..7c2ee4f01c 100644 --- a/frontend/src/app/main/ui/workspace/nudge.cljs +++ b/frontend/src/app/main/ui/workspace/nudge.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.nudge (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/nudge.scss b/frontend/src/app/main/ui/workspace/nudge.scss index 8b00f1f360..892de2576d 100644 --- a/frontend/src/app/main/ui/workspace/nudge.scss +++ b/frontend/src/app/main/ui/workspace/nudge.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 7b5d5250cc..7887add48d 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/palette.scss b/frontend/src/app/main/ui/workspace/palette.scss index a96a3be2f3..b61814d2ba 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index 2206d15a24..41cf242d8d 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.plugins (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index 25785192d3..b1aa38ff8e 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/presence.cljs b/frontend/src/app/main/ui/workspace/presence.cljs index 175148d0fd..2d7a3b69c6 100644 --- a/frontend/src/app/main/ui/workspace/presence.cljs +++ b/frontend/src/app/main/ui/workspace/presence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.presence (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/presence.scss b/frontend/src/app/main/ui/workspace/presence.scss index 27eaa9300e..1eafdf6dbd 100644 --- a/frontend/src/app/main/ui/workspace/presence.scss +++ b/frontend/src/app/main/ui/workspace/presence.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index ff5e6a2152..54d1be1889 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.right-header (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index 65302c705d..2b338659aa 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/shapes.cljs b/frontend/src/app/main/ui/workspace/shapes.cljs index cd8c3c3739..960094e712 100644 --- a/frontend/src/app/main/ui/workspace/shapes.cljs +++ b/frontend/src/app/main/ui/workspace/shapes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes "A workspace specific shapes wrappers. diff --git a/frontend/src/app/main/ui/workspace/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/shapes/bool.cljs index f2fb063893..36174edf46 100644 --- a/frontend/src/app/main/ui/workspace/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.bool (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/common.cljs b/frontend/src/app/main/ui/workspace/shapes/common.cljs index 46dcf859e5..7d5031dc85 100644 --- a/frontend/src/app/main/ui/workspace/shapes/common.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.common (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/debug.cljs b/frontend/src/app/main/ui/workspace/shapes/debug.cljs index 5904b18318..0211df92d4 100644 --- a/frontend/src/app/main/ui/workspace/shapes/debug.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.debug (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index 27346b3178..84344e6af2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.frame (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs index adf587e057..237ce41434 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame/dynamic_modifiers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.frame.dynamic-modifiers (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/group.cljs b/frontend/src/app/main/ui/workspace/shapes/group.cljs index 25a5b4f176..d9544e5358 100644 --- a/frontend/src/app/main/ui/workspace/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.group (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/path.cljs b/frontend/src/app/main/ui/workspace/shapes/path.cljs index 9f2a3699b9..33bd9acab0 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.path (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs index 34b00faf93..dbf2bfee11 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.path.editor (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs index 6f304e17f3..e25224fd34 100644 --- a/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index 5a1dad63df..2faf1e9aa1 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text (:require 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 536febb61b..d9bbcb1fad 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.editor (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 977ec783c1..93e7562438 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.text-edition-outline (:require diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 68da4e80b4..5a9f1f3a92 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.v2-editor (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 2563463d17..b61c7b4c07 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.v3-editor "Contenteditable DOM element for WASM text editor input" diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 634392e97a..3869f62a03 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.shapes.text.viewport-texts-html (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index d66dcdc4ff..3bac81d685 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index 66239eb9e9..06d915fa44 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index a6402a61aa..f345c514f2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index 079733bcb3..716d0682b8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index 853232c118..e1e70bb2e4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.colors (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss index b590a71f6f..60dfffa9f2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 086a21ae11..ba3ff9786e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.common diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss index 140d100bfc..f3ba35dfa1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs index 45a24f96e1..ab1bfb1137 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.components (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss index a758213bc3..c09e1e4d0f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs index 5ee44ee19d..b0d9747a6e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.file-library (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss index 864066bda7..266718c00b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index 3be72a6c27..b072139f1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.groups (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index 0238d8b50d..003db6e756 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 43627008bd..8d40408436 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.assets.typographies (:require-macros [app.main.style :as stl]) 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 4c841ffbae..593f0a6cbd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss index 054c5ead0f..ffbb7d5e25 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs index a7f6c48db0..51435e5779 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.debug (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.scss b/frontend/src/app/main/ui/workspace/sidebar/debug.scss index e358c7b9f4..da70295d97 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs index f7c1e84ca9..92c9ae2e2b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.debug-shape-info (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index 6760cf76a3..c849565bb5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.cljs b/frontend/src/app/main/ui/workspace/sidebar/history.cljs index 1fc0b07464..e399b124ff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.history (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index a22bb5fc35..e7e8b1a2cd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index 446ef4187e..5733b8c3ce 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layer-item (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index 192a00f522..445b8e5bc9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 3cd2038675..db497cf242 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layer-name (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss index de465cac36..23a36b1d1b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index e9db4a2343..9d6e52ded2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.layers (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 328c92afdb..0d1c69b4f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index f07dce3891..abddbaf307 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.scss b/frontend/src/app/main/ui/workspace/sidebar/options.scss index 5b755e38a5..54b1702a1e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index a334737206..84aa1325b8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.common (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.scss b/frontend/src/app/main/ui/workspace/sidebar/options/common.scss index 209ee67afb..2f401be011 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .advanced-options-wrapper { display: flex; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs index 87785b6f65..34f09999e3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.drawing (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs index 67c81dd494..2d5d384542 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.drawing.frame (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss index d4c166465e..71829e132f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs index f3e78e951e..ce43b50d2e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.align (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss index 2ca87d872c..135544aa70 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs index 1e63564a82..62efb577e5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.blur (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss index deb2054058..c1524b82c3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs index 6fb87a8b47..46801115ad 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.bool (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index 5166aa8ac8..50c8ae0625 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss index 61ce6b4049..6b6e64d30d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index e6f9e6010c..cda718388c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.color-selection (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss index 72ce645929..15da32944b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index 74e2eecb2d..d2a8ff1c90 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.component (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index cb738df65d..ca8bbd7aab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs index 0e89cb166c..216dfec3f3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.constraints (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss index 6ba682c5c4..20b13505b9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs index 84886d3f0c..25a4ca08ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.exports (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss index c0b36c4252..92aa2ab196 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 4141143a6e..8863bbeedc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.fill (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss index f497a4ced0..fe412c7867 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index 66b12b9955..608a25c537 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.frame-grid (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index 3647ffbdf8..8f97f4dc42 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs index 34e27560de..2434c66c07 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.grid-cell (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss index d8406725e3..8c245a491f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss index 0747e67692..16fe94c005 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .numeric-input-wrapper { --dropdown-width: var(--seven-columns-width); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs index 6b5f537bfc..f6bebb8d8f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.interactions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss index cffa05d73b..1375dd986f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index 980a2aa336..c74fbdca56 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layer (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss index e00abf5e98..ff8da843ff 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index c02b3e6b90..14f89f38b9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layout-container (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 985eb8adb2..7182dc5729 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL // TODO: When button replace remove this @use @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index 63da937d98..10b9c42db7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.layout-item (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index 2daf441b27..b362df51d0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index c9b3190574..f2b27f824e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.measures (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss index b2cdece8c3..8fa48d1bb0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs index 9e5de7487e..9178b58558 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss index 0c3a1cea44..273459001c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index d671e7294c..874f353d6e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.stroke (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss index dae6ff306e..bfb581f5a6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs index 17ee23f154..ad07f27f6d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.svg-attrs (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss index be8302e9f5..ed73954e38 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60832b642c..71ed30673a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.text (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index adc7da205a..4b39b0663c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "../../../sidebar/common/sidebar.scss" as sidebar; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs index 9ca3054996..509c4f492c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.token-typography-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss index 615e8379c5..32275f09b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 4dc3473644..6bbb835272 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.typography (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index c5b1a59d2f..379c27718a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs index 894a1d1e64..52b9687787 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs @@ -3,7 +3,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.menus.variants-help-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss index 74d6cef89c..00578743b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index 95f236bd7c..d5869890db 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.page "Page options menu entries." diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.scss b/frontend/src/app/main/ui/workspace/sidebar/options/page.scss index c661729d21..9e64c4f0af 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.scss @@ -2,6 +2,6 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; 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 3433048e26..fd070ef0cf 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 @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.color-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 3e49e343d7..078f8f4a0a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs index 449ad67c83..fde57efcde 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.shadow-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss index 4a9d8a72ca..a376138e7f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; 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 cdb971dbbf..1f84979d27 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 @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.rows.stroke-row (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss index 227d229fd1..923c0aad7b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs index 843b8ca560..36ba9b5eb7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/bool.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.bool (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs index eced13447a..f596622f80 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/circle.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.circle (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs index bff1030482..a9e131303b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.frame (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs index 8d4149dc7b..901e712574 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.group (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss index 77327e43d6..d994fad7b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 8a0fca8b6d..a5ed1ce76b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.multiple (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss index 77327e43d6..d994fad7b5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs index 05b8158544..255997db20 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.path (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs index 2024ce5576..dd19747911 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/rect.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.rect (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs index 42511bd2a7..a2fd1ed690 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/svg_raw.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.svg-raw (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 9b3005ecea..3158f59be2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.options.shapes.text (:require diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index be454c585c..542d01aa83 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.shortcuts (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss index a2b1771b30..07d7e60897 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index bcb0771477..ac18b484d0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.sitemap (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index 4bfbe1c95d..60e09e30b3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 13b5b10432..5f3be0c953 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -2,7 +2,7 @@ ;; License v. 2.0. If a copy of the MPL was not distributed with this ;; file You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.sidebar.versions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.scss b/frontend/src/app/main/ui/workspace/sidebar/versions.scss index 9605790813..ea96aa9792 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/text_palette.cljs b/frontend/src/app/main/ui/workspace/text_palette.cljs index abc3981090..b65d5842f7 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.text-palette (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/text_palette.scss b/frontend/src/app/main/ui/workspace/text_palette.scss index c4c2a49ad8..61cf22e34b 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.scss +++ b/frontend/src/app/main/ui/workspace/text_palette.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs index ae4622610d..cdc13170f2 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.text-palette-ctx-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss index b1aefa153c..9cf70b7e93 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/export.cljs b/frontend/src/app/main/ui/workspace/tokens/export.cljs index 8b6123bfff..55ecf194ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.export (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/export.scss b/frontend/src/app/main/ui/workspace/tokens/export.scss index 169c84c0a3..a6e1de4054 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs index c6e37138d2..b1c9ee0f09 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.export.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss index 02edf4df25..d1ece74f9f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/import.scss b/frontend/src/app/main/ui/workspace/tokens/import.scss index d6abf8e8f6..2ef8ed1b31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs index 8c5a886daa..359d9e47a5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.import.modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss index 440445f3b9..8244a29c46 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs b/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs index 4481e1306d..0a9d910c40 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/import_from_library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.import-from-library (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss index b63eaf6648..b7b4ebc20a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/management.scss b/frontend/src/app/main/ui/workspace/tokens/management.scss index a8303de162..d8edcc56b9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; 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 455a8135bc..99af4965fd 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 @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.context-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss index a50dc6e193..2709141353 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs index b208852c12..29243e1576 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.color (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index a655d0756a..1e8d9a6198 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.color-input (:require-macros [app.main.style :as stl]) 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 7c2e6b65dd..b3dbec0c94 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 @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.combobox (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss index 41cc87fd25..884f0d1497 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs index be078ed2da..97b22a5a18 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs index cddb3b9eb4..0b4dd68554 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index e0bf1bb221..88e8346580 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss index c1d132758c..d4027b3a1a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index aed66af134..8264836980 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.input (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs index 324a59328b..8e4a31ea7e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/select.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.select (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs index b06486b9aa..cb628583cc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.controls.token-parsing (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs index 0168121331..21cfe89b78 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/font_family.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.font-family (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index 31fc0b3c35..a817a69fe2 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.form-container (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index efc0048f82..66b0bf93b7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.generic-form (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss index 9d3e14762e..63f49e25b0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs index 1027215996..d46672ea31 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.modals (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss index d8ef365c71..8acb829575 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss index 16206e3ea2..426d4be102 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 41e0c2ce88..90fef836e7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.shadow (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss index 690ced8025..ead3c104fe 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 62b9a63410..4e07ed1c69 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.forms.typography (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss index 6f384fe098..2aa3e9707c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index 020375c28e..4872c116d9 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.group diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss index ef24d9e75c..9ce9830ef5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index e61c2fcbf3..d546bc9497 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.token-pill (:require-macros diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss index 91a8d0301f..4580062688 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index fa917bb0fc..c7528941bb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.management.token-tree (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss index a9b99adde7..87ef7f2e4d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs index 10ce9adaf1..9c8d2bd33d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.remapping-modal "Token remapping confirmation modal" diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss index ead380916e..24a0fbc012 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs index 460b508a18..2e3bc6478d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.scss b/frontend/src/app/main/ui/workspace/tokens/sets.scss index 5fd685ed9f..8034c0bbd1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs index 5fc4e40c56..8c26b751de 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets.context-menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss index ff4cda241a..c09b856e2e 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs index e835234ea9..4b778941b4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sets.lists diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss index 5fd685ed9f..8034c0bbd1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/settings.cljs b/frontend/src/app/main/ui/workspace/tokens/settings.cljs index 97d515fe1c..aba65dc7a8 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/settings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.settings (:require diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs index 3acd1439dd..4833ecb59c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.settings.menu (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss index 377744453e..60ff17126b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/spacing.scss" as *; @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs index dfbc3815d1..4d2f77863f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.sidebar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss index 9c11e0178d..7fcbb8a2e7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/spacing.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes.cljs b/frontend/src/app/main/ui/workspace/tokens/themes.cljs index ce9a960b5f..7661287472 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes.scss b/frontend/src/app/main/ui/workspace/tokens/themes.scss index 19e1de91a0..326596b479 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/typography.scss" as *; @use "ds/_sizes" as *; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs index 5d583a22be..1e9e8d1ce7 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes.create-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss index a90a1e4c6f..aaa2c0f199 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; 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 92f5f6ad03..65c50373c8 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 @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.tokens.themes.theme-selector (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss index 6715ee707c..3fdaa320ca 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 2590122475..5aa617cff4 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.top-toolbar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index 0db0002fbf..23581afc99 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 4814be5042..18ef21db77 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport.scss b/frontend/src/app/main/ui/workspace/viewport.scss index 28940b67a7..bc94d58fd9 100644 --- a/frontend/src/app/main/ui/workspace/viewport.scss +++ b/frontend/src/app/main/ui/workspace/viewport.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 3803577d45..2763174079 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.actions (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs index ca0117bae5..d17eed3ef2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.comments (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.scss b/frontend/src/app/main/ui/workspace/viewport/comments.scss index 95a0354b6b..703b239d53 100644 --- a/frontend/src/app/main/ui/workspace/viewport/comments.scss +++ b/frontend/src/app/main/ui/workspace/viewport/comments.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .workspace-comments-container { inline-size: 100%; diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index 123c0ad387..5a89a97e0a 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.debug (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs index 9bcebcafc3..236ef88e21 100644 --- a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.drawarea "Drawing components." 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 53b9eefe56..931fb6e4df 100644 --- a/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/frame_grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.frame-grid (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index ec2f9b586c..f81a15847f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.gradients "Gradients handlers and renders" diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs index b851d09c4e..d8e1314dc4 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.grid-layout-editor (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss index 26d473689c..008f4e8b0d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; @use "ds/z-index.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index 07cc05f357..69e0df5a31 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.guides (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index d820f01aa2..08798ee497 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.hooks (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index 6ffa2d32de..93b7e7f111 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.interactions "Visually show shape interactions in workspace" diff --git a/frontend/src/app/main/ui/workspace/viewport/outline.cljs b/frontend/src/app/main/ui/workspace/viewport/outline.cljs index 725065d2f8..35a68c3b07 100644 --- a/frontend/src/app/main/ui/workspace/viewport/outline.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/outline.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.outline (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs index fc70c97de6..955147b26c 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.path-actions (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss index ca115480b6..f28915ab99 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs index c756f06e52..583e982e7d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.pixel-overlay (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss index 658fd249a2..8239223684 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .pixel-overlay { inset: 0; diff --git a/frontend/src/app/main/ui/workspace/viewport/presence.cljs b/frontend/src/app/main/ui/workspace/viewport/presence.cljs index dccba55243..ad57321373 100644 --- a/frontend/src/app/main/ui/workspace/viewport/presence.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/presence.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.presence (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/presence.scss b/frontend/src/app/main/ui/workspace/viewport/presence.scss index 22660a9fea..8cc58eab61 100644 --- a/frontend/src/app/main/ui/workspace/viewport/presence.scss +++ b/frontend/src/app/main/ui/workspace/viewport/presence.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs index 4a8c82cd61..5c3206eba9 100644 --- a/frontend/src/app/main/ui/workspace/viewport/rulers.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/rulers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.rulers (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs index caae8cb2c8..aefaf4ddf7 100644 --- a/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/scroll_bars.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.scroll-bars (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 4315ff2d0b..a8008c296d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.selection "Selection handlers component." diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs index 7ad5f0e71d..2683aea88d 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_distances.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.snap-distances (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs index 6bda018c1a..409a6ad153 100644 --- a/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/snap_points.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.snap-points (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index c92c357d25..07d13ddbf0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.top-bar (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 0ef9df6fd6..255ae85080 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; diff --git a/frontend/src/app/main/ui/workspace/viewport/utils.cljs b/frontend/src/app/main/ui/workspace/viewport/utils.cljs index 7858a07f46..add3f045d2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/utils.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.utils (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs index 38349f8f24..7c597fcfd5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.viewport-ref (:require diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index 5521d5bbd6..aa83faf963 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport.widgets (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.scss b/frontend/src/app/main/ui/workspace/viewport/widgets.scss index f2a05d1042..d1f19300e5 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.scss +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index b3ceaec95a..51ebd35b3d 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.viewport-wasm (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.scss b/frontend/src/app/main/ui/workspace/viewport_wasm.scss index 1c721e7cbe..7ead5ecb07 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.scss +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC Sucursal en España SL +// Copyright (c) KALEIDOS SUBSIDIARY SL .viewport { cursor: none; diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs index f4092a4762..c73b0ecf6a 100644 --- a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.ui.workspace.webgl-unavailable-modal (:require-macros [app.main.style :as stl]) diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss index d57571ef0c..ed90d4387b 100644 --- a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. // -// Copyright (c) KALEIDOS INC +// Copyright (c) KALEIDOS SUBSIDIARY SL @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; diff --git a/frontend/src/app/main/worker.cljs b/frontend/src/app/main/worker.cljs index 21032ffd54..bbfec651c6 100644 --- a/frontend/src/app/main/worker.cljs +++ b/frontend/src/app/main/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.main.worker "Interface to communicate with the web worker" diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 8937b4dde6..921cf740b9 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins "RPC for plugins runtime." diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 6582b76e62..643e793787 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.api "RPC for plugins runtime." diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 71e8a0311b..1ee7ee6550 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.comments (:require diff --git a/frontend/src/app/plugins/events.cljs b/frontend/src/app/plugins/events.cljs index 17e50140a1..7b52ba65f1 100644 --- a/frontend/src/app/plugins/events.cljs +++ b/frontend/src/app/plugins/events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.events (:require diff --git a/frontend/src/app/plugins/exports.cljs b/frontend/src/app/plugins/exports.cljs index 0363fdf582..3b804dcea7 100644 --- a/frontend/src/app/plugins/exports.cljs +++ b/frontend/src/app/plugins/exports.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.exports (:require diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 12049611bd..92d9f359e8 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.file (:require diff --git a/frontend/src/app/plugins/fills.cljs b/frontend/src/app/plugins/fills.cljs index 15895ed12b..586eb4d198 100644 --- a/frontend/src/app/plugins/fills.cljs +++ b/frontend/src/app/plugins/fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.fills (:require diff --git a/frontend/src/app/plugins/flags.cljs b/frontend/src/app/plugins/flags.cljs index b804df4d9c..cf9f7be805 100644 --- a/frontend/src/app/plugins/flags.cljs +++ b/frontend/src/app/plugins/flags.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.flags (:require diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 0967edcbec..da3d686705 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.flex (:require diff --git a/frontend/src/app/plugins/fonts.cljs b/frontend/src/app/plugins/fonts.cljs index 2ae009ce9d..3f460a83d3 100644 --- a/frontend/src/app/plugins/fonts.cljs +++ b/frontend/src/app/plugins/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.fonts (:require diff --git a/frontend/src/app/plugins/format.cljs b/frontend/src/app/plugins/format.cljs index 8680a6d8a0..f6f171705e 100644 --- a/frontend/src/app/plugins/format.cljs +++ b/frontend/src/app/plugins/format.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.format (:require diff --git a/frontend/src/app/plugins/gradients.cljs b/frontend/src/app/plugins/gradients.cljs index 809c8c768e..8ad1846621 100644 --- a/frontend/src/app/plugins/gradients.cljs +++ b/frontend/src/app/plugins/gradients.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.gradients (:require diff --git a/frontend/src/app/plugins/grid.cljs b/frontend/src/app/plugins/grid.cljs index 96deccb857..15771ff7b3 100644 --- a/frontend/src/app/plugins/grid.cljs +++ b/frontend/src/app/plugins/grid.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.grid (:require diff --git a/frontend/src/app/plugins/history.cljs b/frontend/src/app/plugins/history.cljs index ea1ee573a1..68eb122506 100644 --- a/frontend/src/app/plugins/history.cljs +++ b/frontend/src/app/plugins/history.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.history (:require diff --git a/frontend/src/app/plugins/image_data.cljs b/frontend/src/app/plugins/image_data.cljs index bcdee4e6eb..97f4a578b3 100644 --- a/frontend/src/app/plugins/image_data.cljs +++ b/frontend/src/app/plugins/image_data.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.image-data (:require diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 5839ed57a4..c7895ef7fc 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.library (:require diff --git a/frontend/src/app/plugins/local_storage.cljs b/frontend/src/app/plugins/local_storage.cljs index 1b24d520ed..fa578fcbb5 100644 --- a/frontend/src/app/plugins/local_storage.cljs +++ b/frontend/src/app/plugins/local_storage.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.local-storage (:require diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index e668bc8756..28a674f111 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.page (:require diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index cbadff48f4..5923289407 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.parser (:require diff --git a/frontend/src/app/plugins/public_utils.cljs b/frontend/src/app/plugins/public_utils.cljs index 3a303fe888..7f34068fcd 100644 --- a/frontend/src/app/plugins/public_utils.cljs +++ b/frontend/src/app/plugins/public_utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.public-utils "Utilities that will be exposed to plugins developers" diff --git a/frontend/src/app/plugins/reflow.cljs b/frontend/src/app/plugins/reflow.cljs index 306fff667b..da92d7c1a2 100644 --- a/frontend/src/app/plugins/reflow.cljs +++ b/frontend/src/app/plugins/reflow.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.reflow "Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 9180463c69..5655deec51 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.register (:require diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index 75658c14fe..56dd051f1e 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.ruler-guides (:require diff --git a/frontend/src/app/plugins/shadows.cljs b/frontend/src/app/plugins/shadows.cljs index 866ae5658d..eb45b16401 100644 --- a/frontend/src/app/plugins/shadows.cljs +++ b/frontend/src/app/plugins/shadows.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.shadows (:require diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 0cccc4724e..d096458b31 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.shape (:require diff --git a/frontend/src/app/plugins/strokes.cljs b/frontend/src/app/plugins/strokes.cljs index 1a717bb21c..7ccfe1bc11 100644 --- a/frontend/src/app/plugins/strokes.cljs +++ b/frontend/src/app/plugins/strokes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.strokes (:require diff --git a/frontend/src/app/plugins/system_events.cljs b/frontend/src/app/plugins/system_events.cljs index 44d75c3752..716852d152 100644 --- a/frontend/src/app/plugins/system_events.cljs +++ b/frontend/src/app/plugins/system_events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.system-events (:require diff --git a/frontend/src/app/plugins/text.cljs b/frontend/src/app/plugins/text.cljs index f8e32458de..901deb9f5b 100644 --- a/frontend/src/app/plugins/text.cljs +++ b/frontend/src/app/plugins/text.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.text (:require diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 64bc69695f..8825775e6f 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.tokens (:require diff --git a/frontend/src/app/plugins/tracks.cljs b/frontend/src/app/plugins/tracks.cljs index 643f6657e5..c5f38664f2 100644 --- a/frontend/src/app/plugins/tracks.cljs +++ b/frontend/src/app/plugins/tracks.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.tracks (:require diff --git a/frontend/src/app/plugins/user.cljs b/frontend/src/app/plugins/user.cljs index 1642759a55..72d146f6c8 100644 --- a/frontend/src/app/plugins/user.cljs +++ b/frontend/src/app/plugins/user.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.user (:require diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 49622d9710..16c93682eb 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.utils "RPC for plugins runtime." diff --git a/frontend/src/app/plugins/viewport.cljs b/frontend/src/app/plugins/viewport.cljs index 333c13a6e1..b09a664103 100644 --- a/frontend/src/app/plugins/viewport.cljs +++ b/frontend/src/app/plugins/viewport.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.plugins.viewport (:require diff --git a/frontend/src/app/rasterizer.cljs b/frontend/src/app/rasterizer.cljs index ed516fc85f..ca62fd508f 100644 --- a/frontend/src/app/rasterizer.cljs +++ b/frontend/src/app/rasterizer.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.rasterizer "A main entry point for the rasterizer process that is diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index 99dfed871a..c2c44dc4bf 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render "The main entry point for UI part needed by the exporter." diff --git a/frontend/src/app/render_wasm.cljs b/frontend/src/app/render_wasm.cljs index 0672c723a8..62c1b760c3 100644 --- a/frontend/src/app/render_wasm.cljs +++ b/frontend/src/app/render_wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm "A WASM based render API" diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 1a4b9b1ba4..80c17e6859 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api "A WASM based render API" diff --git a/frontend/src/app/render_wasm/api/enums.cljs b/frontend/src/app/render_wasm/api/enums.cljs index de132c4638..91ad1325d3 100644 --- a/frontend/src/app/render_wasm/api/enums.cljs +++ b/frontend/src/app/render_wasm/api/enums.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.enums "Binds this build's generated enums into the shared bridge. diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 0697461d8d..c44cc81fa5 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.fonts (:require diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 5a6565bc1e..c27d39d2be 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.texts (:require diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 268dfe4462..862374ec01 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.webgl "WebGL utilities for pixel capture and rendering" diff --git a/frontend/src/app/render_wasm/deserializers.cljs b/frontend/src/app/render_wasm/deserializers.cljs index 813cd9b24a..882c6f22be 100644 --- a/frontend/src/app/render_wasm/deserializers.cljs +++ b/frontend/src/app/render_wasm/deserializers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.deserializers (:require [app.common.data :as d] diff --git a/frontend/src/app/render_wasm/gesture.cljs b/frontend/src/app/render_wasm/gesture.cljs index 2e774e511c..05557e3f42 100644 --- a/frontend/src/app/render_wasm/gesture.cljs +++ b/frontend/src/app/render_wasm/gesture.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.gesture "WASM-linked pointer gestures (interactive transforms, like D&D)") diff --git a/frontend/src/app/render_wasm/performance.cljc b/frontend/src/app/render_wasm/performance.cljc index 8e0ce5a3e7..6825663a9f 100644 --- a/frontend/src/app/render_wasm/performance.cljc +++ b/frontend/src/app/render_wasm/performance.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.performance #?(:cljs (:require-macros [app.render-wasm.performance])) diff --git a/frontend/src/app/render_wasm/rulers_state.cljs b/frontend/src/app/render_wasm/rulers_state.cljs index 231907cec7..4f94047521 100644 --- a/frontend/src/app/render_wasm/rulers_state.cljs +++ b/frontend/src/app/render_wasm/rulers_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.rulers-state "Ruler overlay state derived from the workspace (no WASM/api deps)." diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 19e43e8eea..d351710eaa 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.shape (:require diff --git a/frontend/src/app/render_wasm/svg_fills.cljs b/frontend/src/app/render_wasm/svg_fills.cljs index 725933ef83..829612bb2b 100644 --- a/frontend/src/app/render_wasm/svg_fills.cljs +++ b/frontend/src/app/render_wasm/svg_fills.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.svg-fills (:require diff --git a/frontend/src/app/render_wasm/svg_filters.cljs b/frontend/src/app/render_wasm/svg_filters.cljs index 9f58be9a43..13cc8c00ed 100644 --- a/frontend/src/app/render_wasm/svg_filters.cljs +++ b/frontend/src/app/render_wasm/svg_filters.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.svg-filters (:require diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 241d8960d6..ae914a31d8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.text-editor "Text editor WASM bindings" diff --git a/frontend/src/app/util/array.cljs b/frontend/src/app/util/array.cljs index e598d856d8..15d9dc5f63 100644 --- a/frontend/src/app/util/array.cljs +++ b/frontend/src/app/util/array.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.array "A collection of helpers for work with javascript arrays." diff --git a/frontend/src/app/util/avatars.cljs b/frontend/src/app/util/avatars.cljs index 92a3dc172b..abfbeb4998 100644 --- a/frontend/src/app/util/avatars.cljs +++ b/frontend/src/app/util/avatars.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.avatars (:require diff --git a/frontend/src/app/util/browser_history.js b/frontend/src/app/util/browser_history.js index 3bfdcb49ec..9421aaad2a 100644 --- a/frontend/src/app/util/browser_history.js +++ b/frontend/src/app/util/browser_history.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/frontend/src/app/util/cache.cljs b/frontend/src/app/util/cache.cljs index 6bf0e3f6cd..51939c773f 100644 --- a/frontend/src/app/util/cache.cljs +++ b/frontend/src/app/util/cache.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.cache (:require diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index c2c92c9a76..a23473b964 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.clipboard (:require diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 16476c83a3..2711521bad 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ const maxParseableSize = 16 * 1024 * 1024; diff --git a/frontend/src/app/util/code_beautify.cljs b/frontend/src/app/util/code_beautify.cljs index f00459e479..1ffcee271c 100644 --- a/frontend/src/app/util/code_beautify.cljs +++ b/frontend/src/app/util/code_beautify.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-beautify (:require diff --git a/frontend/src/app/util/code_gen.cljs b/frontend/src/app/util/code_gen.cljs index 97ac9cb91d..ab5b6d540b 100644 --- a/frontend/src/app/util/code_gen.cljs +++ b/frontend/src/app/util/code_gen.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen (:require diff --git a/frontend/src/app/util/code_gen/common.cljs b/frontend/src/app/util/code_gen/common.cljs index 1455768a66..9e239b738c 100644 --- a/frontend/src/app/util/code_gen/common.cljs +++ b/frontend/src/app/util/code_gen/common.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.common (:require diff --git a/frontend/src/app/util/code_gen/markup_html.cljs b/frontend/src/app/util/code_gen/markup_html.cljs index 896c024a00..a2a3ff863d 100644 --- a/frontend/src/app/util/code_gen/markup_html.cljs +++ b/frontend/src/app/util/code_gen/markup_html.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.markup-html (:require diff --git a/frontend/src/app/util/code_gen/markup_svg.cljs b/frontend/src/app/util/code_gen/markup_svg.cljs index 774feae34e..2fa4cfa1b6 100644 --- a/frontend/src/app/util/code_gen/markup_svg.cljs +++ b/frontend/src/app/util/code_gen/markup_svg.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.markup-svg (:require diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs index 85cf87ee0b..2534404394 100644 --- a/frontend/src/app/util/code_gen/style_css.cljs +++ b/frontend/src/app/util/code_gen/style_css.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css (:require 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 5f3741a638..60c47f16a6 100644 --- a/frontend/src/app/util/code_gen/style_css_formats.cljs +++ b/frontend/src/app/util/code_gen/style_css_formats.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css-formats (:require diff --git a/frontend/src/app/util/code_gen/style_css_values.cljs b/frontend/src/app/util/code_gen/style_css_values.cljs index 7bfda75b53..0b1dc28c7f 100644 --- a/frontend/src/app/util/code_gen/style_css_values.cljs +++ b/frontend/src/app/util/code_gen/style_css_values.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-gen.style-css-values diff --git a/frontend/src/app/util/code_highlight.cljs b/frontend/src/app/util/code_highlight.cljs index 70cc198579..5c7c2b3af3 100644 --- a/frontend/src/app/util/code_highlight.cljs +++ b/frontend/src/app/util/code_highlight.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.code-highlight (:require diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index d28c3ccfb6..85ee3ea6ea 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.color "FIXME: this is legacy namespace, all functions of this ns should be diff --git a/frontend/src/app/util/debug.cljs b/frontend/src/app/util/debug.cljs index 8eb7dfaffe..3340e040e2 100644 --- a/frontend/src/app/util/debug.cljs +++ b/frontend/src/app/util/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.debug (:require diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 6a0f514018..27c9e7915b 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.dom (:require diff --git a/frontend/src/app/util/dom/dnd.cljs b/frontend/src/app/util/dom/dnd.cljs index bb647db785..ecff9a4c1d 100644 --- a/frontend/src/app/util/dom/dnd.cljs +++ b/frontend/src/app/util/dom/dnd.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.dom.dnd "Drag & Drop interop helpers." diff --git a/frontend/src/app/util/extends.cljs b/frontend/src/app/util/extends.cljs index afbd8172c9..295838b7d7 100644 --- a/frontend/src/app/util/extends.cljs +++ b/frontend/src/app/util/extends.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.extends "A dummy namespace for closure library and other global objects diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index 48bdb7b6f0..74eab6025a 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.forms (:refer-clojure :exclude [uuid]) diff --git a/frontend/src/app/util/functions.cljs b/frontend/src/app/util/functions.cljs index b45af4b980..fc8e152b75 100644 --- a/frontend/src/app/util/functions.cljs +++ b/frontend/src/app/util/functions.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.functions "A functions helpers" diff --git a/frontend/src/app/util/globals.js b/frontend/src/app/util/globals.js index 2539c7a852..260a930ea4 100644 --- a/frontend/src/app/util/globals.js +++ b/frontend/src/app/util/globals.js @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /* diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 32bb207c3a..62a8b1cac7 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.http "A http client with rx streams interface." diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 4992d61647..cac9757738 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.i18n "A i18n foundation." diff --git a/frontend/src/app/util/json.cljs b/frontend/src/app/util/json.cljs index 4d424b9c99..fca01bc7db 100644 --- a/frontend/src/app/util/json.cljs +++ b/frontend/src/app/util/json.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.json) diff --git a/frontend/src/app/util/kdtree.cljs b/frontend/src/app/util/kdtree.cljs index c6a2ba58c8..a70827a690 100644 --- a/frontend/src/app/util/kdtree.cljs +++ b/frontend/src/app/util/kdtree.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.kdtree "A cljs layer on top of js impl of kdtree located in `kdtree_impl.js`." diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs index c537758477..da4649ee6d 100644 --- a/frontend/src/app/util/keyboard.cljs +++ b/frontend/src/app/util/keyboard.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.keyboard (:require diff --git a/frontend/src/app/util/modules.clj b/frontend/src/app/util/modules.clj index 685d519f82..43823112ad 100644 --- a/frontend/src/app/util/modules.clj +++ b/frontend/src/app/util/modules.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.modules (:refer-clojure :exclude [load resolve])) diff --git a/frontend/src/app/util/modules.cljs b/frontend/src/app/util/modules.cljs index 73c608bec2..ab5c040cc5 100644 --- a/frontend/src/app/util/modules.cljs +++ b/frontend/src/app/util/modules.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.modules (:refer-clojure :exclude [import]) diff --git a/frontend/src/app/util/mouse.cljs b/frontend/src/app/util/mouse.cljs index b5cbfe229b..440fea3a4a 100644 --- a/frontend/src/app/util/mouse.cljs +++ b/frontend/src/app/util/mouse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.mouse (:require diff --git a/frontend/src/app/util/navigator.cljs b/frontend/src/app/util/navigator.cljs index d7c125946d..4dd0ec3bb8 100644 --- a/frontend/src/app/util/navigator.cljs +++ b/frontend/src/app/util/navigator.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.navigator (:require [app.util.globals :as globals])) diff --git a/frontend/src/app/util/object.cljc b/frontend/src/app/util/object.cljc index f2dca618c6..6d1b078df1 100644 --- a/frontend/src/app/util/object.cljc +++ b/frontend/src/app/util/object.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL #_:clj-kondo/ignore (ns app.util.object diff --git a/frontend/src/app/util/path/arc_to_curve.js b/frontend/src/app/util/path/arc_to_curve.js index d1ab5a8e06..28ab2e15c9 100644 --- a/frontend/src/app/util/path/arc_to_curve.js +++ b/frontend/src/app/util/path/arc_to_curve.js @@ -4,7 +4,7 @@ * Is a modified and google closure compatible version of the a2c * functions by https://github.com/fontello/svgpath * - * @author KALEIDOS INC Sucursal en España SL + * @author KALEIDOS SUBSIDIARY SL * @license MIT License */ diff --git a/frontend/src/app/util/path/simplify_curve.cljs b/frontend/src/app/util/path/simplify_curve.cljs index d6e96c4950..e72abc7501 100644 --- a/frontend/src/app/util/path/simplify_curve.cljs +++ b/frontend/src/app/util/path/simplify_curve.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.path.simplify-curve (:require diff --git a/frontend/src/app/util/perf.clj b/frontend/src/app/util/perf.clj index 21e7045892..0b95c972e9 100644 --- a/frontend/src/app/util/perf.clj +++ b/frontend/src/app/util/perf.clj @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.perf "Performance profiling for react components.") diff --git a/frontend/src/app/util/perf.cljs b/frontend/src/app/util/perf.cljs index e04301cd5c..3d934197e6 100644 --- a/frontend/src/app/util/perf.cljs +++ b/frontend/src/app/util/perf.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.perf "Performance profiling for react components." diff --git a/frontend/src/app/util/queue.cljs b/frontend/src/app/util/queue.cljs index 4f534e555b..a44bb7d170 100644 --- a/frontend/src/app/util/queue.cljs +++ b/frontend/src/app/util/queue.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.queue "Low-Level queuing mechanism, mainly used for process thumbnails" diff --git a/frontend/src/app/util/range_tree.js b/frontend/src/app/util/range_tree.js index 60636a1201..ff27839413 100644 --- a/frontend/src/app/util/range_tree.js +++ b/frontend/src/app/util/range_tree.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /* diff --git a/frontend/src/app/util/rxops.cljs b/frontend/src/app/util/rxops.cljs index 292f82bd41..92478a0a3b 100644 --- a/frontend/src/app/util/rxops.cljs +++ b/frontend/src/app/util/rxops.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.rxops (:require diff --git a/frontend/src/app/util/session_state.cljs b/frontend/src/app/util/session_state.cljs index d16f3b6779..ade0091866 100644 --- a/frontend/src/app/util/session_state.cljs +++ b/frontend/src/app/util/session_state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.session-state "Helpers for persisting transient state in browser session storage diff --git a/frontend/src/app/util/shape_icon.cljs b/frontend/src/app/util/shape_icon.cljs index 107a2173c1..e8de4e50b0 100644 --- a/frontend/src/app/util/shape_icon.cljs +++ b/frontend/src/app/util/shape_icon.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.shape-icon (:require diff --git a/frontend/src/app/util/simple_math.cljs b/frontend/src/app/util/simple_math.cljs index 9831ec3d90..4857456900 100644 --- a/frontend/src/app/util/simple_math.cljs +++ b/frontend/src/app/util/simple_math.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.simple-math (:require diff --git a/frontend/src/app/util/sse.cljs b/frontend/src/app/util/sse.cljs index a286083f70..9bd797c5e8 100644 --- a/frontend/src/app/util/sse.cljs +++ b/frontend/src/app/util/sse.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.sse (:require diff --git a/frontend/src/app/util/storage.cljs b/frontend/src/app/util/storage.cljs index a6a971602c..6a3e3e282c 100644 --- a/frontend/src/app/util/storage.cljs +++ b/frontend/src/app/util/storage.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.storage (:require diff --git a/frontend/src/app/util/strings.cljs b/frontend/src/app/util/strings.cljs index 115b24c7e1..83b452b510 100644 --- a/frontend/src/app/util/strings.cljs +++ b/frontend/src/app/util/strings.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.strings (:require diff --git a/frontend/src/app/util/text/content.cljs b/frontend/src/app/util/text/content.cljs index 4c14786a8e..5e6212d3cf 100644 --- a/frontend/src/app/util/text/content.cljs +++ b/frontend/src/app/util/text/content.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content (:require diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs index 19d0293287..89e25bce2b 100644 --- a/frontend/src/app/util/text/content/from_dom.cljs +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.from-dom (:require diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs index 20a2454e1f..e4c50ae22a 100644 --- a/frontend/src/app/util/text/content/styles.cljs +++ b/frontend/src/app/util/text/content/styles.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.styles (:require diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index cd7ab9d5aa..0f7886ef4e 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.content.to-dom (:require diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index 9f8576fcce..226731c552 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text.ui (:require diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs index 4e91ee80ef..fb7b819736 100644 --- a/frontend/src/app/util/text_editor.cljs +++ b/frontend/src/app/util/text_editor.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text-editor "Draft related abstraction functions." diff --git a/frontend/src/app/util/text_position_data.js b/frontend/src/app/util/text_position_data.js index ca0a7e049e..e5b48d0f8a 100644 --- a/frontend/src/app/util/text_position_data.js +++ b/frontend/src/app/util/text_position_data.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ "use strict"; diff --git a/frontend/src/app/util/text_svg_position.cljs b/frontend/src/app/util/text_svg_position.cljs index 772a7f6b8a..13558db6de 100644 --- a/frontend/src/app/util/text_svg_position.cljs +++ b/frontend/src/app/util/text_svg_position.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.text-svg-position (:require diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 07b21ec6cb..75c0837d40 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.theme (:require diff --git a/frontend/src/app/util/thumbnails.cljs b/frontend/src/app/util/thumbnails.cljs index db45329441..c0865d3cd7 100644 --- a/frontend/src/app/util/thumbnails.cljs +++ b/frontend/src/app/util/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.thumbnails (:require diff --git a/frontend/src/app/util/timers.cljs b/frontend/src/app/util/timers.cljs index 752379a39d..be5a13e61c 100644 --- a/frontend/src/app/util/timers.cljs +++ b/frontend/src/app/util/timers.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.timers (:require diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 250b01bced..4c8aaa484b 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.webapi "HTML5 web api helpers." diff --git a/frontend/src/app/util/websocket.cljs b/frontend/src/app/util/websocket.cljs index e192533b76..bac6240860 100644 --- a/frontend/src/app/util/websocket.cljs +++ b/frontend/src/app/util/websocket.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.websocket "A interface to webworkers exposed functionality." diff --git a/frontend/src/app/util/worker.cljs b/frontend/src/app/util/worker.cljs index 58b960c070..f37bb30914 100644 --- a/frontend/src/app/util/worker.cljs +++ b/frontend/src/app/util/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.worker "A lightweight layer on top of webworkers api." diff --git a/frontend/src/app/util/zip.cljs b/frontend/src/app/util/zip.cljs index 2288cbdaae..42bec58c4d 100644 --- a/frontend/src/app/util/zip.cljs +++ b/frontend/src/app/util/zip.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.util.zip "Helpers for make zip file." diff --git a/frontend/src/app/worker.cljs b/frontend/src/app/worker.cljs index 52dd4a2374..da6f0c8a95 100644 --- a/frontend/src/app/worker.cljs +++ b/frontend/src/app/worker.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker (:require diff --git a/frontend/src/app/worker/impl.cljs b/frontend/src/app/worker/impl.cljs index e9d23f3122..5461756ff5 100644 --- a/frontend/src/app/worker/impl.cljs +++ b/frontend/src/app/worker/impl.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.impl "Dispatcher for messages received from the main thread." diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index ba6a49ce1e..564b537278 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.import (:refer-clojure :exclude [resolve]) diff --git a/frontend/src/app/worker/index.cljs b/frontend/src/app/worker/index.cljs index 4d158782d5..1117440563 100644 --- a/frontend/src/app/worker/index.cljs +++ b/frontend/src/app/worker/index.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.index "Page index management within the worker." diff --git a/frontend/src/app/worker/messages.cljs b/frontend/src/app/worker/messages.cljs index 8b846f00cf..2bb1c6a859 100644 --- a/frontend/src/app/worker/messages.cljs +++ b/frontend/src/app/worker/messages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.messages "A lightweight layer on top of webworkers api." diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index 0422d156ef..4fc1039be8 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.selection (:require diff --git a/frontend/src/app/worker/snap.cljs b/frontend/src/app/worker/snap.cljs index f7f3963ed0..afc418b061 100644 --- a/frontend/src/app/worker/snap.cljs +++ b/frontend/src/app/worker/snap.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.snap "Data structure that holds and retrieves the data to make the snaps. diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index 0dd8916cf7..e42a9687b4 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.worker.thumbnails (:require diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index f2ec88f412..057605a5a2 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns debug (:require diff --git a/frontend/src/features.cljs b/frontend/src/features.cljs index d9f387e144..33548e96db 100644 --- a/frontend/src/features.cljs +++ b/frontend/src/features.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; This namespace is only to export the functions for toggle features (ns features diff --git a/frontend/test/frontend_tests/basic_shapes_test.cljs b/frontend/test/frontend_tests/basic_shapes_test.cljs index a114b06868..1082e2435e 100644 --- a/frontend/test/frontend_tests/basic_shapes_test.cljs +++ b/frontend/test/frontend_tests/basic_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.basic-shapes-test (:require diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index 4f04dab929..fb10dfef85 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.code-gen-style-test "Regression tests for the inspect code-generation (HTML/CSS export). diff --git a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs index 9d2089d601..7c13afc4fd 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/nodes.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.nodes "Component-specific operation nodes for the test model. diff --git a/frontend/test/frontend_tests/composable_tests/comp/setups.cljs b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs index 056da9c450..9c40acb214 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/setups.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/setups.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.setups "Component-specific setups for the test model: named functions that build an diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs index 11821550b0..373fb9408e 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.comp.sync-test "Component-behaviour cases authored on the composable test model, run against diff --git a/frontend/test/frontend_tests/composable_tests/core.cljs b/frontend/test/frontend_tests/composable_tests/core.cljs index 6c1221658b..ba0054ee2f 100644 --- a/frontend/test/frontend_tests/composable_tests/core.cljs +++ b/frontend/test/frontend_tests/composable_tests/core.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.core "The domain-agnostic ENGINE of the composable test model (see diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 2d799246e5..905604466c 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.composable-tests.interpreter "FRONTEND interpreter + test-facing `check` for the composable test model. diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs index c2aee4a298..c404a86fcd 100644 --- a/frontend/test/frontend_tests/copy_as_svg_test.cljs +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.copy-as-svg-test "Regression tests for the Copy as SVG action (issue #838). diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index e73ebb889a..001ea2f924 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.dashboard-test (:require diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index d4ae9edea8..d6b4f36a86 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.exports-assets-test (:require diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs index 2c59912c70..f21a438542 100644 --- a/frontend/test/frontend_tests/data/nitrate_test.cljs +++ b/frontend/test/frontend_tests/data/nitrate_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.nitrate-test (:require diff --git a/frontend/test/frontend_tests/data/profile_test.cljs b/frontend/test/frontend_tests/data/profile_test.cljs index 97e62f63d6..f24c2d87f2 100644 --- a/frontend/test/frontend_tests/data/profile_test.cljs +++ b/frontend/test/frontend_tests/data/profile_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.profile-test (:require diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs index 6b0f99e742..13066fbe9e 100644 --- a/frontend/test/frontend_tests/data/repo_test.cljs +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.repo-test (:require diff --git a/frontend/test/frontend_tests/data/store_test.cljs b/frontend/test/frontend_tests/data/store_test.cljs index 56d29fec85..89fd656109 100644 --- a/frontend/test/frontend_tests/data/store_test.cljs +++ b/frontend/test/frontend_tests/data/store_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.store-test "Unit tests for app.main.store. diff --git a/frontend/test/frontend_tests/data/uploads_test.cljs b/frontend/test/frontend_tests/data/uploads_test.cljs index fe1ea50ecc..0a7806b5cc 100644 --- a/frontend/test/frontend_tests/data/uploads_test.cljs +++ b/frontend/test/frontend_tests/data/uploads_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.uploads-test "Integration tests for the generic chunked-upload logic in diff --git a/frontend/test/frontend_tests/data/viewer_test.cljs b/frontend/test/frontend_tests/data/viewer_test.cljs index 5125ca9674..bc0d35a998 100644 --- a/frontend/test/frontend_tests/data/viewer_test.cljs +++ b/frontend/test/frontend_tests/data/viewer_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.viewer-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_colors_test.cljs b/frontend/test/frontend_tests/data/workspace_colors_test.cljs index 7141ed10c0..2ae0150078 100644 --- a/frontend/test/frontend_tests/data/workspace_colors_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_colors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-colors-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_comments_test.cljs b/frontend/test/frontend_tests/data/workspace_comments_test.cljs index 8e653875c1..f5428d6c97 100644 --- a/frontend/test/frontend_tests/data/workspace_comments_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_comments_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-comments-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_interactions_test.cljs b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs index 2d21651991..d15602816d 100644 --- a/frontend/test/frontend_tests/data/workspace_interactions_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-interactions-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs index 7f16894984..76b8fbd948 100644 --- a/frontend/test/frontend_tests/data/workspace_mcp_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_mcp_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-mcp-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_media_test.cljs b/frontend/test/frontend_tests/data/workspace_media_test.cljs index 43fb802c88..cabdd54abb 100644 --- a/frontend/test/frontend_tests/data/workspace_media_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_media_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-media-test "Integration tests for the chunked-upload logic in diff --git a/frontend/test/frontend_tests/data/workspace_pages_test.cljs b/frontend/test/frontend_tests/data/workspace_pages_test.cljs index f5d2d9d2eb..8ec11e41b4 100644 --- a/frontend/test/frontend_tests/data/workspace_pages_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_pages_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-pages-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs index 4cfabcdce2..45eaff8d75 100644 --- a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-path-edition-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs index efbf9234e9..7d3b29756d 100644 --- a/frontend/test/frontend_tests/data/workspace_reflow_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_reflow_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-reflow-test "Tests the reflow tasks the layout and text pipelines feed to diff --git a/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs index efafa722ff..3d873a5260 100644 --- a/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_shortcuts_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-shortcuts-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_stats_test.cljs b/frontend/test/frontend_tests/data/workspace_stats_test.cljs index c4ac483e5f..d3df3853f6 100644 --- a/frontend/test/frontend_tests/data/workspace_stats_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_stats_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-stats-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 5822b571cf..fc8f08cb79 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-texts-test (:require diff --git a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs index 6aaa09e283..d5360b60b6 100644 --- a/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_thumbnails_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.data.workspace-thumbnails-test (:require diff --git a/frontend/test/frontend_tests/errors_test.cljs b/frontend/test/frontend_tests/errors_test.cljs index bff54bc113..ad93213f0b 100644 --- a/frontend/test/frontend_tests/errors_test.cljs +++ b/frontend/test/frontend_tests/errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.errors-test (:require diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index e2de0217e0..c9a772321e 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.fonts-test (:require diff --git a/frontend/test/frontend_tests/helpers/events.cljs b/frontend/test/frontend_tests/helpers/events.cljs index 5fe99cad50..0cce822dbd 100644 --- a/frontend/test/frontend_tests/helpers/events.cljs +++ b/frontend/test/frontend_tests/helpers/events.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.events (:require diff --git a/frontend/test/frontend_tests/helpers/http.cljs b/frontend/test/frontend_tests/helpers/http.cljs index 00019939d1..e935073da5 100644 --- a/frontend/test/frontend_tests/helpers/http.cljs +++ b/frontend/test/frontend_tests/helpers/http.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.http "Helpers for intercepting and mocking the global `fetch` function in diff --git a/frontend/test/frontend_tests/helpers/libraries.cljs b/frontend/test/frontend_tests/helpers/libraries.cljs index 643ad0bb1e..8508a3a065 100644 --- a/frontend/test/frontend_tests/helpers/libraries.cljs +++ b/frontend/test/frontend_tests/helpers/libraries.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.libraries (:require diff --git a/frontend/test/frontend_tests/helpers/mock.cljc b/frontend/test/frontend_tests/helpers/mock.cljc index 50f4d49d29..d342c80002 100644 --- a/frontend/test/frontend_tests/helpers/mock.cljc +++ b/frontend/test/frontend_tests/helpers/mock.cljc @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.mock "Async-first mocking primitives for ClojureScript tests. diff --git a/frontend/test/frontend_tests/helpers/pages.cljs b/frontend/test/frontend_tests/helpers/pages.cljs index 874e0663c5..6ac59e6259 100644 --- a/frontend/test/frontend_tests/helpers/pages.cljs +++ b/frontend/test/frontend_tests/helpers/pages.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.pages (:require diff --git a/frontend/test/frontend_tests/helpers/state.cljs b/frontend/test/frontend_tests/helpers/state.cljs index eb7914b72c..8f891de222 100644 --- a/frontend/test/frontend_tests/helpers/state.cljs +++ b/frontend/test/frontend_tests/helpers/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.state (:require diff --git a/frontend/test/frontend_tests/helpers/wasm.cljs b/frontend/test/frontend_tests/helpers/wasm.cljs index 1b05e203ac..a235d83915 100644 --- a/frontend/test/frontend_tests/helpers/wasm.cljs +++ b/frontend/test/frontend_tests/helpers/wasm.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers.wasm "Test helpers for mocking WASM API boundary functions. diff --git a/frontend/test/frontend_tests/helpers_shapes_test.cljs b/frontend/test/frontend_tests/helpers_shapes_test.cljs index 653f7fd4e1..62fef583a2 100644 --- a/frontend/test/frontend_tests/helpers_shapes_test.cljs +++ b/frontend/test/frontend_tests/helpers_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.helpers-shapes-test (:require diff --git a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs index 9fd0dbce46..476d549c5d 100644 --- a/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs +++ b/frontend/test/frontend_tests/logic/comp_remove_swap_slots_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.comp-remove-swap-slots-test (:require [app.common.test-helpers.components :as cthc] diff --git a/frontend/test/frontend_tests/logic/components_and_tokens.cljs b/frontend/test/frontend_tests/logic/components_and_tokens.cljs index 16370d6e5c..1be799f880 100644 --- a/frontend/test/frontend_tests/logic/components_and_tokens.cljs +++ b/frontend/test/frontend_tests/logic/components_and_tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.components-and-tokens (:require [app.common.geom.point :as geom] diff --git a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs index 2692d17593..7939b903df 100644 --- a/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs +++ b/frontend/test/frontend_tests/logic/copying_and_duplicating_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.copying-and-duplicating-test (:require diff --git a/frontend/test/frontend_tests/logic/frame_guides_test.cljs b/frontend/test/frontend_tests/logic/frame_guides_test.cljs index 6f72b68c93..b3c670c7c7 100644 --- a/frontend/test/frontend_tests/logic/frame_guides_test.cljs +++ b/frontend/test/frontend_tests/logic/frame_guides_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.frame-guides-test (:require [app.common.test-helpers.compositions :as ctho] diff --git a/frontend/test/frontend_tests/logic/groups_test.cljs b/frontend/test/frontend_tests/logic/groups_test.cljs index 400a1e0283..600e076096 100644 --- a/frontend/test/frontend_tests/logic/groups_test.cljs +++ b/frontend/test/frontend_tests/logic/groups_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.groups-test (:require [app.common.data :as d] diff --git a/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs b/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs index 0d182e5fd3..570e2e69b3 100644 --- a/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs +++ b/frontend/test/frontend_tests/logic/nudge_selected_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.nudge-selected-shapes-test "Regression tests for the keyboard-nudge transform stream. diff --git a/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs b/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs index a0a91ea0c2..f85547ac43 100644 --- a/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs +++ b/frontend/test/frontend_tests/logic/pasting_in_containers_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.pasting-in-containers-test (:require [app.common.test-helpers.components :as cthc] diff --git a/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs index 40c174d9cf..dacbb4e15f 100644 --- a/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs +++ b/frontend/test/frontend_tests/logic/sidebar_transform_coalescing_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.sidebar-transform-coalescing-test "Regression tests for the sidebar measures panel transform coalescing diff --git a/frontend/test/frontend_tests/logic/update_position_test.cljs b/frontend/test/frontend_tests/logic/update_position_test.cljs index 93b51f95d6..119f887aa9 100644 --- a/frontend/test/frontend_tests/logic/update_position_test.cljs +++ b/frontend/test/frontend_tests/logic/update_position_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.logic.update-position-test (:require diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 207b295a11..e28830f0f0 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.main-errors-test "Unit tests for app.main.errors. diff --git a/frontend/test/frontend_tests/plugins/comments_test.cljs b/frontend/test/frontend_tests/plugins/comments_test.cljs index 519c8581fa..fa9849e6ac 100644 --- a/frontend/test/frontend_tests/plugins/comments_test.cljs +++ b/frontend/test/frontend_tests/plugins/comments_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.comments-test (:require diff --git a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs index e6c5d2d629..a2c86cb346 100644 --- a/frontend/test/frontend_tests/plugins/context_shapes_test.cljs +++ b/frontend/test/frontend_tests/plugins/context_shapes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.context-shapes-test (:require diff --git a/frontend/test/frontend_tests/plugins/file_test.cljs b/frontend/test/frontend_tests/plugins/file_test.cljs index 8d7c6b0d12..e6f2780035 100644 --- a/frontend/test/frontend_tests/plugins/file_test.cljs +++ b/frontend/test/frontend_tests/plugins/file_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.file-test (:require diff --git a/frontend/test/frontend_tests/plugins/format_test.cljs b/frontend/test/frontend_tests/plugins/format_test.cljs index f426941acb..f658c1e64d 100644 --- a/frontend/test/frontend_tests/plugins/format_test.cljs +++ b/frontend/test/frontend_tests/plugins/format_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.format-test (:require diff --git a/frontend/test/frontend_tests/plugins/grid_test.cljs b/frontend/test/frontend_tests/plugins/grid_test.cljs index 035153ccde..dc5c2ff8df 100644 --- a/frontend/test/frontend_tests/plugins/grid_test.cljs +++ b/frontend/test/frontend_tests/plugins/grid_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.grid-test (:require diff --git a/frontend/test/frontend_tests/plugins/interactions_test.cljs b/frontend/test/frontend_tests/plugins/interactions_test.cljs index 20c70fffda..115786e788 100644 --- a/frontend/test/frontend_tests/plugins/interactions_test.cljs +++ b/frontend/test/frontend_tests/plugins/interactions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.interactions-test (:require diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 47d5869b1a..e359fd852e 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.library-test (:require diff --git a/frontend/test/frontend_tests/plugins/local_storage_test.cljs b/frontend/test/frontend_tests/plugins/local_storage_test.cljs index f1f5117ed2..f07aa9355a 100644 --- a/frontend/test/frontend_tests/plugins/local_storage_test.cljs +++ b/frontend/test/frontend_tests/plugins/local_storage_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.local-storage-test (:require diff --git a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs index 6f3651e81a..9de486f877 100644 --- a/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_active_validation_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.page-active-validation-test "Tests for the guard that prevents plugins from modifying shapes/properties that diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index d29149e846..d48318b77b 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.page-test (:require diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs index e6f78c185f..62f5bb26ed 100644 --- a/frontend/test/frontend_tests/plugins/parser_test.cljs +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.parser-test (:require diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs index c854e4942c..4ce610848c 100644 --- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.shape-bugfixes-test (:require diff --git a/frontend/test/frontend_tests/plugins/text_test.cljs b/frontend/test/frontend_tests/plugins/text_test.cljs index 1b1decdcdb..3f4d96be9d 100644 --- a/frontend/test/frontend_tests/plugins/text_test.cljs +++ b/frontend/test/frontend_tests/plugins/text_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.text-test (:require diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 5c6122f346..c501b9fb66 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.tokens-test (:require diff --git a/frontend/test/frontend_tests/plugins/utils_test.cljs b/frontend/test/frontend_tests/plugins/utils_test.cljs index 3c731055ea..7238732279 100644 --- a/frontend/test/frontend_tests/plugins/utils_test.cljs +++ b/frontend/test/frontend_tests/plugins/utils_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.plugins.utils-test (:require diff --git a/frontend/test/frontend_tests/plugins/value_objects_test.cljs b/frontend/test/frontend_tests/plugins/value_objects_test.cljs index e827e0cfc1..1e88af8ac4 100644 --- a/frontend/test/frontend_tests/plugins/value_objects_test.cljs +++ b/frontend/test/frontend_tests/plugins/value_objects_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; Value-object proxies (shadows, exports, grid tracks) returned by the Plugin ;; API. `format-shadows`, `format-exports` and `format-tracks` hand back live diff --git a/frontend/test/frontend_tests/render_dimensions_test.cljs b/frontend/test/frontend_tests/render_dimensions_test.cljs index d3773f6b36..792b3345b6 100644 --- a/frontend/test/frontend_tests/render_dimensions_test.cljs +++ b/frontend/test/frontend_tests/render_dimensions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-dimensions-test (:require diff --git a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs index cf9e68670a..32ea3a8756 100644 --- a/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/process_objects_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.process-objects-test "Unit tests for wasm.api/process-objects. diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs index a6756b8951..f9681063b0 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.text-editor-apply-styles-test "Unit tests for applying styles to a selection of text spans. diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs index 09203f4bae..cac8dc524a 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.text-editor-caret-color-test "Unit tests for the text-editor caret color resolution. diff --git a/frontend/test/frontend_tests/setup_test.cljs b/frontend/test/frontend_tests/setup_test.cljs index e456791e32..f9b4b37621 100644 --- a/frontend/test/frontend_tests/setup_test.cljs +++ b/frontend/test/frontend_tests/setup_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.setup-test (:require diff --git a/frontend/test/frontend_tests/svg_fills_test.cljs b/frontend/test/frontend_tests/svg_fills_test.cljs index f39adbd49e..1a0d4b66d1 100644 --- a/frontend/test/frontend_tests/svg_fills_test.cljs +++ b/frontend/test/frontend_tests/svg_fills_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.svg-fills-test (:require diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs index 2656a00c8d..80fd1b250a 100644 --- a/frontend/test/frontend_tests/svg_filters_test.cljs +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.svg-filters-test (:require diff --git a/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs b/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs index 3a7829f450..016352974c 100644 --- a/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs +++ b/frontend/test/frontend_tests/text_editor_paste_guard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.text-editor-paste-guard-test "Regression tests for the Cannot read properties of undefined diff --git a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs index 47435af432..86da4c21c0 100644 --- a/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs +++ b/frontend/test/frontend_tests/tokens/copy_paste_props_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.copy-paste-props-test (:require diff --git a/frontend/test/frontend_tests/tokens/helpers/state.cljs b/frontend/test/frontend_tests/tokens/helpers/state.cljs index ce15579649..5e0f549d94 100644 --- a/frontend/test/frontend_tests/tokens/helpers/state.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/state.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.helpers.state (:require diff --git a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs index 4027be3787..f5a0699c85 100644 --- a/frontend/test/frontend_tests/tokens/helpers/tokens.cljs +++ b/frontend/test/frontend_tests/tokens/helpers/tokens.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.helpers.tokens (:require diff --git a/frontend/test/frontend_tests/tokens/import_export_test.cljs b/frontend/test/frontend_tests/tokens/import_export_test.cljs index e05cefb0bd..982c6a7cae 100644 --- a/frontend/test/frontend_tests/tokens/import_export_test.cljs +++ b/frontend/test/frontend_tests/tokens/import_export_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.import-export-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs index 06e468c4ee..bd711f6b12 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_actions_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-actions-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs index 24a9ad6ca8..54badf55a2 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_data_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-data-test (:require diff --git a/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs b/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs index 18aca80efa..83bec23824 100644 --- a/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs +++ b/frontend/test/frontend_tests/tokens/logic/token_remapping_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.logic.token-remapping-test (:require diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 220dd1f36b..2ad4599656 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.style-dictionary-test (:require diff --git a/frontend/test/frontend_tests/tokens/token_errors_test.cljs b/frontend/test/frontend_tests/tokens/token_errors_test.cljs index bb8c581dbb..02fcbf3201 100644 --- a/frontend/test/frontend_tests/tokens/token_errors_test.cljs +++ b/frontend/test/frontend_tests/tokens/token_errors_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.token-errors-test (:require diff --git a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs index cddd8e17d7..679592d6bb 100644 --- a/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs +++ b/frontend/test/frontend_tests/tokens/workspace_tokens_remap_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.tokens.workspace-tokens-remap-test (:require diff --git a/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs b/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs index 34347ffa26..1c55366c13 100644 --- a/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs +++ b/frontend/test/frontend_tests/ui/colorpicker_token_set_order_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.colorpicker-token-set-order-test (:require diff --git a/frontend/test/frontend_tests/ui/comments_clustering_test.cljs b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs index eeaa7d1a97..3514915a28 100644 --- a/frontend/test/frontend_tests/ui/comments_clustering_test.cljs +++ b/frontend/test/frontend_tests/ui/comments_clustering_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.comments-clustering-test (:require diff --git a/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs index 3f842df31a..840e1e2ab0 100644 --- a/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs +++ b/frontend/test/frontend_tests/ui/comments_position_modifier_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.comments-position-modifier-test (:require diff --git a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs index a4e8ce7ef2..4fe508ce56 100644 --- a/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs +++ b/frontend/test/frontend_tests/ui/ds_controls_numeric_input_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.ds-controls-numeric-input-test (:require diff --git a/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs b/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs index c20deaca39..4d820ed2b7 100644 --- a/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs +++ b/frontend/test/frontend_tests/ui/gradient_handlers_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.gradient-handlers-test (:require diff --git a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs index 19c4278057..10b64d95b1 100644 --- a/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs +++ b/frontend/test/frontend_tests/ui/layout_container_multiple_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.layout-container-multiple-test (:require diff --git a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs index 8214572f59..e4a7b81903 100644 --- a/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs +++ b/frontend/test/frontend_tests/ui/measures_menu_props_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.measures-menu-props-test (:require diff --git a/frontend/test/frontend_tests/ui/routes_test.cljs b/frontend/test/frontend_tests/ui/routes_test.cljs index ad52f2fb05..e85246d954 100644 --- a/frontend/test/frontend_tests/ui/routes_test.cljs +++ b/frontend/test/frontend_tests/ui/routes_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.routes-test (:require diff --git a/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs b/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs index ac305b81fb..29d4f05199 100644 --- a/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_password_schema_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.ui.settings-password-schema-test (:require diff --git a/frontend/test/frontend_tests/util/dom/dnd_test.cljs b/frontend/test/frontend_tests/util/dom/dnd_test.cljs index 60c7ad725d..37e45c1fb1 100644 --- a/frontend/test/frontend_tests/util/dom/dnd_test.cljs +++ b/frontend/test/frontend_tests/util/dom/dnd_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util.dom.dnd-test (:require diff --git a/frontend/test/frontend_tests/util_clipboard_test.cljs b/frontend/test/frontend_tests/util_clipboard_test.cljs index 4c664786a1..aa6b2742cb 100644 --- a/frontend/test/frontend_tests/util_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_clipboard_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-clipboard-test "Regression tests for `to-clipboard-multi` (issue #10596). diff --git a/frontend/test/frontend_tests/util_object_test.cljs b/frontend/test/frontend_tests/util_object_test.cljs index 1238c33e10..68d982fda2 100644 --- a/frontend/test/frontend_tests/util_object_test.cljs +++ b/frontend/test/frontend_tests/util_object_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-object-test (:require diff --git a/frontend/test/frontend_tests/util_range_tree_test.cljs b/frontend/test/frontend_tests/util_range_tree_test.cljs index 52e112d15e..c0fdd5f8e0 100644 --- a/frontend/test/frontend_tests/util_range_tree_test.cljs +++ b/frontend/test/frontend_tests/util_range_tree_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-range-tree-test (:require diff --git a/frontend/test/frontend_tests/util_simple_math_test.cljs b/frontend/test/frontend_tests/util_simple_math_test.cljs index c06f509d45..827fe7b18b 100644 --- a/frontend/test/frontend_tests/util_simple_math_test.cljs +++ b/frontend/test/frontend_tests/util_simple_math_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-simple-math-test (:require diff --git a/frontend/test/frontend_tests/util_text_editor_test.cljs b/frontend/test/frontend_tests/util_text_editor_test.cljs index 0823a77d10..bd38a36e9e 100644 --- a/frontend/test/frontend_tests/util_text_editor_test.cljs +++ b/frontend/test/frontend_tests/util_text_editor_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-text-editor-test (:require diff --git a/frontend/test/frontend_tests/util_webapi_test.cljs b/frontend/test/frontend_tests/util_webapi_test.cljs index 1307526ffb..e6ca55d1d6 100644 --- a/frontend/test/frontend_tests/util_webapi_test.cljs +++ b/frontend/test/frontend_tests/util_webapi_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-webapi-test (:require diff --git a/frontend/test/frontend_tests/util_zip_test.cljs b/frontend/test/frontend_tests/util_zip_test.cljs index 7cf2d6f609..a9fbc658f7 100644 --- a/frontend/test/frontend_tests/util_zip_test.cljs +++ b/frontend/test/frontend_tests/util_zip_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.util-zip-test (:require diff --git a/frontend/test/frontend_tests/worker_snap_test.cljs b/frontend/test/frontend_tests/worker_snap_test.cljs index 38739a2ae9..c6b1f38a41 100644 --- a/frontend/test/frontend_tests/worker_snap_test.cljs +++ b/frontend/test/frontend_tests/worker_snap_test.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.worker-snap-test (:require diff --git a/frontend/text-editor/src/editor/Event.js b/frontend/text-editor/src/editor/Event.js index 7df7ba474f..86c4661882 100644 --- a/frontend/text-editor/src/editor/Event.js +++ b/frontend/text-editor/src/editor/Event.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index e2c320cf43..b02caa76e5 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import clipboard from "./clipboard/index.js"; diff --git a/frontend/text-editor/src/editor/clipboard/copy.js b/frontend/text-editor/src/editor/clipboard/copy.js index 9ec2db74d0..6e93c50382 100644 --- a/frontend/text-editor/src/editor/clipboard/copy.js +++ b/frontend/text-editor/src/editor/clipboard/copy.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/clipboard/cut.js b/frontend/text-editor/src/editor/clipboard/cut.js index dd0a1d0d36..55b212d90a 100644 --- a/frontend/text-editor/src/editor/clipboard/cut.js +++ b/frontend/text-editor/src/editor/clipboard/cut.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/clipboard/index.js b/frontend/text-editor/src/editor/clipboard/index.js index 89a8a7d0f5..922ba6b9cd 100644 --- a/frontend/text-editor/src/editor/clipboard/index.js +++ b/frontend/text-editor/src/editor/clipboard/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { copy } from "./copy.js"; diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index ea699349a9..76cc63f919 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/commands/deleteByCut.js b/frontend/text-editor/src/editor/commands/deleteByCut.js index 0c3ff6fffd..fba52207ce 100644 --- a/frontend/text-editor/src/editor/commands/deleteByCut.js +++ b/frontend/text-editor/src/editor/commands/deleteByCut.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/deleteContentBackward.js b/frontend/text-editor/src/editor/commands/deleteContentBackward.js index 3d4ed0a729..036e2696e6 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentBackward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentBackward.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/deleteContentForward.js b/frontend/text-editor/src/editor/commands/deleteContentForward.js index add99a0507..0fa7da5364 100644 --- a/frontend/text-editor/src/editor/commands/deleteContentForward.js +++ b/frontend/text-editor/src/editor/commands/deleteContentForward.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/index.js b/frontend/text-editor/src/editor/commands/index.js index ed04c36a10..df1af72c94 100644 --- a/frontend/text-editor/src/editor/commands/index.js +++ b/frontend/text-editor/src/editor/commands/index.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { insertText } from "./insertText.js"; diff --git a/frontend/text-editor/src/editor/commands/insertParagraph.js b/frontend/text-editor/src/editor/commands/insertParagraph.js index cbd70cb651..6c3246a2ca 100644 --- a/frontend/text-editor/src/editor/commands/insertParagraph.js +++ b/frontend/text-editor/src/editor/commands/insertParagraph.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/commands/insertText.js b/frontend/text-editor/src/editor/commands/insertText.js index 8d1b87f892..6a84fc406d 100644 --- a/frontend/text-editor/src/editor/commands/insertText.js +++ b/frontend/text-editor/src/editor/commands/insertText.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/content/Text.js b/frontend/text-editor/src/editor/content/Text.js index 76fe1f5ae9..0e453c44f8 100644 --- a/frontend/text-editor/src/editor/content/Text.js +++ b/frontend/text-editor/src/editor/content/Text.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 0cb742992d..a16036e8a0 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createTextSpan, isLikeTextSpan } from "./TextSpan.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Editor.js b/frontend/text-editor/src/editor/content/dom/Editor.js index 9d87ec3414..9339d1e74e 100644 --- a/frontend/text-editor/src/editor/content/dom/Editor.js +++ b/frontend/text-editor/src/editor/content/dom/Editor.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { isElement } from "./Element.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Element.js b/frontend/text-editor/src/editor/content/dom/Element.js index 40c9922684..3b5ecb6f12 100644 --- a/frontend/text-editor/src/editor/content/dom/Element.js +++ b/frontend/text-editor/src/editor/content/dom/Element.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { setStyles } from "./Style.js"; diff --git a/frontend/text-editor/src/editor/content/dom/LineBreak.js b/frontend/text-editor/src/editor/content/dom/LineBreak.js index e2bae6dc6a..c49eeee7f1 100644 --- a/frontend/text-editor/src/editor/content/dom/LineBreak.js +++ b/frontend/text-editor/src/editor/content/dom/LineBreak.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ export const TAG = "BR"; diff --git a/frontend/text-editor/src/editor/content/dom/Paragraph.js b/frontend/text-editor/src/editor/content/dom/Paragraph.js index 465aaea95b..f13c659759 100644 --- a/frontend/text-editor/src/editor/content/dom/Paragraph.js +++ b/frontend/text-editor/src/editor/content/dom/Paragraph.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/content/dom/Root.js b/frontend/text-editor/src/editor/content/dom/Root.js index f2dc401c90..1db5dac701 100644 --- a/frontend/text-editor/src/editor/content/dom/Root.js +++ b/frontend/text-editor/src/editor/content/dom/Root.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createRandomId, createElement, isElement } from "./Element.js"; diff --git a/frontend/text-editor/src/editor/content/dom/Style.js b/frontend/text-editor/src/editor/content/dom/Style.js index bfadad1a6e..ad30de5c0e 100644 --- a/frontend/text-editor/src/editor/content/dom/Style.js +++ b/frontend/text-editor/src/editor/content/dom/Style.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import StyleDeclaration from "../../controllers/StyleDeclaration.js"; diff --git a/frontend/text-editor/src/editor/content/dom/TextNode.js b/frontend/text-editor/src/editor/content/dom/TextNode.js index 24b2fe93c3..300d8496db 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNode.js +++ b/frontend/text-editor/src/editor/content/dom/TextNode.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { isTextSpan } from "./TextSpan.js"; diff --git a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js index 62ae2476cb..e10925d1a7 100644 --- a/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js +++ b/frontend/text-editor/src/editor/content/dom/TextNodeIterator.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { SafeGuard } from "../../controllers/SafeGuard.js"; diff --git a/frontend/text-editor/src/editor/content/dom/TextSpan.js b/frontend/text-editor/src/editor/content/dom/TextSpan.js index 1c3a25cd25..cf24f61f6b 100644 --- a/frontend/text-editor/src/editor/content/dom/TextSpan.js +++ b/frontend/text-editor/src/editor/content/dom/TextSpan.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { diff --git a/frontend/text-editor/src/editor/controllers/ChangeController.js b/frontend/text-editor/src/editor/controllers/ChangeController.js index babfcaf97e..73ddd3fd9e 100644 --- a/frontend/text-editor/src/editor/controllers/ChangeController.js +++ b/frontend/text-editor/src/editor/controllers/ChangeController.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 4ba0b4655a..494a500e80 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ import { createLineBreak, isLineBreak } from "../content/dom/LineBreak.js"; diff --git a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js index 4c4a0db691..7a9efaf76c 100644 --- a/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js +++ b/frontend/text-editor/src/editor/debug/SelectionControllerDebug.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/frontend/text-editor/src/editor/layout/LayoutType.js b/frontend/text-editor/src/editor/layout/LayoutType.js index 03515bdc79..aca12249f7 100644 --- a/frontend/text-editor/src/editor/layout/LayoutType.js +++ b/frontend/text-editor/src/editor/layout/LayoutType.js @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * - * Copyright (c) KALEIDOS INC Sucursal en España SL + * Copyright (c) KALEIDOS SUBSIDIARY SL */ /** diff --git a/library/README.md b/library/README.md index 9d76a1e3eb..d381e818b0 100644 --- a/library/README.md +++ b/library/README.md @@ -75,6 +75,6 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` diff --git a/library/src/lib/builder.cljs b/library/src/lib/builder.cljs index ba279feb63..e860adcd81 100644 --- a/library/src/lib/builder.cljs +++ b/library/src/lib/builder.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns lib.builder (:require diff --git a/library/src/lib/export.cljs b/library/src/lib/export.cljs index 7ce561fa12..bfb9a2c778 100644 --- a/library/src/lib/export.cljs +++ b/library/src/lib/export.cljs @@ -2,7 +2,7 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) KALEIDOS INC Sucursal en España SL +;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns lib.export "A .penpot export implementation" diff --git a/manage.sh b/manage.sh index be6cc078b1..ca7013db9f 100755 --- a/manage.sh +++ b/manage.sh @@ -1146,7 +1146,7 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL EOF } diff --git a/plugins/README.md b/plugins/README.md index 9a224135f1..16f8bb92f7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -92,7 +92,7 @@ This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. -Copyright (c) KALEIDOS INC Sucursal en España SL +Copyright (c) KALEIDOS SUBSIDIARY SL ``` Penpot is a Kaleidos’ [open source project](https://kaleidos.net/) diff --git a/scripts/replace-copyright.sh b/scripts/replace-copyright.sh new file mode 100755 index 0000000000..f910d0a20d --- /dev/null +++ b/scripts/replace-copyright.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# replace-copyright.sh — Replace KALEIDOS INC copyright references with KALEIDOS SUBSIDIARY SL +# +# Usage: +# scripts/replace-copyright.sh # Execute replacements +# scripts/replace-copyright.sh --dry-run # Simulate without modifying files +# scripts/replace-copyright.sh --help # Show help +# + +set -euo pipefail + +# ── Configuration ────────────────────────────────────────────────────────────── + +EXTENSIONS="clj|cljs|cljc|scss|js|jsx|mdx|md|sh|py|java" + +# ── Colors ───────────────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# ── Globals ──────────────────────────────────────────────────────────────────── + +DRY_RUN=false +TOTAL_FILES=0 +TOTAL_REPLACEMENTS=0 +declare -A MODULE_FILES +declare -A MODULE_REPLACEMENTS + +# ── Functions ────────────────────────────────────────────────────────────────── + +usage() { + cat <&2 +} + +log_dry() { + echo -e "${YELLOW}[DRY-RUN]${NC} $*" +} + +get_module() { + local file="$1" + local first_dir + first_dir=$(echo "$file" | cut -d'/' -f1) + if [[ "$first_dir" == "$file" || "$first_dir" == "." ]]; then + echo "root" + else + echo "$first_dir" + fi +} + +count_kaleidos() { + local file="$1" + rg -c "KALEIDOS INC" "$file" 2>/dev/null || echo "0" +} + +replace_in_file() { + local file="$1" + local matches replacements module + + matches=$(count_kaleidos "$file") + + if [[ "$matches" -eq 0 ]]; then + return + fi + + if [[ "$DRY_RUN" == false ]]; then + # Order matters: non-accented first, then full, then truncated + perl -pi -e 's/KALEIDOS INC Sucursal en Espana SL/KALEIDOS SUBSIDIARY SL/g' "$file" + perl -pi -e 's/KALEIDOS INC Sucursal en España SL/KALEIDOS SUBSIDIARY SL/g' "$file" + perl -pi -e 's/KALEIDOS INC\b(?!\s+Sucursal)(?!\s+SUBSIDIARY)/KALEIDOS SUBSIDIARY SL/g' "$file" + # Count remaining to compute actual replacements + local remaining + remaining=$(count_kaleidos "$file") + replacements=$(( matches - remaining )) + else + # In dry-run mode, report all matches as potential replacements + replacements=$matches + fi + + if [[ "$replacements" -gt 0 ]]; then + module=$(get_module "$file") + if [[ "$DRY_RUN" == true ]]; then + echo -e " ${YELLOW}[${module}]${NC} ${file}: ${replacements} replacement(s)" + else + echo -e " ${GREEN}[${module}]${NC} ${file}: ${replacements} replacement(s)" + fi + MODULE_REPLACEMENTS["$module"]=$(( ${MODULE_REPLACEMENTS["$module"]:-0} + replacements )) + MODULE_FILES["$module"]=$(( ${MODULE_FILES["$module"]:-0} + 1 )) + TOTAL_REPLACEMENTS=$(( TOTAL_REPLACEMENTS + replacements )) + TOTAL_FILES=$(( TOTAL_FILES + 1 )) + fi +} + +print_summary() { + echo "" + echo -e "${BOLD}=== SUMMARY ===${NC}" + printf "%-15s %6s %14s\n" "Module" "Files" "Replacements" + printf "%-15s %6s %14s\n" "------" "-----" "------------" + + for module in $(printf '%s\n' "${!MODULE_REPLACEMENTS[@]}" | sort); do + printf "%-15s %6d %14d\n" "$module" "${MODULE_FILES[$module]}" "${MODULE_REPLACEMENTS[$module]}" + done + + printf "%-15s %6s %14s\n" "------" "-----" "------------" + printf "${BOLD}%-15s %6d %14d${NC}\n" "TOTAL" "$TOTAL_FILES" "$TOTAL_REPLACEMENTS" +} + +# ── Main ─────────────────────────────────────────────────────────────────────── + +main() { + # Ensure we run from the repo root + cd "$(git rev-parse --show-toplevel)" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + --help|-h) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac + done + + # Header + if [[ "$DRY_RUN" == true ]]; then + log_dry "Starting copyright replacement (simulation mode)..." + else + log_info "Starting copyright replacement..." + fi + + # Check dependencies + for cmd in rg perl git; do + if ! command -v "$cmd" &>/dev/null; then + log_error "Required command not found: $cmd" + exit 1 + fi + done + + # Get tracked files matching our extensions + local files + files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true) + + if [[ -z "$files" ]]; then + log_warn "No tracked files found matching extensions: ${EXTENSIONS}" + exit 0 + fi + + local file_count + file_count=$(echo "$files" | wc -l) + log_info "Found ${file_count} tracked files to scan" + + if [[ "$DRY_RUN" == true ]]; then + log_dry "Would process files (no changes will be made)" + fi + + echo "" + + # Process each file + while IFS= read -r file; do + replace_in_file "$file" + done <<< "$files" + + # Print summary + print_summary + + # Exit code + if [[ "$DRY_RUN" == true ]]; then + echo "" + log_dry "No files were modified. Run without --dry-run to apply changes." + fi +} + +main "$@" From 57c0e8161604c1e0e1e2dacffc28744ed77e593c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 25 Aug 2026 12:56:07 +0200 Subject: [PATCH 183/298] :zap: Present viewport before interest and clamp paint to atlas (#11313) Present visible tiles via ViewportReady so zoom settle turns sharp without waiting on the interest ring, and paint at atlas slot size so DPR 2 does not rasterize 1024 only to downscale into 512 slots. --- .../render-wasm/ffi-rendering-subtleties.md | 3 + common/src/app/common/render_wasm/wasm.cljs | 1 + frontend/src/app/render_wasm/api.cljs | 107 +++++++++++------- render-wasm/src/render.rs | 70 +++++++++--- render-wasm/src/tiles.rs | 32 +++++- 5 files changed, 156 insertions(+), 57 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index bb09ea8d52..aac0201522 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,9 @@ - Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring + work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is + compose+present only. - Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame. - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. diff --git a/common/src/app/common/render_wasm/wasm.cljs b/common/src/app/common/render_wasm/wasm.cljs index 816bca617c..81b21ca5d7 100644 --- a/common/src/app/common/render_wasm/wasm.cljs +++ b/common/src/app/common/render_wasm/wasm.cljs @@ -58,6 +58,7 @@ WebGL is ready before re-init finishes." [] (set! internal-frame-id nil) + (set! internal-frame-type 0) (set! canvas nil) (set! canvas-snapshot nil) (set! gl-context-handle nil) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 80c17e6859..a1fc87a83d 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -389,6 +389,19 @@ (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 FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending. + +(defn- needs-more-render-frames? + "True when WASM still has progressive tile work (visible or interest ring)." + [] + (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) + +(defn- frame-presented-target? + "True when this frame recomposited Target (full or early viewport present)." + [] + (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)) + (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render @@ -398,7 +411,7 @@ (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) + (when (needs-more-render-frames?) (request-render "frame-type-partial")))) (defn- build-reload-payload @@ -495,13 +508,13 @@ (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) ;; Only repaint the overlay when this frame recomposited Target (a full - ;; frame). A partial frame is flushed but not presented — Target still - ;; shows the last presented frame with the overlay already on it — so - ;; repainting the translucent selection over it stacks another layer - ;; every progressive frame: it darkens, then snaps back when the final - ;; frame presents from the clean Backbuffer (the blink at the end of a - ;; zoom over a selection, gh-10709). - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + ;; frame or early viewport present). A partial frame is flushed but not + ;; presented - Target still shows the last presented frame with the + ;; overlay already on it - so repainting the translucent selection over + ;; it stacks another layer every progressive frame: it darkens, then + ;; snaps back when the final frame presents from the clean Backbuffer + ;; (the blink at the end of a zoom over a selection, gh-10709). + (when (frame-presented-target?) (text-editor/text-editor-render-overlay)) ;; Drain editor events. Only content/layout changes need a full shape ;; re-render; selection/style changes are already reflected by the @@ -610,12 +623,31 @@ (defonce shapes-loading? (atom false)) (defonce deferred-render? (atom false)) +;; Each `request-render` captures this number. `stop-progressive-render!` +;; increments it so already-scheduled rAFs become no-ops. +;; +;; Why: ViewportReady asks for another frame for the interest ring. If the +;; page changes before that frame runs, `_init` leaves an empty shapes pool +;; and `_render` panics ("Root shape not found"). Cancel alone is not enough +;; when the current `_render` itself schedules the next rAF after we cancelled. +(defonce ^:private render-seq* (atom 0)) + (defn render-pending? "True while a render has been scheduled but not yet completed — including the frames of an in-progress progressive render." [] @pending-render) +(defn- stop-progressive-render! + "Cancel the pending tile-pass rAF and invalidate any follow-ups it may schedule." + [] + (swap! render-seq* inc) + (when-let [frame-id wasm/internal-frame-id] + (timers/cancel-af! frame-id) + (set! wasm/internal-frame-id nil)) + (reset! pending-render false) + (set! wasm/internal-frame-type FRAME_TYPE_NONE)) + (defn- register-deferred-render! [] (reset! deferred-render? true)) @@ -629,19 +661,21 @@ (register-deferred-render!) (when-not @pending-render (reset! pending-render true) - (let [frame-id - (timers/raf - (fn [ts] - (reset! pending-render false) - (set! wasm/internal-frame-id nil) - (try - (render ts) - (catch :default e - ;; A failed render (e.g. a WASM panic) must not strand an - ;; active page-transition. Force ending of it so the - ;; workspace is shown without a blur. - (end-page-transition!) - (throw e)))))] + (let [seq-n @render-seq* + frame-id (timers/raf + (fn [ts] + ;; Dropped if `stop-progressive-render!` ran since we scheduled. + (when (= seq-n @render-seq*) + (reset! pending-render false) + (set! wasm/internal-frame-id nil) + (try + (render ts) + (catch :default e + ;; A failed render (e.g. a WASM panic) must not strand an + ;; active page-transition. Force ending of it so the + ;; workspace is shown without a blur. + (end-page-transition!) + (throw e))))))] (set! wasm/internal-frame-id frame-id)))))) (defn request-render-preserving-target @@ -664,12 +698,8 @@ (defn- begin-shapes-loading! [] (reset! shapes-loading? true) - (let [frame-id wasm/internal-frame-id - was-pending @pending-render] - (when frame-id - (js/cancelAnimationFrame frame-id) - (set! wasm/internal-frame-id nil)) - (reset! pending-render false) + (let [was-pending @pending-render] + (stop-progressive-render!) (reset! deferred-render? was-pending))) (defn- end-shapes-loading! @@ -1425,14 +1455,14 @@ (defn- render-text-editor-overlay-after-frame! "Repaint the overlay after a direct `internal-render`, but only when that - render recomposited Target (a full frame). A partial frame is only flushed — - Target keeps the last presented frame with the overlay already on it — so - repainting the translucent selection then stacks another layer and it visibly - darkens across the progressive frames before snapping back on the final - present (the blink at the end of a zoom over a selection, gh-10709). The - final full frame's own repaint keeps the overlay in place." + render recomposited Target (a full frame or early viewport present). A partial + frame is only flushed - Target keeps the last presented frame with the overlay + already on it - so repainting the translucent selection then stacks another + layer and it visibly darkens across the progressive frames before snapping + back on the final present (the blink at the end of a zoom over a selection, + gh-10709). The final full frame's own repaint keeps the overlay in place." [] - (when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (when (frame-presented-target?) (render-text-editor-overlay-if-active!))) (defn finalize-view-interaction! @@ -2096,6 +2126,8 @@ (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) total-shapes (count (vals base-objects))] + ;; Stop Partial/ViewportReady follow-ups before we clear the shapes pool. + (stop-progressive-render!) (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (h/call wasm/internal-module "_init_shapes_pool" total-shapes) @@ -2378,12 +2410,9 @@ (set! wasm/context-initialized? false) - ;; Cancel any pending animation frame to prevent race conditions. - (when wasm/internal-frame-id - (timers/cancel-af! wasm/internal-frame-id)) + (stop-progressive-render!) - ;; Reset render flags to prevent new renders from being scheduled. - (reset! pending-render false) + ;; Reset remaining render flags so teardown cannot schedule work. (reset! shapes-loading? false) (reset! deferred-render? false) (reset! view-interaction-active? false) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1854a2d5ba..f435bf6697 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -49,6 +49,10 @@ pub enum FrameType { None = 0, Partial = 1, Full = 2, + /// Viewport tiles are presented; interest-ring work may still be pending. + /// Frontend should keep requesting frames (like Partial) but may treat the + /// Target as freshly composited (like Full) for overlays. + ViewportReady = 3, } #[allow(dead_code)] @@ -419,6 +423,9 @@ pub(crate) struct RenderState { pub drop_shadows_ops_warmed: bool, /// Filter-surface snapshots for drop shadows, reused across tiles. drop_shadow_filter_cache: shadows::DropShadowFilterCache, + /// Visible tiles were already presented this pass; interest-ring fill may + /// still be running. Final Full should not re-present. + pub viewport_presented: bool, } pub struct InteractiveDragCrop { @@ -607,6 +614,7 @@ impl RenderState { tile_atlas_flushed: false, drop_shadows_ops_warmed: false, drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(), + viewport_presented: false, }) } @@ -2309,6 +2317,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; self.drop_shadow_filter_cache.clear(); + self.viewport_presented = false; } pub fn start_render_loop( @@ -2481,9 +2490,14 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not - // presented (only flushed), so defer composition to the final frame and - // avoid re-snapshotting up to 4096² on every rAF during async tile work. - if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { + // presented (only flushed), so defer composition until the viewport is + // ready and avoid re-snapshotting up to 4096² on every rAF during async + // tile work. + let should_compose = !self.options.is_interactive_transform() + && matches!(frame_type, FrameType::Full | FrameType::ViewportReady) + && !self.viewport_presented; + + if should_compose { self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, &self.tile_viewbox, @@ -2500,17 +2514,31 @@ impl RenderState { // `drain_partial_gpu_soft`). Full still submits via present_frame. Self::drain_partial_gpu_soft(); } + FrameType::ViewportReady => { + // Visible tiles are done: present now so the user sees the + // viewport without waiting for interest-ring pre-render. + // Defer crop-cache rebuild to Full — it is expensive on large + // HiDPI viewports and is not needed until the next drag. + self.present_frame(tree); + self.viewport_presented = true; + wapi::notify_tiles_render_complete!(); + Self::drain_partial_gpu_soft(); + } FrameType::Full => { - // A full-quality frame is now complete. Rebuild the per-shape crop - // cache from the clean Backbuffer (no UI overlay yet) so that - // interactive drag backgrounds don't include the grid overlay. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + if !self.viewport_presented { + // A full-quality frame is now complete (no early viewport + // present). Rebuild crop cache and present. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.rebuild_backbuffer_crop_cache(tree); + } + self.present_frame(tree); + wapi::notify_tiles_render_complete!(); + } else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + // Interest fill finished after ViewportReady. Backbuffer + // still holds the viewport compose; rebuild crop cache + // off the sharp-snap frame. self.rebuild_backbuffer_crop_cache(tree); } - // present_frame: copy clean Backbuffer → Target, draw UI/debug - // overlays on Target only, then flush. Backbuffer stays overlay-free. - self.present_frame(tree); - wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } } @@ -4015,8 +4043,18 @@ impl RenderState { flattened: false, })); } else { - // If there are no more pending tiles, stop. - should_stop = true; + // Visible tiles finished. Promote deferred interest-ring work + // so pan/zoom pre-render still happens, but yield first when + // allowed so continue_render_loop can present the viewport. + if self.pending_tiles.promote_deferred_interest() { + if allow_stop { + should_stop = true; + } + // Sync path (allow_stop=false): keep looping on interest + // tiles in the same call without an early present. + } else { + should_stop = true; + } } } @@ -4034,6 +4072,12 @@ impl RenderState { self.cached_viewbox = self.viewbox; } + // Visible done with interest still queued and we yielded: present + // viewport now, keep Partial-like rAFs for the ring. + if allow_stop && !self.pending_tiles.list.is_empty() { + return Ok(FrameType::ViewportReady); + } + Ok(FrameType::Full) } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0a59bda8da..71c7d91329 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -352,6 +352,8 @@ pub struct PendingTiles { pub visible_uncached: Vec, pub interest_cached: Vec, pub interest_uncached: Vec, + /// Interest-ring tiles deferred until after the viewport has been presented. + deferred_interest: Vec, } impl PendingTiles { @@ -364,14 +366,16 @@ impl PendingTiles { visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), interest_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + deferred_interest: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), } } pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { self.list.clear(); + self.deferred_interest.clear(); // During interactive transform, skip the interest-area ring - // entirely — the user is dragging, every rAF is on the critical + // entirely: the user is dragging, every rAF is on the critical // 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. @@ -423,10 +427,28 @@ impl PendingTiles { } } - self.list.extend(self.interest_uncached.iter()); - self.list.extend(self.interest_cached.iter()); - self.list.extend(self.visible_uncached.iter()); - self.list.extend(self.visible_cached.iter()); + // Visible tiles first. Interest-ring work is deferred so we can present + // as soon as the viewport is ready (see `promote_deferred_interest`). + // Interactive/`only_visible` already excludes the ring from `tile_rect`. + if only_visible { + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } else { + self.deferred_interest.extend(self.interest_uncached.iter()); + self.deferred_interest.extend(self.interest_cached.iter()); + self.list.extend(self.visible_uncached.iter()); + self.list.extend(self.visible_cached.iter()); + } + } + + /// Move deferred interest-ring tiles onto the pending list. + /// Returns true when there is interest work left to do. + pub fn promote_deferred_interest(&mut self) -> bool { + if self.deferred_interest.is_empty() { + return false; + } + self.list.append(&mut self.deferred_interest); + true } pub fn pop(&mut self) -> Option { From b79680eeb70f8fe6776d3df29a8feca2a7b7c1ab Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 13:10:55 +0200 Subject: [PATCH 184/298] :bug: Fix asset 404 for unauthenticated share-link viewers (#11342) PR #11036 added a per-request permission check to the file-media asset endpoints (/assets/by-file-media-id/:id and the /thumbnail variant) using bfc/get-file-permissions. Anonymous share-link viewers were then rejected because they have neither a session nor an access token, the asset URL carries no share context, and the 2-arg get-file-permissions short-circuits to nil when profile-id is nil. Make the asset endpoints share-link aware, mirroring how get-view-only-bundle already authorizes the same scenario: read the share-id from the query string, validate it as a UUID, and call the 3-arg perms/get-file-read-permissions (which chains the existing 2-arg bfc lookup, the bfc share-link fallback, and the Nitrate org-owner fallback). On the frontend, extend cf/resolve-file-media with an optional share-id arg and pass it from the WASM viewer render path using the share-id already present in [:viewer-local :share-id]. Non-viewer call sites (workspace, clipboard, code-gen) keep the original URL shape because the new arg defaults to nil. Closes #11338 AI-assisted-by: minimax-m3 --- backend/src/app/http/assets.clj | 11 +- .../test/backend_tests/http_assets_test.clj | 106 ++++++++++++++++++ frontend/src/app/config.cljs | 20 +++- frontend/src/app/main/data/viewer.cljs | 11 +- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 6258760548..22783be1e2 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -7,7 +7,6 @@ (ns app.http.assets "Assets related handlers." (:require - [app.binfile.common :as bfc] [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] @@ -15,6 +14,7 @@ [app.db :as db] [app.http.access-token :as actoken] [app.http.session :as session] + [app.rpc.permissions :as perms] [app.storage :as sto] [integrant.core :as ig] [yetti.response :as-alias yres])) @@ -41,6 +41,12 @@ (ex/raise :type :not-found :hint "object not found"))) +(defn- get-share-id + "Extract and validate the optional `share-id` query param. Returns a UUID + or `nil` for missing/malformed values." + [{:keys [query-params]}] + (some-> query-params :share-id d/parse-uuid)) + (defn- get-file-media-object [pool id] (db/get* pool :file-media-object {:id id} {::db/remove-deleted false})) @@ -125,7 +131,8 @@ (let [file-id (:file-id mobj) profile-id (or (::session/profile-id request) (::actoken/profile-id request)) - perms (bfc/get-file-permissions pool profile-id file-id)] + share-id (get-share-id request) + perms (perms/get-file-read-permissions pool profile-id file-id share-id)] (if-not (:can-read perms) {::yres/status 404} (let [sobj (sto/get-object storage (kf mobj))] diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index 94510d73d6..e4f5ebff43 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -13,6 +13,7 @@ [app.http.access-token :as actoken] [app.http.assets :as assets] [app.http.session :as session] + [app.rpc :as-alias rpc] [app.rpc.commands.access-token :as access-token] [app.storage :as sto] [backend-tests.helpers :as th] @@ -588,6 +589,111 @@ response (assets/file-objects-handler cfg request)] (t/is (= 404 (::yres/status response))))) +;; ---------------------------------------------------------------- +;; Tests: file-objects-handler — share-link authz (issue #11338) +;; ---------------------------------------------------------------- + +(t/deftest file-objects-handler-anonymous-with-valid-share-id-succeeds + ;; Anonymous request with a valid share-id matching the file must + ;; succeed (share-link viewers are unauthenticated by definition). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-share-id-for-other-file-returns-404 + ;; A share-id from file A must not grant access to assets of file B. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file-a (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + file-b (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project)}) + media-a (create-storage-object! storage "file-media-object" "image A") + media-obj-a (th/create-file-media-object* {:file-id (:id file-a) + :media-id (:id media-a)}) + media-b (create-storage-object! storage "file-media-object" "image B") + media-obj-b (th/create-file-media-object* {:file-id (:id file-b) + :media-id (:id media-b)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file-a) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj-b))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-objects-handler-anonymous-with-malformed-share-id-returns-404 + ;; Malformed share-id must not raise; it must short-circuit to 404. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + profile (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + media-storage (create-storage-object! storage "file-media-object" "image data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id media-storage)}) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id "not-a-uuid"}} + response (assets/file-objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest file-thumbnails-handler-anonymous-with-valid-share-id-succeeds + ;; Thumbnail endpoint must also honor the share-id query param. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data") + media-obj (th/create-file-media-object* {:file-id (:id file) + :media-id (:id thumb-storage)}) + slink (:result (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "all"})) + request {:path-params {:id (str (:id media-obj))} + :query-params {:share-id (str (:id slink))}} + response (assets/file-thumbnails-handler cfg request)] + ;; Falls back to media-id since no thumbnail-id, but still serves + (t/is (= 204 (::yres/status response))))) + (t/deftest objects-handler-expired-object ;; Expired objects should return 404 (get-object filters them out). (let [storage (-> (:app.storage/storage th/*system*) diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index dc2c5a237a..97bb2eb0f6 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -258,6 +258,23 @@ [id] (dm/str (u/join public-uri "assets/by-id/" (str id)))) +;; Current share-id for asset URL building. The share-link viewer sets +;; this in `app.main.data.viewer/initialize` so every caller of +;; `resolve-file-media` (inspector, code panel, image previews, +;; code generators, etc.) automatically receives a share-id without +;; having to thread it through every call site. Workspace callers +;; leave it nil and continue to get the original URL shape. +(defonce ^:private ^{:doc "Active share-id used by `resolve-file-media`." + :dynamic true} + current-share-id + nil) + +(defn set-current-share-id! + "Set the share-id used by `resolve-file-media`. Pass `nil` to clear it + (e.g. when leaving the viewer)." + [share-id] + (set! current-share-id share-id)) + (defn resolve-file-media ([media] (resolve-file-media media false)) @@ -266,7 +283,8 @@ (dm/str (cond-> (u/join public-uri "assets/by-file-media-id/") (true? thumbnail?) (u/join (dm/str id "/thumbnail")) - (false? thumbnail?) (u/join (dm/str id))))))) + (false? thumbnail?) (u/join (dm/str id)) + (some? current-share-id) (u/join (dm/str "?share-id=" current-share-id))))))) (defn resolve-href [resource] diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 1d9c49f9d3..f847cd862d 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -95,14 +95,21 @@ ;; browser just focus the opened tab instead of creating new ;; tab. (let [name (str "viewer-" file-id)] - (unchecked-set ug/global "name" name))))) + (unchecked-set ug/global "name" name)) + ;; Make every `cf/resolve-file-media` call (inspector, code panel, + ;; image previews, ...) share-link aware for the lifetime of this + ;; viewer. Cleared by `finalize` below. + (cf/set-current-share-id! share-id)))) (defn finalize [_] (ptk/reify ::finalize ptk/UpdateEvent (update [_ state] - (dissoc state :viewer)))) + (dissoc state :viewer)) + ptk/EffectEvent + (effect [_ _ _] + (cf/set-current-share-id! nil)))) ;; --- Data Fetching From 0a3352927dae1363b1a4619d7ebf7d152dfc8d38 Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 25 Aug 2026 18:40:09 +0530 Subject: [PATCH 185/298] :bug: Hide register link in viewer login modal when registration is disabled (#11199) The viewer's login-register modal always showed the Register link regardless of the disable-registration server flag. The main login page already gates this link on (contains? cf/flags :registration); apply the same guard to the viewer modal to make the flag consistent across all entry points. --- frontend/src/app/main/ui/viewer/login.cljs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index 8da7fd857a..302e762eb3 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.logging :as log] + [app.config :as cf] [app.main.data.modal :as modal] [app.main.store :as st] [app.main.ui.auth.login :refer [login-dialog*]] @@ -84,13 +85,14 @@ :class (stl/css :recovery-link) :data-value "recovery-request"} (tr "auth.forgot-password")]] - [:div {:class (stl/css :register)} - [:span {:class (stl/css :register-text)} - (tr "auth.register") " "] - [:a {:on-click set-section - :class (stl/css :register-link) - :data-value "register"} - (tr "auth.register-submit")]]]] + (when (contains? cf/flags :registration) + [:div {:class (stl/css :register)} + [:span {:class (stl/css :register-text)} + (tr "auth.register") " "] + [:a {:on-click set-section + :class (stl/css :register-link) + :data-value "register"} + (tr "auth.register-submit")]])]] :register [:div {:class (stl/css :form-container)} From 85a68ea3b20150850c175b5a851de0ad3c6d5b1b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 13:41:22 +0000 Subject: [PATCH 186/298] :bug: Normalize toast HTML prop to boolean Ensure toast components always receive a boolean `is-html` prop so nil or truthy notification values do not violate the Rumext schema. AI-assisted-by: gpt-5.6-luna --- frontend/src/app/main/ui/notifications.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index a7cead63cc..7a05787464 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -35,7 +35,7 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) - :is-html (:is-html notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content] @@ -58,6 +58,6 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) - :is-html (:is-html notification) + :is-html (boolean (:is-html notification)) :detail (:detail notification) :on-close on-close} content])))) From 44dfc04300362c1e08dfeb85c598728e68fc8489 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 25 Aug 2026 16:55:34 +0200 Subject: [PATCH 187/298] :bug: Fix font selector dropdown takes noticeably long to open when changing font (#11073) * :bug: Fix font selector dropdown takes noticeably long to open when changing font * :recycle: Refactor detach-preview-sprite! to use atomic swap Use idiomatic atomic swap! update instead of non-atomic read-then-write pattern. The new implementation computes the decremented refs inside swap! and only removes the node when the result reaches zero. AI-assisted-by: mimo-v2.5-pro --------- Co-authored-by: Andrey Antukh --- frontend/src/app/main/fonts.cljs | 78 +++++++----- .../sidebar/options/menus/typography.cljs | 32 +++-- frontend/test/frontend_tests/fonts_test.cljs | 118 +++++++++++++++++- frontend/test/frontend_tests/runner.cljs | 2 + 4 files changed, 186 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 15fa05534b..644169d3de 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -138,10 +138,11 @@ ;; uploads, ones that fail to bake) use the runtime fallback. ;; ;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched -;; markup is cached here as a string (`:svg`) and the nodes are materialized only -;; while the picker is open (attach/detach below). `:ids` are the font ids it -;; covers, so the UI can pick sprite vs fallback. -(defonce preview-sprite (l/atom {:status :idle :ids #{} :svg nil})) +;; markup is parsed once eagerly into a cached node (`:node`) so attaching is a +;; cheap appendChild. `:ids` are the font ids it covers (also pre-computed), so +;; the UI can pick sprite vs fallback. `:refs` counts open dropdowns sharing the +;; node, so the last one to close is the one that detaches it. +(defonce preview-sprite (l/atom {:status :idle :ids #{} :node nil :refs 0})) ;; Id prefix shared with the generator and the UI's ``; referenced here ;; rather than re-declared so the contract stays in one place. @@ -163,7 +164,7 @@ [] ;; :error → the UI shows plain names (no previews, no per-font load storm); a ;; later `prefetch-preview-sprite!` call can retry. - (reset! preview-sprite {:status :error :ids #{} :svg nil})) + (reset! preview-sprite {:status :error :ids #{} :node nil :refs 0})) (defn- parse-sprite-svg "Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection @@ -177,10 +178,10 @@ root))) (defn prefetch-preview-sprite! - "Fetch the font-preview sprite markup and cache it in memory (no DOM yet — see - `attach-preview-sprite!`). Idempotent: fetches only when nothing is cached yet - (`:idle`) or a previous attempt failed (`:error`); no-op while `:loading` or - `:ready`." + "Fetch the font-preview sprite markup, pre-parse it on idle, and cache the + parsed DOM node with the font ids it covers. Idempotent: fetches only when + nothing is cached yet (`:idle`) or a previous attempt failed (`:error`); no-op + while `:loading` or `:ready`." [] (when (and (globals/browser?) (contains? #{:idle :error} (:status @preview-sprite))) @@ -192,9 +193,24 @@ (rx/subs! (fn [response] ;; http/send! doesn't reject on non-2xx; guard so an error body isn't - ;; cached as the sprite. + ;; cached as the sprite. The parse is deferred to idle so the + ;; ~2000-node import doesn't spike the main thread at load time; + ;; `:status` stays `:loading` until it's done. (if (http/success? response) - (swap! preview-sprite assoc :status :ready :svg (:body response)) + (let [svg (:body response)] + (tm/schedule-on-idle + (fn [] + (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] + (do + (dom/set-attribute! node "id" "font-preview-sprite") + (let [ids (collect-preview-ids node)] + (swap! preview-sprite assoc + :status :ready + :node node + :ids ids))) + (do + (log/wrn :hint "cannot parse font preview sprite") + (reset-preview-sprite-error!)))))) (do (log/wrn :hint "cannot load font preview sprite" :status (:status response)) (reset-preview-sprite-error!)))) @@ -203,32 +219,28 @@ (reset-preview-sprite-error!)))))) (defn attach-preview-sprite! - "Materialize the cached sprite into the DOM (hidden) so rows can reference its - glyph groups via ``, and record the covered font ids. Returns the injected - node (pass it to `detach-preview-sprite!` on close), or nil if not ready / the - markup is invalid. Parsing happens here, not on prefetch, so the cost is paid - only while the picker is open." + "Append the pre-parsed sprite node into the DOM (hidden) so rows can reference + its glyph groups via ``. Returns the node (pass it to + `detach-preview-sprite!` on close), or nil if not ready. Parsing and id + collection happen once during `prefetch-preview-sprite!`, so this is just a + cheap appendChild. Multiple dropdowns may share the node; each attach + increments `:refs` so the node is only detached when the last one closes." [] - (let [{:keys [status svg]} @preview-sprite] - (when (and (globals/browser?) (= :ready status) (some? svg)) - (if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))] - ;; The node already carries display:none + aria-hidden from the generator. - (do - (dom/set-attribute! node "id" "font-preview-sprite") - (when-let [body-el (unchecked-get globals/document "body")] - (dom/append-child! body-el node)) - (swap! preview-sprite assoc :ids (collect-preview-ids node)) - node) - (do - (log/wrn :hint "cannot parse font preview sprite") - (reset-preview-sprite-error!) - nil))))) + (let [{:keys [status node]} @preview-sprite] + (when (and (globals/browser?) (= :ready status) (some? node)) + (when-let [body-el (unchecked-get globals/document "body")] + (dom/append-child! body-el node)) + (swap! preview-sprite update :refs inc) + node))) (defn detach-preview-sprite! - "Remove the sprite node injected by `attach-preview-sprite!` from the DOM. The - cached markup and `:ids` stay, so reopening re-attaches without a refetch." + "Remove the sprite node injected by `attach-preview-sprite!` from the DOM when + the last open dropdown closes. The cached node and `:ids` stay, so reopening + re-attaches without a refetch or re-parse." [node] - (dom/remove! node)) + (let [new-state (swap! preview-sprite update :refs #(max 0 (dec %)))] + (when (zero? (:refs new-state)) + (dom/remove! node)))) (defn- add-font-css! "Creates a style element and attaches it to the dom." diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 4dc3473644..1a085ef1c4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -105,13 +105,18 @@ [{:keys [font]}] (let [font-id (:id font) sprite (mf/deref fonts/preview-sprite) - in-sprite? (contains? (:ids sprite) font-id) - ;; Fallback is ONLY for custom fonts: ones the (ready) sprite doesn't - ;; cover. If the sprite isn't ready (loading/error) we show the plain name - ;; rather than runtime-loading the whole catalog. - fallback? (and (= :ready (:status sprite)) - (not in-sprite?)) + ;; The sprite is only referenceable once it's been attached to the DOM, + ;; so the `` glyph is gated on `attached?`. Until then we show the + ;; plain name: no blank rows, and no per-font load storm either (see + ;; `fallback?` below). + attached? (pos? (:refs sprite)) + + ;; Fallback is ONLY for custom fonts: ones the (attached) sprite doesn't + ;; cover. If the sprite isn't ready (loading/error) or not yet attached, + ;; we show the plain name rather than runtime-loading the whole catalog. + in-sprite? (and attached? (contains? (:ids sprite) font-id)) + fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) loaded? (use-font-lazy-load font-id fallback?)] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. @@ -257,13 +262,20 @@ ;; FLAG :font-preview — materialize the preview sprite into the DOM only while ;; the picker is open (markup is prefetched on workspace load), removing it on - ;; close so its ~2000 nodes aren't kept around idle. Remove the flag clause to - ;; drop the feature. + ;; close so its ~2000 nodes aren't kept around idle. The attachment is deferred + ;; so the dropdown can paint first with plain names, then the sprite swaps in + ;; on the next tick. Remove the flag clause to drop the feature. (mf/with-effect [sprite-status] (when (and (contains? cf/flags :font-preview) (= :ready sprite-status)) - (let [node (fonts/attach-preview-sprite!)] - #(fonts/detach-preview-sprite! node)))) + (let [node* (volatile! nil) + task (tm/schedule + (fn [] + (vreset! node* (fonts/attach-preview-sprite!))))] + (fn [] + (tm/dispose! task) + (when-some [n @node*] + (fonts/detach-preview-sprite! n)))))) (mf/with-effect [@selected] (when-let [inst (mf/ref-val flist)] diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index e2de0217e0..40645fcfc4 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -7,7 +7,11 @@ (ns frontend-tests.fonts-test (:require [app.main.fonts :as fonts] - [cljs.test :as t :include-macros true])) + [app.util.globals :as globals] + [app.util.http :as http] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) (def sample-font {:id "sourcesanspro" @@ -124,3 +128,115 @@ result (fonts/find-closest-variant font "200" nil)] (t/is (= "200" (:weight result))) (t/is (= "italic" (:style result)))))) + +;; --- preview sprite ---------------------------------------------------------- +;; +;; The sprite feature (FLAG :font-preview) caches a pre-parsed SVG node shared by +;; every open font dropdown. `:refs` counts the open dropdowns so the node is only +;; detached when the last one closes. The unit test runner has no browser DOM, so +;; the environment boundary (`globals/browser?`) is mocked and DOM nodes are +;; replaced with minimal fakes exposing only what attach/detach touches. + +(t/use-fixtures + :each + (fn [test-fn] + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (test-fn))) + +(defn- fake-node + "A minimal DOM-like node exposing only what the sprite attach/detach touches." + [] + #js {:remove (fn [] nil)}) + +(t/deftest attach-preview-sprite-returns-nil-while-sprite-is-not-ready + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (t/is (nil? (fonts/attach-preview-sprite!))) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (done)) + (fn [] nil))) + +(t/deftest attach-preview-sprite-increments-refs-and-returns-the-node + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [node (fake-node)] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (identical? node (fonts/attach-preview-sprite!))) + (t/is (= 2 (:refs @fonts/preview-sprite))) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-removes-node-only-when-last-reference-drops + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/attach-preview-sprite!) + (fonts/attach-preview-sprite!) + + ;; First detach keeps the node: another dropdown is still open. + (fonts/detach-preview-sprite! node) + (t/is (= 1 (:refs @fonts/preview-sprite))) + (t/is (false? @removed?)) + + ;; Second detach reaches zero refs, so the node is removed from the DOM. + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest detach-preview-sprite-clamps-refs-at-zero + (mock/with-mocks + {globals/browser? (mock/stub (constantly true))} + (fn [done] + (let [removed? (volatile! false) + node #js {:remove (fn [] (vreset! removed? true))}] + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node node :refs 0}) + (fonts/detach-preview-sprite! node) + (t/is (= 0 (:refs @fonts/preview-sprite))) + (t/is (true? @removed?)) + (done))) + (fn [] nil))) + +(t/deftest prefetch-preview-sprite-fetches-only-from-idle-or-error + (let [calls (volatile! 0) + fetch (mock/stub (fn [& _] + (vswap! calls inc) + (rx/empty)))] + (mock/with-mocks + {globals/browser? (mock/stub (constantly true)) + http/fetch fetch} + (fn [done] + ;; :ready → no refetch + (reset! fonts/preview-sprite {:status :ready :ids #{"a"} :node (fake-node) :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :loading → no refetch (an earlier request is in flight) + (reset! fonts/preview-sprite {:status :loading :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 0 @calls)) + + ;; :error → retries + (reset! fonts/preview-sprite {:status :error :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 1 @calls)) + + ;; :idle → first fetch + (reset! fonts/preview-sprite {:status :idle :ids #{} :node nil :refs 0}) + (fonts/prefetch-preview-sprite!) + (t/is (= 2 @calls)) + (done)) + (fn [] nil)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f4ae117eda..73749f9810 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -28,6 +28,7 @@ [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.errors-test] + [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -125,6 +126,7 @@ 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.errors-test + 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens From 5e1ced03eaad9193fce4f0ef46162ea316bd836c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 26 Aug 2026 08:08:40 +0200 Subject: [PATCH 188/298] :bug: Fix missing warning when moving a team (#11357) --- backend/src/app/rpc/commands/nitrate.clj | 13 ++-- .../test/backend_tests/rpc_nitrate_test.clj | 60 ++++++++++++++++++- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index a476ce0dbf..8714c5c841 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -458,13 +458,14 @@ (let [emails (map :email (noh/get-team-invitation-emails conn team-id))] (if (empty? emails) {:allows-anybody false :external-emails []} - (let [emails-array (db/create-array conn "text" (vec emails)) - profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) + (let [emails-array (db/create-array conn "text" (vec emails)) + profiles (db/exec! conn [sql:get-profiles-by-emails emails-array]) organization-member-ids (into #{} (nitrate/call cfg :get-organization-members {:organization-id organization-id})) - external-emails (->> profiles - (remove #(contains? organization-member-ids (:id %))) - (map :email) - (vec))] + member-emails (->> profiles + (filter #(contains? organization-member-ids (:id %))) + (map :email) + (into #{})) + external-emails (into [] (remove member-emails emails))] {:allows-anybody false :external-emails external-emails})))))) (def ^:private schema:add-team-to-organization diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 90b746e2a1..8519021194 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -1168,12 +1168,68 @@ @set-team-params)) (let [emails (->> @sent (map :to) set)] - (t/is (= 2 (count @sent))) - (t/is (= #{"member302@example.com" "external301@example.com"} emails)) + (t/is (= 1 (count @sent))) + (t/is (= #{"member302@example.com"} emails)) (doseq [email-params @sent] (t/is (= organization-name (:organization-name email-params))) (t/is (= eml/organization-setup-sso (::eml/factory email-params))))))) +(t/deftest add-team-to-organization-deletes-external-invitations-for-unregistered-users + (let [owner (th/create-profile* 305 {:is-active true + :fullname "Owner" + :email "owner305@example.com"}) + member (th/create-profile* 306 {:is-active true + :fullname "Member" + :email "member306@example.com"}) + team (th/create-team* 305 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + organization-id (uuid/random) + organization-summary {:id organization-id + :name "Test Org" + :owner-id (:id owner) + :teams []} + organization-perms {:owner-id (:id owner) + :permissions {:create-teams "any" + :move-teams "always" + :new-team-members "members"}}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "unregistered@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team) + :org-id nil + :email-to "unregistered2@example.com" + :created-by (:id owner) + :role "editor" + :valid-until (ct/in-future "48h")}) + + (with-redefs [cf/flags (conj cf/flags :admin-console) + nitrate/call (add-team-to-organization-nitrate-mock + {:organization-id organization-id + :organization-summary organization-summary + :organization-perms organization-perms + :owner-id (:id owner) + :team-id (:id team) + :sso-active? false}) + teams/initialize-user-in-organization (fn [& _] nil)] + (let [out (th/command! {::th/type :add-team-to-organization + ::rpc/profile-id (:id owner) + :team-id (:id team) + :organization-id organization-id})] + (t/is (th/success? out)))) + + (let [remaining (th/db-query :team-invitation {:team-id (:id team)})] + (t/is (empty? remaining) "Both external invitations should be deleted")))) + (t/deftest create-team-in-organization-passes-association-to-nitrate (let [organization-id (uuid/random) team {:id (uuid/random) From 7419bc70075a1536b1bfbb1ffd0d51f40986a6a0 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 26 Aug 2026 08:25:23 +0200 Subject: [PATCH 189/298] :bug: Evict multi-scale tile cache on shape edits (#11337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit those textures across zoom for progressive previews, and invalidate by old∪new document coverage so rotate/move edits do not leave stale fragments on zoom-out. --- .../render-wasm/ffi-rendering-subtleties.md | 6 +- render-wasm/src/render.rs | 109 +++++-- render-wasm/src/render/surfaces.rs | 273 +++++++++++++----- render-wasm/src/state.rs | 34 ++- render-wasm/src/tiles.rs | 53 +++- 5 files changed, 371 insertions(+), 104 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index aac0201522..7d7d56d7fd 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -26,7 +26,9 @@ - During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately. - `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render. - Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush. -- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters. +- Zoom settle wipes the tile texture cache in `set_view_end`. Mid-zoom overlays + key tiles by scale; shape edits must `invalidate_cached_tiles_intersecting` + the old∪new extrect so those overlays do not keep pre-edit pixels. - Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling. - Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect + - blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. \ No newline at end of file + blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f435bf6697..985a2ca57a 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -383,6 +383,8 @@ pub(crate) struct RenderState { /// Frame id passed as `base_object` for viewer renders; always traversed. pub viewer_render_root: Option, pub touched_ids: HashSet, + /// Pre-edit extrects for old∪new tile eviction (captured on first touch). + touched_prev_extrects: HashMap, /// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. @@ -603,6 +605,7 @@ impl RenderState { include_filter: None, viewer_render_root: None, touched_ids: HashSet::default(), + touched_prev_extrects: HashMap::default(), ignore_nested_blurs: false, preview_mode: false, export_context: None, @@ -1148,6 +1151,8 @@ impl RenderState { &tile_rect, false, self.render_area, + self.get_scale(), + self.viewbox.area, ); Ok(()) @@ -2406,7 +2411,7 @@ impl RenderState { performance::begin_measure!("tile_cache"); let only_visible = self.options.is_interactive_transform(); self.pending_tiles - .update(&self.tile_viewbox, &self.surfaces, only_visible); + .update(&self.tile_viewbox, &self.surfaces, scale, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); @@ -2498,11 +2503,21 @@ impl RenderState { && !self.viewport_presented; if should_compose { - self.surfaces.draw_tile_atlas_to_backbuffer( - &self.viewbox, - &self.tile_viewbox, - self.background_color, - ); + // Fast mode skips the tile atlas; use the same doc-atlas + scale + // overlays as render_from_cache instead of composing empty slots. + if self.options.is_fast_mode() { + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } else { + self.surfaces.draw_tile_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + } } match frame_type { @@ -3923,7 +3938,10 @@ impl RenderState { // is not cached because everything will be handled from draw_atlas. // Viewer masked passes (include_filter) must not reuse cached tiles from // a previous pass; otherwise pass-1 pixels can leak into pass 2. - if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) + if self.viewer_masked_pass() + || !self + .surfaces + .has_cached_tile_surface(current_tile, self.get_scale()) { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self @@ -3968,7 +3986,9 @@ impl RenderState { } } } else if self.tiles.is_empty_at(current_tile) { - self.surfaces.remove_cached_tile_surface(current_tile); + // Keep other-scale entries for mid-zoom overlays. + self.surfaces + .remove_cached_tile_surface_at(current_tile, self.get_scale()); } } @@ -3989,6 +4009,7 @@ impl RenderState { self.drop_shadows_ops_warmed = false; let viewer_masked_pass = self.viewer_masked_pass(); + let current_scale = self.get_scale(); let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. @@ -3996,7 +4017,11 @@ impl RenderState { }; // Never skip based on cached surfaces during viewer masked passes. - if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { + if !viewer_masked_pass + && self + .surfaces + .has_cached_tile_surface(next_tile, current_scale) + { // If the tile is cached, then we do not need to // render it. continue; @@ -4333,9 +4358,8 @@ impl RenderState { pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) { performance::begin_measure!("rebuild_touched_tiles"); - let mut all_tiles = HashSet::::new(); - let ids = std::mem::take(&mut self.touched_ids); + let prev_extrects = std::mem::take(&mut self.touched_prev_extrects); // 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() { @@ -4345,16 +4369,15 @@ impl RenderState { for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles( + shape, + tree, + prev_extrects.get(shape_id).copied(), + ); } } } - // Update the changed tiles - for tile in all_tiles { - self.remove_cached_tile(tile); - } - performance::end_measure!("rebuild_touched_tiles"); } @@ -4372,19 +4395,50 @@ impl RenderState { tree: ShapesPoolMutRef<'_>, ) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); - let mut all_tiles = HashSet::::new(); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + self.invalidate_shape_and_update_tiles(shape, tree, None); } } - for tile in all_tiles { - self.remove_cached_tile(tile); - } performance::end_measure!("invalidate_and_update_tiles"); Ok(()) } + /// old∪new∪indexed document coverage used to evict cached tiles after edits. + fn dirty_doc_rect_for_shape( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) -> skia::Rect { + let scale = self.get_scale(); + let new_extrect = self.get_cached_extrect(shape, tree, 1.0); + let prev_extrect = prev_extrect.or_else(|| { + tree.get_modifier(&shape.id) + .and_then(|_| tree.get_raw(&shape.id).map(|raw| raw.extrect(tree, 1.0))) + }); + let indexed = self + .tiles + .get_tiles_of(shape.id) + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + tiles::union_edit_dirty_rect(prev_extrect, new_extrect, indexed) + } + + fn invalidate_shape_and_update_tiles( + &mut self, + shape: &Shape, + tree: ShapesPoolRef, + prev_extrect: Option, + ) { + let dirty = self.dirty_doc_rect_for_shape(shape, tree, prev_extrect); + let _ = self.update_shape_tiles(shape, tree); + self.surfaces.invalidate_cached_tiles_intersecting(dirty); + } + /// Rebuilds tiles for shapes with modifiers and processes their ancestors /// /// This function applies transformation modifiers to shapes and updates their tiles. @@ -4423,12 +4477,21 @@ impl RenderState { } pub fn mark_touched(&mut self, uuid: Uuid) { - self.touched_ids.insert(uuid); + self.mark_touched_with_prev(uuid, None); + } + + pub fn mark_touched_with_prev(&mut self, uuid: Uuid, prev_extrect: Option) { + if self.touched_ids.insert(uuid) { + if let Some(rect) = prev_extrect.filter(|r| !r.is_empty()) { + self.touched_prev_extrects.insert(uuid, rect); + } + } } #[allow(dead_code)] pub fn clean_touched(&mut self) { self.touched_ids.clear(); + self.touched_prev_extrects.clear(); } pub fn get_cached_extrect(&mut self, shape: &Shape, tree: ShapesPoolRef, scale: f32) -> Rect { diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index f234c9f711..36e5c02008 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -92,6 +92,29 @@ pub enum SurfaceId { TileAtlas = 0b100_0000_1000, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TileCacheKey { + pub tile: Tile, + pub scale_bits: u32, +} + +impl TileCacheKey { + pub fn new(tile: Tile, scale: f32) -> Self { + Self { + tile, + scale_bits: scale.to_bits(), + } + } + + pub fn matches_scale(&self, scale: f32) -> bool { + self.scale_bits == scale.to_bits() + } + + pub fn scale(&self) -> f32 { + f32::from_bits(self.scale_bits) + } +} + pub struct DocAtlas { // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. // It grows dynamically to include any rendered document rect. @@ -105,9 +128,9 @@ pub struct DocAtlas { /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. pub doc_bounds: Option, - /// Tracks the last document-space rect written to the atlas per tile. - /// Used to clear old content without clearing the whole (potentially huge) tile rect. - pub tile_doc_rects: HashMap, + /// Last atlas write per (tile, scale). Scale is part of the key so zoom + /// levels do not overwrite each other's placement metadata. + pub tile_doc_rects: HashMap, } impl DocAtlas { @@ -389,13 +412,17 @@ impl DocAtlas { Ok(()) } - /// Clears the last atlas region written by `tile` (if any). + /// Clears the last atlas region written by `key` (if any). /// /// This avoids clearing the entire logical tile rect which, at very low /// zoom levels, can be enormous in document space and would unnecessarily /// grow / rescale the atlas. - pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { - if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) { + pub fn clear_tile_in_atlas( + &mut self, + gpu_state: &mut GpuState, + key: TileCacheKey, + ) -> Result<()> { + if let Some(doc_rect) = self.tile_doc_rects.remove(&key) { self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; } Ok(()) @@ -1227,6 +1254,7 @@ impl Surfaces { canvas.restore(); } + #[allow(clippy::too_many_arguments)] pub fn draw_current_tile_into_tile_atlas( &mut self, tile_viewbox: &TileViewbox, @@ -1234,6 +1262,8 @@ impl Surfaces { tile_rect: &skia::Rect, skip_cache_surface: bool, tile_doc_rect: skia::Rect, + scale: f32, + view_doc: skia::Rect, ) { let gpu_state = get_gpu_state(); let src = skia::Rect::from(TILE_DRAWABLE_RECT); @@ -1247,9 +1277,14 @@ impl Surfaces { tile_doc_rect, sampling, ); - self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + let key = TileCacheKey::new(*tile, scale); + self.atlas.tile_doc_rects.insert(key, tile_doc_rect); - let tile_ref = self.tiles.add(tile_viewbox, tile); + let mut tile_doc_rects = std::mem::take(&mut self.atlas.tile_doc_rects); + let tile_ref = self + .tiles + .add(tile_viewbox, tile, scale, view_doc, &mut tile_doc_rects); + self.atlas.tile_doc_rects = tile_doc_rects; let dst = tile_ref.rect; let mut current = self.current.clone(); draw_surface_src_rect_to_dst(&mut current, self.tile_atlas.canvas(), src, dst, sampling); @@ -1268,8 +1303,8 @@ impl Surfaces { } } - pub fn has_cached_tile_surface(&self, tile: Tile) -> bool { - self.tiles.has(tile) + pub fn has_cached_tile_surface(&self, tile: Tile, scale: f32) -> bool { + self.tiles.has(tile, scale) } /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into @@ -1326,7 +1361,7 @@ impl Surfaces { (clip_doc.bottom - vb_top) * scale - iy0, ); - if let Some(tile_ref) = self.tiles.get(tile) { + if let Some(tile_ref) = self.tiles.get(tile, scale) { let bounds = skia::IRect::from_ltrb( tile_ref.rect.left as i32, tile_ref.rect.top as i32, @@ -1387,7 +1422,47 @@ impl Surfaces { self.tiles.remove(tile); // Also clear the corresponding region in the persistent atlas to avoid // leaving stale pixels when shapes move/delete. - let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); + let keys: Vec<_> = self + .atlas + .tile_doc_rects + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + for key in keys { + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + } + + /// Drop one (tile, scale) entry; leave other zoom levels alone. + pub fn remove_cached_tile_surface_at(&mut self, tile: Tile, scale: f32) { + let gpu_state = get_gpu_state(); + self.tiles.remove_at(tile, scale); + let key = TileCacheKey::new(tile, scale); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + + /// Evict every cached tile whose stored doc rect intersects `doc_rect`. + pub fn invalidate_cached_tiles_intersecting(&mut self, doc_rect: skia::Rect) { + if doc_rect.is_empty() { + return; + } + + let keys: Vec = self + .atlas + .tile_doc_rects + .iter() + .filter_map(|(key, rect)| { + (!rect.is_empty() && rect.intersects(doc_rect)).then_some(*key) + }) + .collect(); + + let gpu_state = get_gpu_state(); + for key in keys { + self.tiles.remove_at(key.tile, key.scale()); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, key); + } + self.atlas.clear_doc_rect_in_atlas_clipped(doc_rect); } /// Draws the current tile directly to the backbuffer and cache surfaces without @@ -1594,8 +1669,8 @@ pub struct TileTextureCache { provider: TileAtlasTextureProvider, transforms: Vec, textures: Vec, - grid: HashMap, - removed: HashSet, + grid: HashMap, + removed: HashSet, } pub struct AtlasDrawBatch { @@ -1658,9 +1733,10 @@ impl TileTextureCache { } fn gc(&mut self) { - // Make a real remove - for tile in self.removed.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + // Drain so soft-deleted keys cannot accumulate forever (scale bits make + // TileCacheKey rarely repeat across zoom levels). + for key in self.removed.drain() { + if let Some(tile_ref) = self.grid.remove(&key) { self.provider.deallocate(tile_ref); } } @@ -1674,29 +1750,57 @@ impl TileTextureCache { self.is_updated = false; } - fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { - let marked: Vec<_> = self - .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } - }) - .take(TEXTURES_BATCH_DELETE) - .collect(); + fn gc_non_visible( + &mut self, + tile_viewbox: &TileViewbox, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) { + // Evict by document coverage, not grid index: other-scale tiles can + // still cover the viewport even when their index is outside visible_rect. + let mut offscreen = Vec::new(); + let mut other_scale_onscreen = Vec::new(); - for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { + for key in self.grid.keys() { + if self.removed.contains(key) { + continue; + } + if key.matches_scale(scale) && tile_viewbox.is_visible(&key.tile) { + continue; + } + + let intersects = tile_doc_rects + .get(key) + .is_some_and(|doc_rect| !doc_rect.is_empty() && doc_rect.intersects(view_doc)); + + if intersects { + if !key.matches_scale(scale) { + other_scale_onscreen.push(*key); + } + } else { + offscreen.push(*key); + } + } + + let mut marked = Vec::with_capacity(TEXTURES_BATCH_DELETE); + marked.extend(offscreen.into_iter().take(TEXTURES_BATCH_DELETE)); + if marked.len() < TEXTURES_BATCH_DELETE { + let remaining = TEXTURES_BATCH_DELETE - marked.len(); + marked.extend(other_scale_onscreen.into_iter().take(remaining)); + } + + for key in marked.iter() { + if let Some(tile_ref) = self.grid.remove(key) { self.provider.deallocate(tile_ref); } + tile_doc_rects.remove(key); } } pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { let dest_scale = self.dest_scale(); + let scale = viewbox.get_scale(); if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( tile_viewbox.visible_rect.len() as usize, @@ -1719,13 +1823,13 @@ impl TileTextureCache { let mut index = 0; for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), scale); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } @@ -1750,7 +1854,7 @@ impl TileTextureCache { &self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, - tile_doc_rects: &HashMap, + tile_doc_rects: &HashMap, ) -> AtlasDrawBatch { let mut transforms = Vec::new(); let mut textures = Vec::new(); @@ -1760,20 +1864,20 @@ impl TileTextureCache { for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { - let tile = Tile(x, y); + let key = TileCacheKey::new(Tile(x, y), s); - let Some(tile_ref) = self.grid.get(&tile) else { + let Some(tile_ref) = self.grid.get(&key) else { continue; }; - if self.removed.contains(&tile) { + if self.removed.contains(&key) { continue; } let doc_rect = tile_doc_rects - .get(&tile) + .get(&key) .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + .unwrap_or_else(|| tiles::get_tile_rect(key.tile, s)); if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1788,17 +1892,22 @@ impl TileTextureCache { } } - // Cached tiles from a previous zoom level use indices outside visible_rect; - // place them via their stored document rect, not the current grid walk above. - for (&tile, tile_ref) in &self.grid { - if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { + // Other-scale / off-grid tiles: place via stored doc rect (not current scale). + for (&key, tile_ref) in &self.grid { + if self.removed.contains(&key) { + continue; + } + let visible = tile_viewbox.is_visible(&key.tile); + if key.matches_scale(s) && visible { + continue; + } + if !key.matches_scale(s) && visible && self.has(key.tile, s) { continue; } - let doc_rect = tile_doc_rects - .get(&tile) - .copied() - .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + let Some(doc_rect) = tile_doc_rects.get(&key).copied() else { + continue; + }; if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { continue; } @@ -1818,11 +1927,19 @@ impl TileTextureCache { } } - pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) + pub fn has(&self, tile: Tile, scale: f32) -> bool { + let key = TileCacheKey::new(tile, scale); + self.grid.contains_key(&key) && !self.removed.contains(&key) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { + pub fn add( + &mut self, + tile_viewbox: &TileViewbox, + tile: &Tile, + scale: f32, + view_doc: skia::Rect, + tile_doc_rects: &mut HashMap, + ) -> TileAtlasTextureRef { // Evict against the real slot count (`provider.length`), not the // hardcoded capacity — otherwise the guard never fires and the atlas // fills up until `allocate()` has no slot left. @@ -1830,41 +1947,67 @@ impl TileTextureCache { if self.grid.len() >= capacity { self.gc(); - self.gc_non_visible(tile_viewbox); + self.gc_non_visible(tile_viewbox, scale, view_doc, tile_doc_rects); } let Some(tile_ref) = self.provider.allocate() else { panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); }; - self.insert(tile, tile_ref) + self.insert(TileCacheKey::new(*tile, scale), tile_ref) } - fn insert(&mut self, tile: &Tile, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { - self.grid.insert(*tile, tile_ref.clone()); + fn insert(&mut self, key: TileCacheKey, tile_ref: TileAtlasTextureRef) -> TileAtlasTextureRef { + if let Some(old_ref) = self.grid.insert(key, tile_ref.clone()) { + self.provider.deallocate(old_ref); + } - if self.removed.contains(tile) { - self.removed.remove(tile); + if self.removed.contains(&key) { + self.removed.remove(&key); } self.is_updated = true; tile_ref } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { - if self.removed.contains(&tile) { + pub fn get(&mut self, tile: Tile, scale: f32) -> Option<&TileAtlasTextureRef> { + let key = TileCacheKey::new(tile, scale); + if self.removed.contains(&key) { return None; } - self.grid.get(&tile) + self.grid.get(&key) } pub fn remove(&mut self, tile: Tile) { - if let Some(tile_ref) = self.grid.get(&tile) { - if tile_ref.index < self.textures.len() { - self.textures[tile_ref.index].set_empty(); + let keys: Vec<_> = self + .grid + .keys() + .copied() + .filter(|key| key.tile == tile) + .collect(); + if keys.is_empty() { + return; + } + for key in keys { + if let Some(tile_ref) = self.grid.get(&key) { + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } } + self.removed.insert(key); } self.is_updated = true; - self.removed.insert(tile); + } + + pub fn remove_at(&mut self, tile: Tile, scale: f32) { + let key = TileCacheKey::new(tile, scale); + let Some(tile_ref) = self.grid.get(&key) else { + return; + }; + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } + self.removed.insert(key); + self.is_updated = true; } pub fn clear(&mut self) { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 849d35131b..8e9be844d7 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -198,22 +198,24 @@ impl State { // headless export path has none, so skip it there. if has_render_state() { let render_state = get_render_state(); - // IMPORTANT: - // Do NOT use `get_tiles_for_shape` here. That method intersects the shape - // tiles with the current interest area, which means we'd only invalidate - // the subset currently near the viewport. When the user later pans/zooms - // to reveal previously cached tiles, stale pixels could reappear. - // - // Instead, remove the shape from *all* tiles where it was indexed, and - // drop cached tiles for those entries. + // Do NOT use `get_tiles_for_shape` (interest-clipped). Evict by + // document coverage so cached tiles outside the interest area + // cannot keep pixels of the deleted shape. let indexed_tiles: Vec = render_state .tiles .get_tiles_of(shape.id) .map(|t| t.iter().copied().collect()) .unwrap_or_default(); - + let scale = render_state.get_scale(); + let dirty = indexed_tiles + .iter() + .fold(shape.extrect(&self.shapes, 1.0), |acc, tile| { + tiles::join_nonempty(acc, tiles::get_tile_rect(*tile, scale)) + }); + render_state + .surfaces + .invalidate_cached_tiles_intersecting(dirty); for tile in indexed_tiles { - render_state.remove_cached_tile(tile); render_state.tiles.remove_shape_at(tile, shape.id); } } @@ -345,7 +347,11 @@ impl State { return; } if let Some(current_id) = self.current_id { - get_render_state().mark_touched(current_id); + let prev = self + .shapes + .get(¤t_id) + .map(|shape| shape.extrect(&self.shapes, 1.0)); + get_render_state().mark_touched_with_prev(current_id, prev); } } @@ -353,6 +359,10 @@ impl State { if self.loading || !has_render_state() { return; } - get_render_state().mark_touched(id); + let prev = self + .shapes + .get(&id) + .map(|shape| shape.extrect(&self.shapes, 1.0)); + get_render_state().mark_touched_with_prev(id, prev); } } diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 71c7d91329..ea36363f6d 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -370,7 +370,13 @@ impl PendingTiles { } } - pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { + pub fn update( + &mut self, + tile_viewbox: &TileViewbox, + surfaces: &Surfaces, + scale: f32, + only_visible: bool, + ) { self.list.clear(); self.deferred_interest.clear(); @@ -417,7 +423,7 @@ impl PendingTiles { 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); + let is_cached = surfaces.has_cached_tile_surface(tile, scale); match (is_visible, is_cached) { (true, true) => self.visible_cached.push(tile), @@ -456,9 +462,34 @@ impl PendingTiles { } } +pub fn join_nonempty(mut acc: skia::Rect, rect: skia::Rect) -> skia::Rect { + if rect.is_empty() { + return acc; + } + if acc.is_empty() { + rect + } else { + acc.join(rect); + acc + } +} + +/// old ∪ new ∪ indexed tile coverage for post-edit cache eviction. +pub fn union_edit_dirty_rect( + old: Option, + new: skia::Rect, + indexed: skia::Rect, +) -> skia::Rect { + [old, Some(new), Some(indexed)] + .into_iter() + .flatten() + .fold(skia::Rect::new_empty(), join_nonempty) +} + #[cfg(test)] mod tests { use super::*; + use skia_safe as skia; #[test] fn atlas_slot_is_full_size_when_tiles_fit() { @@ -487,4 +518,22 @@ mod tests { assert!((scale * src - TILE_SIZE).abs() < 1e-4); assert!(src < slot as f32); } + + #[test] + fn edit_dirty_rect_includes_pre_rotate_extent_outside_current_index() { + // Indexed tiles are interest-clipped; old AABB still covers wings. + let old = skia::Rect::from_ltrb(-1103.0, 1871.1, 4693.2, 3559.9); + let new = skia::Rect::from_ltrb(1445.0, -164.4, 2144.9, 5598.0); + let indexed = skia::Rect::from_ltrb(663.1, 1989.4, 2652.6, 3315.7); + let left_wing = skia::Rect::from_ltrb(-3926.0, 0.0, 0.0, 3926.0); + let right_wing = skia::Rect::from_ltrb(3926.0, 0.0, 7852.0, 3926.0); + + let without_old = union_edit_dirty_rect(None, new, indexed); + assert!(!without_old.intersects(left_wing)); + assert!(!without_old.intersects(right_wing)); + + let dirty = union_edit_dirty_rect(Some(old), new, indexed); + assert!(dirty.intersects(left_wing)); + assert!(dirty.intersects(right_wing)); + } } From b33213787e2eb4f52fade3a257ce82f76b3b3221 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 26 Aug 2026 08:33:20 +0200 Subject: [PATCH 190/298] :bug: Add accumulated storage byte quota for media uploads (#11038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Add accumulated storage byte quota for media uploads Add media-storage-bytes-per-team quote to prevent persistent DoS via repeated uploads. The quota sums storage_object sizes from both file_media_object (media + thumbnails) and team_font_variant (otf/ttf/woff1/woff2). Default limit is 20 GiB per team, configurable via PENPOT_QUOTES_MEDIA_STORAGE_BYTES_PER_TEAM. The check is invoked in upload-file-media-object before processing, looking up the team-id via file -> project -> team_id join. AI-assisted-by: mimo-v2.5-pro * :bug: Fix deduplicated storage overcounting in media-storage-bytes-per-team quote The SQL query sql:get-media-storage-bytes-per-team used UNION ALL across six SELECT branches that each produce a so_id reference. When deduplication causes multiple file_media_object or team_font_variant rows to point at the same storage_object, UNION ALL counts that objects size once per reference — inflating "used bytes" and causing false :max-quote-reached rejections. Change all five UNION ALL to UNION so that duplicate so_id values are collapsed before the JOIN storage_object / SUM(so.size). Add a test (media-storage-bytes-quote-deduped) that creates one storage_object referenced by two file_media_object rows and asserts the computed usage reflects the deduplicated physical size, not 2x. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/config.clj | 1 + backend/src/app/rpc/commands/media.clj | 12 ++ backend/src/app/rpc/quotes.clj | 70 +++++++++ .../test/backend_tests/rpc_quotes_test.clj | 134 ++++++++++++++++++ 4 files changed, 217 insertions(+) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index f02136b1ca..3979f399d7 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -194,6 +194,7 @@ [:quotes-team-access-requests-per-requester {:optional true} ::sm/int] [:quotes-upload-sessions-per-profile {:optional true} ::sm/int] [:quotes-upload-chunks-per-session {:optional true} ::sm/int] + [:quotes-media-storage-bytes-per-team {:optional true} ::sm/int] [:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-max-age {:optional true} ::ct/duration] diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 3dff04fa10..a5e1b9672a 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -40,6 +40,12 @@ (declare create-file-media-object) +(def ^:private sql:get-team-id-for-file + "SELECT p.team_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE f.id = ?") + (def ^:private schema:upload-file-media-object [:map {:title "upload-file-media-object"} [:id {:optional true} ::sm/uuid] @@ -58,6 +64,12 @@ (media.v/validate-media-type! content) (media.v/validate-media-size! content) + (let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))] + (quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id profile-id + ::quotes/team-id team-id + ::quotes/incr (:size content)})) + (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if ;; file is not already deleted diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj index 0a7004cc54..e391f0b0ac 100644 --- a/backend/src/app/rpc/quotes.clj +++ b/backend/src/app/rpc/quotes.clj @@ -546,6 +546,76 @@ (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) (generic-check!))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private schema:media-storage-bytes-per-team + [:map + [::profile-id ::sm/uuid] + [::team-id ::sm/uuid]]) + +(def ^:private valid-media-storage-bytes-per-team-quote? + (sm/lazy-validator schema:media-storage-bytes-per-team)) + +(def ^:private sql:get-media-storage-bytes-per-team + "SELECT COALESCE(SUM(so.size), 0) AS total + FROM ( + SELECT fmo.media_id AS so_id + FROM file_media_object AS fmo + JOIN file AS f ON (f.id = fmo.file_id) + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND fmo.deleted_at IS NULL + AND f.deleted_at IS NULL + UNION + SELECT fmo.thumbnail_id AS so_id + FROM file_media_object AS fmo + JOIN file AS f ON (f.id = fmo.file_id) + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND fmo.thumbnail_id IS NOT NULL + AND fmo.deleted_at IS NULL + AND f.deleted_at IS NULL + UNION + SELECT v.otf_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.otf_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.ttf_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.ttf_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.woff1_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.woff1_file_id IS NOT NULL + AND v.deleted_at IS NULL + UNION + SELECT v.woff2_file_id AS so_id + FROM team_font_variant AS v + WHERE v.team_id = ? + AND v.woff2_file_id IS NOT NULL + AND v.deleted_at IS NULL + ) AS refs + JOIN storage_object AS so ON (so.id = refs.so_id) + WHERE so.deleted_at IS NULL") + +(defmethod check-quote ::media-storage-bytes-per-team + [{:keys [::profile-id ::team-id ::target] :as quote}] + (assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters") + (-> quote + (assoc ::default (cf/get :quotes-media-storage-bytes-per-team + (* 20 1024 1024 1024))) + (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id]) + (assoc ::count-sql [sql:get-media-storage-bytes-per-team + team-id team-id team-id team-id team-id team-id]) + (generic-check!))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUOTE: DEFAULT ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj index 94db804e17..b279ff96d3 100644 --- a/backend/test/backend_tests/rpc_quotes_test.clj +++ b/backend/test/backend_tests/rpc_quotes_test.clj @@ -338,3 +338,137 @@ (check-ok! 4) (check-ko! 5)))) + +(t/deftest media-storage-bytes-per-team-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 1000})}] + + (let [profile-1 (th/create-profile* 1) + profile-2 (th/create-profile* 2) + team-id (:default-team-id profile-1) + data {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id (:id profile-1) + ::quotes/team-id team-id + ::quotes/incr 500} + + check-ok! (fn [msg] + (quotes/check! th/*system* data) + (t/is (true? true) msg)) + check-ko! (fn [msg] + (try + (quotes/check! th/*system* data) + (t/is false (str msg " — expected exception but none thrown")) + (catch Exception e + (let [ed (ex-data e)] + (t/is (= :restriction (:type ed))) + (t/is (= :max-quote-reached (:code ed))) + (t/is (= "media-storage-bytes-per-team" (:target ed)))))))] + + ;; Under default limit (1000) with incr=500 and no existing storage — ok + (check-ok! "first check under limit") + + ;; Insert a quote row for another profile on the same team — does not help + (th/db-insert! :usage-quote + {:profile-id (:id profile-2) + :target "media-storage-bytes-per-team" + :quote 100}) + + ;; Insert a team+profile quote that is still too low + (th/db-insert! :usage-quote + {:team-id team-id + :profile-id (:id profile-2) + :target "media-storage-bytes-per-team" + :quote 200}) + + ;; Insert a team-level quote (no profile) that is still too low + (th/db-insert! :usage-quote + {:team-id team-id + :target "media-storage-bytes-per-team" + :quote 400}) + + ;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked + (check-ko! "blocked by team-level quote") + + ;; Insert a team+profile quote that allows it + (th/db-insert! :usage-quote + {:team-id team-id + :profile-id (:id profile-1) + :target "media-storage-bytes-per-team" + :quote 1000}) + + ;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok + (check-ok! "allowed by team+profile quote")))) + +(t/deftest media-storage-bytes-quote-deduped + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 1100})}] + + (let [prof (th/create-profile* 1) + team-id (:default-team-id prof) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id team-id}) + file1 (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + file2 (th/create-file* 2 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + + ;; One physical storage object of 500 bytes + so-id (uuid/random) + _ (th/db-insert! :storage-object {:id so-id + :size 500 + :backend "test"}) + + ;; Two file_media_object rows pointing at the SAME storage object + ;; (simulates the deduplication path: same content uploaded twice) + _ (th/create-file-media-object* + {:file-id (:id file1) :media-id so-id + :name "icon" :mtype "image/svg+xml"}) + _ (th/create-file-media-object* + {:file-id (:id file2) :media-id so-id + :name "icon" :mtype "image/svg+xml"}) + + data {::quotes/id ::quotes/media-storage-bytes-per-team + ::quotes/profile-id (:id prof) + ::quotes/team-id team-id + ::quotes/incr 200}] + + ;; Physical size is 500. With UNION (correct), total=500, 500+200=700 ≤ 1100 → ok. + ;; With UNION ALL (buggy), total=1000, 1000+200=1200 > 1100 → rejected. + (quotes/check! th/*system* data) + (t/is (true? true) "deduped storage counted once, under quota")))) + +(t/deftest media-upload-enforces-storage-quote + (with-mocks [mock {:target 'app.config/get + :return (th/config-get-mock + {:quotes-media-storage-bytes-per-team 100})}] + + (let [prof (th/create-profile* 1) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:id proj) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + + params {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile} + + out (th/command! params)] + + ;; 312043 bytes > 100 byte limit → should be rejected + (t/is (not (th/success? out))) + (let [error (:error out)] + (t/is (= :restriction (th/ex-type error))) + (t/is (= :max-quote-reached (th/ex-code error))) + (t/is (= "media-storage-bytes-per-team" (:target (ex-data error)))))))) From 7079d33ae197e4d9a7f62dca0ec6fee9365217a8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 26 Aug 2026 13:05:36 +0200 Subject: [PATCH 191/298] :bug: Enforce ownership check on tempfile bucket access (#11270) * :bug: Enforce ownership check on tempfile bucket access The upload-tempfile RPC stores profile-id with tempfile objects, but objects-handler never verified the requester was the owner. Any authenticated user who knew the UUID could access the tempfile. Add ownership check: tempfile bucket now requires the request's profile-id to match the stored profile-id. Returns 404 on mismatch (not 403) to avoid leaking object existence. Legacy tempfiles without stored profile-id remain accessible to any authenticated user for backward compatibility. Closes #11269 AI-assisted-by: qwen3.7-plus * :recycle: Extract tempfile-bucket constant and fix docstring indentation Extract the 'tempfile' bucket string literal into a named constant (sto/tempfile-bucket) to prevent typos and make future bucket renames trivial. Updated 9 occurrences across 7 files. Also fixed minor docstring indentation inconsistency in authenticated? function. AI-assisted-by: qwen3.7-plus * :recycle: Refactor process-bucket! and authenticated? helpers Replace case with cond in process-bucket! to properly resolve sto/tempfile-bucket var from another namespace (case does not evaluate qualified vars at compile time). Redefine authenticated? in terms of request-profile-id to remove duplicated lookup logic. Closes #11269 AI-assisted-by: mimo-v2.5-pro --- backend/src/app/http/assets.clj | 25 +++++- backend/src/app/rpc/commands/binfile.clj | 2 +- backend/src/app/rpc/commands/fonts.clj | 2 +- backend/src/app/rpc/commands/media.clj | 2 +- backend/src/app/rpc/management/exporter.clj | 2 +- backend/src/app/storage.clj | 8 +- backend/src/app/storage/gc_touched.clj | 21 ++--- .../test/backend_tests/http_assets_test.clj | 82 +++++++++++++++++-- 8 files changed, 120 insertions(+), 24 deletions(-) diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index 22783be1e2..b0adb45b13 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -95,17 +95,32 @@ (let [bucket (-> obj meta :bucket)] (not (contains? public-buckets bucket)))) +(defn- request-profile-id + "Extract the authenticated profile-id from the request." + [request] + (or (::session/profile-id request) + (::actoken/profile-id request))) + (defn- authenticated? "Check if the request has an authenticated profile, either via session or access token." [request] - (or (some? (::session/profile-id request)) - (some? (::actoken/profile-id request)))) + (some? (request-profile-id request))) + +(defn- tempfile-owner-match? + "Check if the request's profile-id matches the tempfile's stored owner. + Returns true if no profile-id was stored (legacy objects)." + [obj request] + (let [stored-profile-id (:profile-id (meta obj)) + request-profile-id (request-profile-id request)] + (or (nil? stored-profile-id) + (= stored-profile-id request-profile-id)))) (defn objects-handler "Handler that serves storage objects by id. For non-public buckets (e.g. profile), requires authentication - via session cookie or access token." + via session cookie or access token. + For tempfile bucket, also requires ownership (profile-id match)." [{:keys [::sto/storage] :as cfg} request] (let [id (get-id request) obj (sto/get-object storage id)] @@ -117,6 +132,10 @@ (not (authenticated? request))) {::yres/status 401} + (and (= (-> obj meta :bucket) sto/tempfile-bucket) + (not (tempfile-owner-match? obj request))) + {::yres/status 404} + :else (serve-object cfg obj)))) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ec4510200d..44b7014968 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -60,7 +60,7 @@ {::sto/content data ::sto/touched-at (ct/in-future {:minutes 60}) :content-type "application/zip" - :bucket "tempfile"})] + :bucket sto/tempfile-bucket})] (-> (cf/get :public-uri) (u/join "/assets/by-id/") diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 7b5ac6ac4e..4ab02627bc 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -353,7 +353,7 @@ ::sto/touched-at (ct/in-future {:minutes 30}) :profile-id profile-id :content-type mtype - :bucket "tempfile"}] + :bucket sto/tempfile-bucket}] (sto/put-object! storage content))) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index a5e1b9672a..ffa94d5a6b 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -379,7 +379,7 @@ ::sto/deduplicate? false ::sto/touch true :content-type (:mtype content) - :bucket "tempfile" + :bucket sto/tempfile-bucket :upload-id (str session-id) :chunk-index index})) diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index f4b7d9547f..f6cdbdd820 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -43,7 +43,7 @@ ::sto/touched-at (ct/in-future {:minutes 10}) :profile-id profile-id :content-type (:mtype content) - :bucket "tempfile"} + :bucket sto/tempfile-bucket} object (sto/put-object! storage content)] {:id (:id object) :uri (-> (cf/get :public-uri) diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index f30d8762ec..019536e233 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -38,6 +38,10 @@ (def default-bucket "file-media-object") +(def tempfile-bucket + "Bucket name for temporary file uploads (10-minute expiry)." + "tempfile") + (def valid-buckets #{"file-media-object" "team-font-variant" @@ -45,7 +49,7 @@ "file-thumbnail" "profile" "organization" - "tempfile" + tempfile-bucket "file-data" "file-data-fragment" "file-change"}) @@ -136,7 +140,7 @@ result (when (and (::deduplicate? params) (:hash mdata) (:bucket mdata) - (not= "tempfile" (:bucket mdata))) + (not= tempfile-bucket (:bucket mdata))) (let [result (get-database-object-by-hash connectable backend (:bucket mdata) (:hash mdata))] diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index b7ace59ef3..49bce333ed 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -149,7 +149,7 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= "tempfile" bucket) + (let [deletion-delay (if (= sto/tempfile-bucket bucket) (ct/duration {:hours 2}) (cf/get-deletion-delay))] (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) @@ -158,15 +158,16 @@ (defn- process-bucket! [conn bucket objects] - (case bucket - "file-media-object" (process-objects! conn has-file-media-object-refs? bucket objects) - "team-font-variant" (process-objects! conn has-team-font-variant-refs? bucket objects) - "file-object-thumbnail" (process-objects! conn has-file-object-thumbnails-refs? bucket objects) - "file-thumbnail" (process-objects! conn has-file-thumbnails-refs? bucket objects) - "profile" (process-objects! conn has-profile-refs? bucket objects) - "file-data" (process-objects! conn has-file-data-refs? bucket objects) - "tempfile" (process-objects! conn (constantly false) bucket objects) - "organization" (process-objects! conn (constantly false) bucket objects) + (cond + (= bucket "file-media-object") (process-objects! conn has-file-media-object-refs? bucket objects) + (= bucket "team-font-variant") (process-objects! conn has-team-font-variant-refs? bucket objects) + (= bucket "file-object-thumbnail") (process-objects! conn has-file-object-thumbnails-refs? bucket objects) + (= bucket "file-thumbnail") (process-objects! conn has-file-thumbnails-refs? bucket objects) + (= bucket "profile") (process-objects! conn has-profile-refs? bucket objects) + (= bucket "file-data") (process-objects! conn has-file-data-refs? bucket objects) + (= bucket sto/tempfile-bucket) (process-objects! conn (constantly false) sto/tempfile-bucket objects) + (= bucket "organization") (process-objects! conn (constantly false) bucket objects) + :else (ex/raise :type :internal :code :unexpected-unknown-reference :hint (dm/fmt "unknown reference '%'" bucket)))) diff --git a/backend/test/backend_tests/http_assets_test.clj b/backend/test/backend_tests/http_assets_test.clj index e4f5ebff43..d689e9d2a6 100644 --- a/backend/test/backend_tests/http_assets_test.clj +++ b/backend/test/backend_tests/http_assets_test.clj @@ -37,11 +37,16 @@ (assoc storage ::sto/backend :fs)) (defn- create-storage-object! - "Create a storage object with the given bucket and content." - [storage bucket content] - (sto/put-object! storage {::sto/content (sto/content content) - :bucket bucket - :content-type "text/plain"})) + "Create a storage object with the given bucket and content. + Optional opts map can include :profile-id to set the owner." + ([storage bucket content] + (create-storage-object! storage bucket content {})) + ([storage bucket content {:keys [profile-id]}] + (sto/put-object! storage (cond-> {::sto/content (sto/content content) + :bucket bucket + :content-type "text/plain"} + (some? profile-id) + (assoc :profile-id profile-id))))) (defn- make-handler-cfg "Build a minimal cfg map for the assets handlers." @@ -708,3 +713,70 @@ ::session/profile-id (:id profile)} response (assets/objects-handler cfg request)] (t/is (= 404 (::yres/status response))))) + +;; ---------------------------------------------------------------- +;; Tests: objects-handler — tempfile bucket ownership (T9-F-10) +;; ---------------------------------------------------------------- + +(t/deftest objects-handler-tempfile-owner-can-access + ;; Owner of a tempfile should be able to access it via session auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id owner)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-non-owner-gets-404 + ;; Non-owner accessing a tempfile should get 404 (not 403, to avoid leaking existence). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + stranger (th/create-profile* 2) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-access-token-owner-can-access + ;; Owner of a tempfile should be able to access it via access token auth. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::actoken/profile-id (:id owner)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-access-token-non-owner-gets-404 + ;; Non-owner accessing a tempfile via access token should get 404. + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + owner (th/create-profile* 1) + stranger (th/create-profile* 2) + object (create-storage-object! storage "tempfile" "temp data" {:profile-id (:id owner)}) + request {:path-params {:id (str (:id object))} + ::actoken/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 404 (::yres/status response))))) + +(t/deftest objects-handler-tempfile-no-stored-profile-id-serves + ;; Legacy tempfile objects without stored profile-id should be accessible + ;; to any authenticated user (backward compatibility). + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + cfg (make-handler-cfg storage) + stranger (th/create-profile* 1) + object (create-storage-object! storage "tempfile" "legacy temp data") + request {:path-params {:id (str (:id object))} + ::session/profile-id (:id stranger)} + response (assets/objects-handler cfg request)] + (t/is (= 204 (::yres/status response))))) From 4adfa5d2f2f6e15df603fc00add6e7fab3f99c47 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 13:35:21 +0000 Subject: [PATCH 192/298] :bug: Require edition permissions for get-team-invitation-token The handler previously allowed any team member (including viewers) to generate invitation tokens. Now requires at least edition-level permissions (can-edit, admin, or owner). Closes #11358 AI-assisted-by: longcat-2.0 --- .../app/rpc/commands/teams_invitations.clj | 2 +- backend/test/backend_tests/rpc_team_test.clj | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index b96cb0a8ce..729205c4c9 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -603,7 +603,7 @@ ::doc/module :teams ::sm/params schema:get-team-invitation-token} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (teams/check-read-permissions! cfg profile-id team-id) + (teams/check-edition-permissions! cfg profile-id team-id) (let [email (profile/clean-email email) invit (-> (db/get pool :team-invitation {:team-id team-id diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 4bca0fbaa2..22455ce66b 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -357,6 +357,28 @@ (t/is (= (:id profile2) (:member-id claims)))))))) +(t/deftest get-team-invitation-token-requires-edition-permissions + (let [profile1 (th/create-profile* 1 {:is-active true}) + profile2 (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + pool (:app.db/pool th/*system*)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile2) + :role :viewer}) + (db/insert! pool :team-invitation + {:team-id (:id team) + :email-to "victim@example.com" + :role "editor" + :valid-until (ct/in-future "48h")}) + (let [data {::th/type :get-team-invitation-token + ::rpc/profile-id (:id profile2) + :team-id (:id team) + :email "victim@example.com"} + out (th/command! data)] + (t/is (not (th/success? out))) + (t/is (= :not-found (-> out :error ex-data :type)))))) + + (t/deftest accept-invitation-tokens (let [profile1 (th/create-profile* 1 {:is-active true}) profile2 (th/create-profile* 2 {:is-active true}) @@ -366,14 +388,7 @@ pool (:app.db/pool th/*system*)] - (let [token (tokens/generate th/*system* - {:iss :team-invitation - :exp (ct/in-future "1h") - :profile-id (:id profile1) - :role :editor - :team-id (:id team) - :member-email (:email profile2) - :member-id (:id profile2)})] + (let [token (tokens/generate th/*system*)] (t/testing "Verify token as anonymous user" (db/insert! pool :team-invitation From 33f13f9bfd41142c32a045edcba3be0df65d24d0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 14:14:25 +0000 Subject: [PATCH 193/298] :bug: Fix test formatting for get-team-invitation-token AI-assisted-by: longcat-2.0 --- backend/test/backend_tests/rpc_team_test.clj | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 22455ce66b..bf803e57c7 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -388,7 +388,14 @@ pool (:app.db/pool th/*system*)] - (let [token (tokens/generate th/*system*)] + (let [token (tokens/generate th/*system* + {:iss :team-invitation + :exp (ct/in-future "1h") + :profile-id (:id profile1) + :role :editor + :team-id (:id team) + :member-email (:email profile2) + :member-id (:id profile2)})] (t/testing "Verify token as anonymous user" (db/insert! pool :team-invitation From 33e39bc7ed789e504edd9e1fed3acec1b2bdeb50 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 25 Aug 2026 14:59:52 +0000 Subject: [PATCH 194/298] :wrench: Fix backend format check script in CI The backend format check was using 'check-fmt' instead of 'check-fmt:clj', causing CI to always fail on the fmt step. Closes #11358 AI-assisted-by: longcat-2.0 --- scripts/ci | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/ci b/scripts/ci index f78bbd0795..cff93eca3a 100755 --- a/scripts/ci +++ b/scripts/ci @@ -45,24 +45,24 @@ declare -A TEST_CMD=( declare -A FMT_CHECK_CMD=( [frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss" - [backend]="pnpm run check-fmt" + [backend]="pnpm run check-fmt:clj" [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" [render-wasm]="cargo fmt --check" [exporter]="pnpm run check-fmt:clj" [mcp]="pnpm run fmt:check" [plugins]="pnpm run format:check" - [library]="pnpm run check-fmt" + [library]="pnpm run check-fmt:clj" ) declare -A FMT_FIX_CMD=( [frontend]="pnpm run fmt:clj && pnpm run fmt:js && pnpm run fmt:scss" - [backend]="pnpm run fmt" + [backend]="pnpm run fmt:clj" [common]="pnpm run fmt:clj && pnpm run fmt:js" [render-wasm]="cargo fmt" [exporter]="pnpm run fmt:clj" [mcp]="pnpm run fmt" [plugins]="pnpm run format" - [library]="pnpm run fmt" + [library]="pnpm run fmt:clj" ) declare -A PAREN_REPAIR_CMD=( From e1a2d0b932ebca87efbf4cf3dad3b68197336870 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 26 Aug 2026 17:24:59 +0200 Subject: [PATCH 195/298] :bug: Fix nitrate sso failure message (#11214) --- backend/src/app/auth/oidc.clj | 180 ++++++++++++------ backend/test/backend_tests/auth_oidc_test.clj | 136 +++++++++++++ frontend/src/app/main/errors.cljs | 6 + .../test/frontend_tests/main_errors_test.cljs | 47 ++++- 4 files changed, 306 insertions(+), 63 deletions(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 164edafd76..9fb3e75bd1 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -42,31 +42,52 @@ ;; OIDC PROVIDER (GENERIC) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defn- raise-invalid-sso-config + "Raise a controlled validation error for OIDC provider configuration failures." + [& {:keys [hint cause] :as params}] + (throw (ex-info (or hint "invalid-sso-config") + (-> params + (dissoc :cause) + (assoc :type :validation + :code :invalid-sso-config)) + cause))) + (defn- discover-oidc-config [cfg {:keys [base-uri skip-ssrf-check?] :as provider}] - (let [uri (u/join base-uri ".well-known/openid-configuration") - rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})] + (let [uri (u/join base-uri ".well-known/openid-configuration")] + (try + (let [rsp (http/req cfg {:method :get :uri (dm/str uri)} {:skip-ssrf-check? skip-ssrf-check?})] + (if (= 200 (:status rsp)) + (let [data (-> rsp :body json/decode) + token-uri (get data :token_endpoint) + auth-uri (get data :authorization_endpoint) + user-uri (get data :userinfo_endpoint) + jwks-uri (get data :jwks_uri) + logout-uri (get data :end_session_endpoint)] - (if (= 200 (:status rsp)) - (let [data (-> rsp :body json/decode) - token-uri (get data :token_endpoint) - auth-uri (get data :authorization_endpoint) - user-uri (get data :userinfo_endpoint) - jwks-uri (get data :jwks_uri) - logout-uri (get data :end_session_endpoint)] + (-> provider + (assoc :token-uri token-uri) + (assoc :auth-uri auth-uri) + (assoc :user-uri user-uri) + (assoc :jwks-uri jwks-uri) + (assoc :logout-uri logout-uri))) - (-> provider - (assoc :token-uri token-uri) - (assoc :auth-uri auth-uri) - (assoc :user-uri user-uri) - (assoc :jwks-uri jwks-uri) - (assoc :logout-uri logout-uri))) - - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "unable to discover OIDC configuration" - :discover-uri uri - :response-status-code (:status rsp))))) + (raise-invalid-sso-config + :hint "unable to discover OIDC configuration" + :discover-uri uri + :response-status-code (:status rsp)))) + (catch Throwable cause + ;; Controlled raises above are ExceptionInfo and would otherwise be + ;; re-wrapped by this catch, dropping fields like :response-status-code. + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + ;; Wrap SSRF blocks, DNS failures, TLS errors, etc. — from the caller's + ;; perspective these are all "bad/unreachable issuer URL". + (raise-invalid-sso-config + :hint "unable to discover OIDC configuration" + :discover-uri uri + :cause cause)))))) (def ^:private default-oidc-scopes #{"openid" "profile" "email"}) @@ -107,16 +128,29 @@ (defn- fetch-oidc-jwks [cfg jwks-uri {:keys [skip-ssrf-check?]}] - (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})] - (if (= 200 status) - (-> body json/decode :keys process-oidc-jwks) - (ex/raise :type ::internal - :code :unable-to-fetch-sso-jwks - :hint "unable to retrieve JWKs (unexpected response status code)" - :response-status-code status)))) + (try + (let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri} {:skip-ssrf-check? skip-ssrf-check?})] + (if (= 200 status) + (-> body json/decode :keys process-oidc-jwks) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs (unexpected response status code)" + :jwks-uri jwks-uri + :response-status-code status))) + (catch Throwable cause + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs" + :jwks-uri jwks-uri + :cause cause))))) (defn- populate-jwks - "Fetch and Add (if possible) JWK's to the OIDC provider" + "Fetch and add JWKs to the OIDC provider. + + When `:strict-jwks?` is set (organization SSO), failures raise a controlled + validation error. Otherwise JWKS is best-effort: log and continue without keys + so global OIDC/GitLab providers can still initialize if JWKS is temporarily down." [cfg provider] (try (if-let [jwks (when-let [jwks-uri (:jwks-uri provider)] @@ -124,20 +158,28 @@ (assoc provider :jwks jwks) provider) (catch Throwable cause - (l/warn :hint "unable to fetch JWKs for the OIDC provider" - :provider (str (:id provider)) - :cause cause) - provider))) + (if (:strict-jwks? provider) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unable to retrieve JWKs" + :provider (:id provider) + :cause cause)) + (do + (l/warn :hint "unable to fetch JWKs for the OIDC provider" + :provider (str (:id provider)) + :cause cause) + provider))))) (defn- prepare-oidc-provider [cfg params] (when-not (and (string? (:base-uri params)) (string? (:client-id params)) (string? (:client-secret params))) - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "missing params for provider initialization" - :provider (:id params))) + (raise-invalid-sso-config + :hint "missing params for provider initialization" + :provider (:id params))) (try (if (and (string? (:token-uri params)) @@ -150,11 +192,13 @@ (with-meta provider {::discovered true}))) (catch Throwable cause - (ex/raise :type ::internal - :type :invalid-sso-config - :hint "unexpected exception on configuring provider" - :provider (:id params) - :cause cause)))) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unexpected exception on configuring provider" + :provider (:id params) + :cause cause))))) (defmethod ig/assert-key ::providers/generic [_ params] @@ -322,10 +366,9 @@ [cfg params] (when-not (and (string? (:client-id params)) (string? (:client-secret params))) - (ex/raise :type ::internal - :code :invalid-sso-config - :hint "missing params for provider initialization" - :provider (:id params))) + (raise-invalid-sso-config + :hint "missing params for provider initialization" + :provider (:id params))) (try (let [provider (populate-jwks cfg params)] @@ -336,11 +379,13 @@ :client-secret (d/obfuscate-string (:client-secret provider))) provider) (catch Throwable cause - (ex/raise :type ::internal - :type :invalid-sso-config - :hint "unexpected exception on configuring provider" - :provider (:id params) - :cause cause)))) + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw cause) + (raise-invalid-sso-config + :hint "unexpected exception on configuring provider" + :provider (:id params) + :cause cause))))) (defmethod ig/init-key ::providers/gitlab [_ cfg] @@ -867,7 +912,10 @@ :base-uri (some-> (non-blank-uri issuer) (str/rtrim "/") (str "/")) - :scopes default-oidc-scopes})) + :scopes default-oidc-scopes + ;; Organization SSO is configured by customers; discovery + ;; and JWKS failures must surface as controlled errors. + :strict-jwks? true})) (defn build-organization-sso-auth-redirect-uri "Build the OIDC authorization redirect URI for an organization SSO config. @@ -877,16 +925,24 @@ issuer (organization-sso-discovery-uri sso) dest-url (or dest-url (str (cf/get :public-uri)))] (when-not issuer - (ex/raise :type :validation - :code :invalid-sso-config - :hint "missing issuer")) - (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso)) - state-token (tokens/generate cfg {:iss "oidc" - :dest-url dest-url - :organization-id organization-id - :issuer issuer - :exp (ct/in-future "4h")})] - (build-auth-redirect-uri oidc-provider state-token)))) + (raise-invalid-sso-config + :hint "missing issuer" + :organization-id organization-id)) + (try + (let [oidc-provider (or provider (prepare-organization-sso-provider cfg sso)) + state-token (tokens/generate cfg {:iss "oidc" + :dest-url dest-url + :organization-id organization-id + :issuer issuer + :exp (ct/in-future "4h")})] + (build-auth-redirect-uri oidc-provider state-token)) + (catch Throwable cause + (if (and (ex/error? cause) + (= :invalid-sso-config (:code (ex-data cause)))) + (throw (ex-info (ex-message cause) + (assoc (ex-data cause) :organization-id organization-id) + (ex-cause cause))) + (throw cause)))))) (def ^:private probe-auth-code "penpot-sso-config-probe") diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index b99de502c4..29469e36d7 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -15,6 +15,7 @@ [app.setup :as-alias setup] [app.tokens :as tokens] [clojure.test :as t] + [cuerdas.core :as str] [mockery.core :refer [with-mocks]] [yetti.response :as-alias yres])) @@ -587,3 +588,138 @@ :issuer "https://idp.example.com"}) (t/is (not (true? (:skip-ssrf-check? @captured-params))) "SSRF protection must be disabled for organization SSO"))))) + +(defn- ssl-handshake-failure + [] + (javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake")) + +(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure + (t/testing "SSL/network failures during OIDC discovery become controlled validation errors" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] (throw (ssl-handshake-failure)))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://wrong-idp.example.com"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))))))) + +(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200 + (t/testing "non-200 OIDC discovery responses become controlled validation errors" + (with-mocks [http-mock {:target 'app.http.client/req + :return {:status 404 :body "not found"}}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (catch Throwable t t)) + data (ex-data e)] + (t/is (ex/error? e)) + (t/is (= :validation (:type data))) + (t/is (= :invalid-sso-config (:code data))) + (t/is (= 404 (:response-status-code data))) + (t/is (= "unable to discover OIDC configuration" (ex-message e))) + (t/is (str/includes? (str (:discover-uri data)) "openid-configuration")))))) + +(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer + (t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri host could not be resolved"))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://unresolvable.invalid"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e))))))))) + +(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure + (t/testing "SSL/network failures while fetching JWKs become controlled validation errors" + (let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\"," + "\"token_endpoint\":\"https://idp.example.com/token\"," + "\"userinfo_endpoint\":\"https://idp.example.com/userinfo\"," + "\"jwks_uri\":\"https://idp.example.com/jwks\"}")] + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [_cfg request & _] + (if (str/includes? (str (:uri request)) "openid-configuration") + {:status 200 :body discovery-body} + (throw (ssl-handshake-failure))))}] + (let [e (try + (#'oidc/prepare-organization-sso-provider + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://idp.example.com"}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e))))))))) + +(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors + (t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them" + (with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks + :side-effect (fn [& _] + (ex/raise :type :validation + :code :ssrf-blocked-target + :hint "uri host could not be resolved"))}] + (let [e (try + (#'oidc/populate-jwks + {} + {:id "oidc" + :jwks-uri "https://idp.example.com/jwks" + :strict-jwks? true}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e))))))))) + +(t/deftest populate-jwks-strict-rethrows-invalid-sso-config + (t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config" + (with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks + :side-effect (fn [& _] + (ex/raise :type :validation + :code :invalid-sso-config + :hint "unable to retrieve JWKs" + :jwks-uri "https://idp.example.com/jwks"))}] + (let [e (try + (#'oidc/populate-jwks + {} + {:id "oidc" + :jwks-uri "https://idp.example.com/jwks" + :strict-jwks? true}) + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :invalid-sso-config (:code (ex-data e)))) + (t/is (= "unable to retrieve JWKs" (ex-message e))) + (t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e)))))))) + +(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider + (t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable" + (with-mocks [http-mock {:target 'app.http.client/req + :side-effect (fn [& _] (throw (ssl-handshake-failure)))}] + (let [e (try + (oidc/build-organization-sso-auth-redirect-uri + {} + {:client-id "test-client" + :client-secret "test-secret" + :issuer "https://wrong-idp.example.com"} + :dest-url "https://localhost:3449/#/dashboard" + :organization-id #uuid "00000000-0000-0000-0000-000000000001") + (catch Throwable t t))] + (t/is (ex/error? e)) + (t/is (= :validation (:type (ex-data e)))) + (t/is (= :invalid-sso-config (:code (ex-data e)))))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index f8b5ad9dc4..48aba11881 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -389,6 +389,12 @@ :level :error :timeout 3000}))) + (= code :invalid-sso-config) + ;; SSO error page needs :organization-id to retry + (if (:organization-id error) + (st/async-emit! (rt/assign-exception (assoc error :type :sso-error))) + (st/async-emit! (rt/assign-exception error))) + :else (st/async-emit! (rt/assign-exception error)))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index 207b295a11..d35af2f2ca 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -12,7 +12,8 @@ - exception->error-data – pure transformer - on-error re-entrancy guard – prevents recursive invocations - flash schedules async emit – ntf/show is not emitted synchronously - - organization SSO recovery – expired SSO sessions go back to the provider" + - organization SSO recovery – expired SSO sessions go back to the provider + - invalid-sso-config handler – requires :organization-id to promote to :sso-error" (:require [app.main.errors :as errors] [app.main.repo :as rp] @@ -351,3 +352,47 @@ (t/is (nil? @assigned*)) (done')))) done)))) + +;; --------------------------------------------------------------------------- +;; :validation / :invalid-sso-config +;; +;; The SSO error page needs an organization-id to retry meaningfully. Promote +;; to :sso-error only when that id is present; otherwise keep :validation so +;; we do not surface a broken SSO dialog for a future code path that omits it. +;; --------------------------------------------------------------------------- + +(defn- capture-async-exception + "Invoke `ptk/handle-error` while capturing the error map passed to + `rt/assign-exception` via `st/async-emit!`. + + `st/async-emit!` is variadic (`[& params]`); the mock must be too, + otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws." + [error] + (let [captured (atom nil)] + (with-redefs [st/async-emit! (fn [& events] + (reset! captured (first events))) + rt/assign-exception (fn [err] err)] + (ptk/handle-error error) + @captured))) + +(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error + (t/testing "invalid-sso-config with :organization-id is shown as :sso-error" + (let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assigned (capture-async-exception + {:type :validation + :code :invalid-sso-config + :organization-id org-id + :hint "missing issuer"})] + (t/is (= :sso-error (:type assigned))) + (t/is (= org-id (:organization-id assigned))) + (t/is (= :invalid-sso-config (:code assigned)))))) + +(t/deftest invalid-sso-config-without-organization-id-keeps-validation + (t/testing "invalid-sso-config without :organization-id must not become :sso-error" + (let [assigned (capture-async-exception + {:type :validation + :code :invalid-sso-config + :hint "missing issuer"})] + (t/is (= :validation (:type assigned))) + (t/is (nil? (:organization-id assigned))) + (t/is (= :invalid-sso-config (:code assigned)))))) From 4be749d45f1d34a900d912ddf24e47f7d8ec10f2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 26 Aug 2026 19:18:59 +0200 Subject: [PATCH 196/298] :sparkles: Add minor improvements to scripts/gh.py --- scripts/gh.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/gh.py b/scripts/gh.py index c7e1f87aca..f8b4f74d45 100755 --- a/scripts/gh.py +++ b/scripts/gh.py @@ -473,8 +473,10 @@ query($owner: String!, $repo: String!, $milestone: Int!, $cursor: String) { state mergedAt createdAt + headRefName author { login } labels(first: 20) { nodes { name } } + files(first: 100) { nodes { path } } closingIssuesReferences(first: 5) { nodes { number } } } } @@ -494,8 +496,9 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]: states: GraphQL states enum array literal, e.g. ``"[MERGED]"`` or ``"[OPEN CLOSED MERGED]"`` Returns: - List of {number, title, body, state, merged_at, created_at, author, - labels: [str], closing_issues: [int]} + List of {number, title, body, state, merged_at, created_at, + head_ref_name, author, labels: [str], files: [str], + closing_issues: [int]} """ query = GQL_MILESTONE_PRS_QUERY.replace("__STATES__", states) all_nodes: list[dict] = [] @@ -522,8 +525,10 @@ def fetch_milestone_prs(milestone_num: int, states: str) -> list[dict]: "state": node["state"], "merged_at": node.get("mergedAt"), "created_at": node.get("createdAt"), + "head_ref_name": node.get("headRefName"), "author": node["author"]["login"] if node["author"] else None, "labels": [lbl["name"] for lbl in node["labels"]["nodes"]], + "files": [file["path"] for file in node["files"]["nodes"]], "closing_issues": [iss["number"] for iss in node["closingIssuesReferences"]["nodes"]], }) @@ -602,7 +607,20 @@ def cmd_prs(args: argparse.Namespace) -> None: def fetch_advisories() -> list[dict]: """Fetch all security advisories for the repository via REST API.""" - return run_gh_rest(f"repos/{REPO}/security-advisories") + all_advisories: list[dict] = [] + page = 1 + + while True: + advisories = run_gh_rest( + f"repos/{REPO}/security-advisories?per_page=100&page={page}" + ) + all_advisories.extend(advisories) + + if len(advisories) < 100: + break + page += 1 + + return all_advisories def fetch_advisory(ghsa_id: str) -> dict: From 6e173a02fb2f32dc734dd27398f8a4b11fa2e4a8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 26 Aug 2026 18:11:40 +0000 Subject: [PATCH 197/298] :books: Split backend testing memory and link from testing skill Extract the backend Testing section from backend/core into a dedicated backend/testing memory, following the pattern of common, frontend, and exporter. Update the testing skill and root testing memory to point at the new location, and add exporter/testing to the skill's required reading list. AI-assisted-by: deepseek-v4-flash --- .opencode/skills/testing/SKILL.md | 3 ++- .serena/memories/backend/core.md | 8 ++------ .serena/memories/backend/testing.md | 11 +++++++++++ .serena/memories/testing.md | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .serena/memories/backend/testing.md diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md index 5ad5d04e6e..29ab7112f8 100644 --- a/.opencode/skills/testing/SKILL.md +++ b/.opencode/skills/testing/SKILL.md @@ -34,7 +34,8 @@ Before writing any test, read: 2. Module-specific testing memory for the affected module: - `mem:common/testing` — CLJC unit tests - `mem:frontend/testing` — CLJS unit tests, Playwright E2E - - `mem:backend/core` — JVM clojure.test conventions + - `mem:backend/testing` — JVM clojure.test conventions + - `mem:exporter/testing` — exporter unit tests ## Key Rules diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 7b085856d1..9a567932b7 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -102,9 +102,5 @@ misleading linter/compiler output. See `mem:scripts/paren-repair`. ## Testing -IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline. - -* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. -* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace. -* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas. -* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. +Backend test commands, coverage rules, and conventions: `mem:backend/testing`. +Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`. diff --git a/.serena/memories/backend/testing.md b/.serena/memories/backend/testing.md new file mode 100644 index 0000000000..66d6f8a246 --- /dev/null +++ b/.serena/memories/backend/testing.md @@ -0,0 +1,11 @@ +# Backend Testing + +JVM `clojure.test` (kaocha runner) under `backend/test/backend_tests/`. + +- READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all JVM test runs. +- All CLI commands must be executed from the `backend/` subdirectory. +- Tests are invoked directly via `clojure -M:dev:test` (kaocha) — there is no pnpm wrapper. Kaocha auto-discovers test namespaces, so no runner registration is needed. +- Coverage: if code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated. +- Isolated run: `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace, or `clojure -M:dev:test --focus backend-tests.my-ns-test/my-test-var` for a specific test var. +- Regression run: `clojure -M:dev:test` to ensure no regressions in related functional areas. +- If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. \ No newline at end of file diff --git a/.serena/memories/testing.md b/.serena/memories/testing.md index 295da86212..ce301ff5bb 100644 --- a/.serena/memories/testing.md +++ b/.serena/memories/testing.md @@ -13,7 +13,7 @@ and helpers, consult: builders, production-path change helpers - `mem:frontend/testing` — CLJS unit tests, Playwright E2E integration tests, live browser verification via nREPL -- Backend — JVM `clojure.test` under `backend/test/`; see `mem:backend/core` +- `mem:backend/testing` — JVM `clojure.test` under `backend/test/` ## When to Use From 980ccf15fa0e1ece7130db48a6a6defce50045fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 26 Aug 2026 20:21:57 +0200 Subject: [PATCH 198/298] :construction_worker: Fix names related to concurrency policy for workflow calls --- .github/workflows/build-bundle.yml | 6 +++++- .github/workflows/build-develop.yml | 4 ++++ .github/workflows/build-docker.yml | 6 +++++- .github/workflows/build-staging.yml | 4 ++++ .github/workflows/build-tag.yml | 6 ++++++ .github/workflows/build-tmp-tokens.yml | 4 ++++ 6 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 3ab7900134..b31450ac60 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -17,8 +17,12 @@ on: required: true default: 'develop' +# Literal group name: under `workflow_call`, `github.workflow` resolves to the +# caller's workflow, which put this workflow and the other reusable one called +# by the same caller into a single shared group, and left a manual dispatch of +# the same ref in a group of its own, free to race on the same artifacts. concurrency: - group: ${{ github.workflow }}-${{ inputs.gh_ref }} + group: build-bundle-${{ inputs.gh_ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index b0edcb63cb..961ad1dca9 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -5,6 +5,10 @@ on: schedule: - cron: '16 5-20 * * 1-5' +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 6fffaa4acd..32205c5385 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -16,8 +16,12 @@ on: required: true default: 'develop' +# Literal group name: under `workflow_call`, `github.workflow` resolves to the +# caller's workflow, which put this workflow and the other reusable one called +# by the same caller into a single shared group, and left a manual dispatch of +# the same ref in a group of its own, free to race on the same artifacts. concurrency: - group: ${{ github.workflow }}-${{ inputs.gh_ref }} + group: build-docker-${{ inputs.gh_ref }} cancel-in-progress: true env: diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index ec3003a4b2..1523e4d7df 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -5,6 +5,10 @@ on: schedule: - cron: '36 5-20 * * 1-5' +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 18f81d10f1..aa6a2b8357 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -6,6 +6,12 @@ on: tags: - '*' +# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1 +# must not abort the release already in flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml index 3c21981664..838a97a617 100644 --- a/.github/workflows/build-tmp-tokens.yml +++ b/.github/workflows/build-tmp-tokens.yml @@ -5,6 +5,10 @@ on: schedule: - cron: '46 5-20 * * 1-5' +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: build-bundle: uses: ./.github/workflows/build-bundle.yml From 2bce69f9272f9cc70c701efebb0bd91cb3245833 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Wed, 26 Aug 2026 21:35:07 +0200 Subject: [PATCH 199/298] :bug: Fix increasing value clicking and dragging in a numeric input (#11334) --- frontend/src/app/main/ui/ds/controls/numeric_input.cljs | 9 ++++----- frontend/src/app/main/ui/ds/controls/numeric_input.scss | 4 +--- .../app/main/ui/ds/controls/utilities/input_field.scss | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index ed7d4c4096..f04c30b849 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -558,10 +558,8 @@ (mf/deps disabled is-open is-multiple? ref min max nillable default is-token-applied?) (fn [event] (when-not (or disabled is-open is-multiple? is-token-applied?) - (let [node (mf/ref-val ref) - is-focused (and (some? node) (dom/active? node)) - has-token (some? (deref token-applied-name*))] - (when-not (or is-focused has-token) + (let [has-token (some? (deref token-applied-name*))] + (when-not has-token (let [client-x (.-clientX event) parsed (parse-value (str/trim (mf/ref-val raw-value*)) (mf/ref-val last-value*) min max nillable) start-val (or parsed default 0)] @@ -610,7 +608,8 @@ (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) (when-let [node (mf/ref-val ref)] - (dom/focus! node))) + (dom/focus! node) + (dom/select-text! node))) (when (= state :dragging) (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index d9fb372647..98f09307b4 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -23,9 +23,7 @@ position: relative; &.resizable { - &:not(:focus-within) { - cursor: ew-resize; - } + cursor: ew-resize; } &:hover { diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index a295655f26..9aede7e748 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -96,7 +96,7 @@ } &::selection { - background: var(--color-accent-select); + background: var(--color-accent-background-select); } &::placeholder { From 88a52d1098cb6094a408e517e2f3fd88a6f9a716 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 09:24:53 +0200 Subject: [PATCH 200/298] :paperclip: Update changelog --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index f76af25171..e2874e9492 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.17.2 + +### :bug: Bugs fixed + +- Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272)) + ## 2.17.1 ### :bug: Bugs fixed From f7bdc9786c12757f7abc70e1d8f68ee533583f49 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 27 Aug 2026 09:41:50 +0200 Subject: [PATCH 201/298] :bug: Compare text numeric attrs with float tolerance (#11366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor/WASM round-trips can truncate line-height strings (e.g. 1.3333333333333333 → 1.33333). Exact string compare treated that as a style change and detached typography tokens. --- common/src/app/common/types/text.cljc | 17 +++++++++++++++-- common/test/common_tests/types/text_test.cljc | 11 +++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 6068cfc829..3b830eb9d3 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.flags :as flags] + [app.common.math :as mth] [app.common.types.color :as clr] [app.common.types.fills :as types.fills] [clojure.set :as set] @@ -217,7 +218,10 @@ attributes or other things that may be attached). - Consider nil values, empty strings or empty lists all equal. - Normalize numeric values (legacy) into strings. - - No value is equal than the default value." + - No value is equal than the default value. + - Numeric attrs (e.g. line-height) compare with float tolerance so + editor/WASM round-trips like \"1.3333333333333333\" vs \"1.33333\" + do not count as a real style change (avoids detaching tokens)." [key value1 value2] (when (text-node-attr? key) (let [default-value (get default-text-attrs key) @@ -229,7 +233,16 @@ $))) value1' (normalize-value value1) value2' (normalize-value value2)] - (not= value1' value2')))) + (cond + (= value1' value2') + false + + :else + (let [n1 (when (string? value1') (d/parse-double value1')) + n2 (when (string? value2') (d/parse-double value2'))] + (if (and (some? n1) (some? n2)) + (not (mth/close? n1 n2)) + true)))))) (defn- compare-text-content "Given two content text structures, conformed by maps and vectors, diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index b63a6db6e1..98032b8bc4 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -78,6 +78,14 @@ (def content-changed-line-height (assoc-in content-base [:children 0 :children 0 :line-height] "1.5")) +;; Token/WASM may store full float precision; editor round-trips often +;; truncate (e.g. CSS / f32). These must compare as equal. +(def content-line-height-full-precision + (assoc-in content-base [:children 0 :children 0 :line-height] "1.3333333333333333")) + +(def content-line-height-truncated + (assoc-in content-base [:children 0 :children 0 :line-height] "1.33333")) + (def content-redundant-span-line-height (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) @@ -208,6 +216,8 @@ ;; Other text-node-attr categories attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-line-height-precision (cttx/get-diff-attrs content-line-height-full-precision + content-line-height-truncated) attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height) attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height content-after-editor-roundtrip) @@ -242,6 +252,7 @@ ;; Each text-node-attr category reports correct attr key (t/is (= #{:font-family} attrs-font-family)) (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{} attrs-line-height-precision)) (t/is (= #{} attrs-span-line-height)) (t/is (= #{} attrs-roundtrip-line-height)) (t/is (= #{} attrs-nil-typography-refs)) From 06491a2d24da57c124b369c1d0afbabab50d210d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 09:49:22 +0200 Subject: [PATCH 202/298] :bug: Fix svg-raw child ids stored as strings in existing files (#11387) * :bug: Fix svg-raw child ids stored as strings in existing files Before schema:svg-raw-attrs declared :shapes as a vector of uuid, the JSON decoder had no type information for those child ids and left them as plain strings on any round trip, so they got persisted as strings. Once the schema was tightened those files fail schema validation on load. Add migration 0026-fix-svg-raw-shapes-uuids that coerces the :shapes entries of svg-raw shapes back to uuid instances, in both pages and components. AI-assisted-by: deepseek-v4-flash * :recycle: Drop redundant vector check in svg-raw shapes migration :shapes is always a vector when present, so the guard is unnecessary. AI-assisted-by: deepseek-v4-flash --- common/src/app/common/files/migrations.cljc | 23 +++- .../files_migrations_0026_test.cljc | 110 ++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 common/test/common_tests/files_migrations_0026_test.cljc diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 28174ba84f..873aa1c3ab 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -1978,6 +1978,26 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0026-fix-svg-raw-shapes-uuids" + ;; Before the svg-raw schema declared :shapes as a vector of uuid, + ;; the JSON decoder had no type information for those child ids and + ;; left them as plain strings on any round trip, so they got + ;; persisted as strings. Once the schema was tightened, such files + ;; fail schema validation; this migration parses the strings back + ;; into uuid instances. + [data _] + (letfn [(update-object [object] + (cond-> object + (cfh/svg-raw-shape? object) + (d/update-when :shapes #(mapv uuid/coerce %)))) + + (update-container [container] + (d/update-when container :objects d/update-vals update-object))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -2060,4 +2080,5 @@ "0022-normalize-component-root-and-resync" "0023-repair-token-themes-with-inexistent-sets" "0024b-fix-stroke-cap-placement" - "0025-repair-empty-text-content"])) + "0025-repair-empty-text-content" + "0026-fix-svg-raw-shapes-uuids"])) diff --git a/common/test/common_tests/files_migrations_0026_test.cljc b/common/test/common_tests/files_migrations_0026_test.cljc new file mode 100644 index 0000000000..92dde088e8 --- /dev/null +++ b/common/test/common_tests/files_migrations_0026_test.cljc @@ -0,0 +1,110 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.files-migrations-0026-test + (:require + [app.common.files.migrations :as cfm] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; 0026-fix-svg-raw-shapes-uuids +;; Before the svg-raw schema declared :shapes as a vector of uuid, the +;; JSON decoder had no type information for those child ids and left +;; them as plain strings on any round trip, so they got persisted as +;; strings. Once the schema was tightened, such files fail schema +;; validation; this migration parses the strings back into uuids. + +(defn- make-svg-raw-shape + "Build a minimal svg-raw shape with the supplied :shapes vector. + When `shapes` is nil the :shapes key is omitted, like a leaf svg-raw + shape." + [shape-id shapes] + (cond-> {:id shape-id + :type :svg-raw} + (some? shapes) + (assoc :shapes shapes))) + +(defn- make-other-shape + "Build a minimal non-svg-raw shape that must stay untouched." + [shape-id shapes] + {:id shape-id + :type :group + :shapes shapes}) + +(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-pages + (let [shape-id (uuid/next) + child-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-svg-raw-shape + shape-id + [(str child-id) + "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31"])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + shape (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= 2 (count (:shapes shape))) "child ids preserved") + (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid") + (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb8f3b5ab31" (second (:shapes shape))) + "foreign uuid string parsed to uuid") + (t/is (every? uuid? (:shapes shape)) "all child ids are uuids"))) + +(t/deftest migration-0026-converts-svg-raw-shapes-strings-to-uuids-in-components + (let [shape-id (uuid/next) + child-id (uuid/next) + component-id (uuid/next) + data {:components + {component-id + {:objects + {shape-id (make-svg-raw-shape + shape-id + [(str child-id) + "1c2986ce-4a0f-8001-8007-1fb92196e65f"])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + shape (get-in data' [:components component-id :objects shape-id])] + + (t/is (= 2 (count (:shapes shape))) "child ids preserved") + (t/is (= child-id (first (:shapes shape))) "existing uuid string parsed to uuid") + (t/is (= #uuid "1c2986ce-4a0f-8001-8007-1fb92196e65f" (second (:shapes shape))) + "foreign uuid string parsed to uuid") + (t/is (every? uuid? (:shapes shape)) "all child ids are uuids"))) + +(t/deftest migration-0026-leaves-uuids-and-other-shapes-untouched + (let [svg-raw-id (uuid/next) + child-id (uuid/next) + group-id (uuid/next) + leaf-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {svg-raw-id (make-svg-raw-shape svg-raw-id [child-id]) + group-id (make-other-shape group-id [(str child-id)]) + leaf-id (make-svg-raw-shape leaf-id nil)}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + objects (get-in data' [:pages-index page-id :objects])] + + (t/is (= [child-id] (:shapes (get objects svg-raw-id))) + "already-uuid svg-raw children untouched") + (t/is (= [(str child-id)] (:shapes (get objects group-id))) + "non-svg-raw shapes untouched") + (t/is (nil? (:shapes (get objects leaf-id))) + "svg-raw leaf without :shapes untouched"))) + +(t/deftest migration-0026-is-idempotent + (let [shape-id (uuid/next) + child-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-svg-raw-shape shape-id [(str child-id)])}}}} + data' (cfm/migrate-data data "0026-fix-svg-raw-shapes-uuids") + data'' (cfm/migrate-data data' "0026-fix-svg-raw-shapes-uuids")] + + (t/is (= data' data'') "second run is a no-op"))) \ No newline at end of file diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index b24b045e1a..cc08609837 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -19,6 +19,7 @@ [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-0025-test] + [common-tests.files-migrations-0026-test] [common-tests.files-migrations-test] [common-tests.files.shapes-builder-test] [common-tests.files.validate-test] @@ -97,6 +98,7 @@ 'common-tests.files-changes-test 'common-tests.files-builder-test 'common-tests.files-migrations-0025-test + 'common-tests.files-migrations-0026-test 'common-tests.files-migrations-test 'common-tests.files.validate-test 'common-tests.geom-align-test From f7fd3e1cd59617d919e21b5b3461d0bc61c05e63 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 10:00:00 +0200 Subject: [PATCH 203/298] :books: Update the `update-changelog` skill --- .opencode/skills/update-changelog/SKILL.md | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index d5c0ccf2af..d456ce5bf9 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -212,6 +212,37 @@ superseded it: Replace the reference in the changelog entry with the correct merged PR number. +### 5b. Security advisory (GHSA) entries + +Security advisories fixed in a release are documented in the changelog even +though they are **neither milestone issues nor PRs**. The GHSA ID and its +description are supplied by the user or the release notes — they never come +from the milestone fetch in step 2. + +**Format** (matches the existing precedent in `CHANGES.md`, e.g. the +`create-font-variant` arbitrary file read advisory): + +```markdown +- Fix (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX) +``` + +Rules: +- Place the entry under `### :bug: Bugs fixed`, with **no issue or PR link** — + only the advisory URL. +- The advisory may be **draft/unpublished** at changelog time (the URL 404s + publicly). Do **not** web-fetch or verify the URL, and do **not** drop the + entry because of that. Rely on the GHSA ID provided by the user. +- Derive the description from the supplied advisory title, imperative mood and + user-facing (e.g. `Fix command injection in SVG exporter via legacy fill-color`). +- These entries are **invisible to the automation**: they are not returned by + `gh.py issues`, not matched by `--compare` (step 3), not part of the PR + cross-reference (step 10), and not scanned by the anomaly-report regexes + (step 11, which only match `issues/` and `pull/` links). Add them manually. +- During pre-flight checks (step 6a) apply only the **backport/duplicate** + check: if the same GHSA already appears in an earlier version section, remove + it from the current section. Their absence from milestone cross-references + is expected, not an anomaly. + ### 6. Read the current CHANGES.md Read the top of `CHANGES.md` to understand the existing format and find the @@ -400,6 +431,8 @@ if closed: - ✅ Every merged milestone PR is either in the changelog or excluded by label - ✅ PR and issue counts are internally consistent - ✅ No false-positive PR-to-issue associations +- ✅ Advisory (GHSA) entries are not milestone PRs — their absence from the + cross-reference is intentional (see step 5b) ## Version section template @@ -410,8 +443,12 @@ if closed: - [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) - (by @contributor) [#](https://github.com/penpot/penpot/issues/) (PR: [#](https://github.com/penpot/penpot/pull/)) +- (https://github.com/penpot/penpot/security/advisories/GHSA-XXXX-XXXX-XXXX) ``` +Advisory (GHSA) entries have no issue or PR link — just the advisory URL. See +step 5b. + ### 11. Generate anomaly report and save to CHANGES-ISSUES.md After all edits and cross-referencing are complete, generate a structured @@ -732,6 +769,14 @@ self-contained and clickable in any Markdown viewer. Taiga description text or by searching GitHub PRs that reference the Taiga URL. Replace the Taiga reference with the GitHub issue link and add the PR reference if applicable. +- **Security advisory (GHSA) entries.** Advisories fixed in the release are + listed under `### :bug: Bugs fixed` with the advisory URL and **no issue or + PR link**, even though they are not in the milestone. The GHSA ID and + description come from the user — do **not** fetch or verify the URL, and do + not drop a draft (unpublished) advisory. Precedent: + `- Fix arbitrary file read security issue on create-font-variant rpc method + (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2)`. + See step 5b. - **Re-fetch before editing.** Milestones can change — always re-fetch issues before making edits, don't rely on cached data. - **Use `scripts/gh.py`.** Prefer the helper script over raw `gh api` calls for From 1d2c37e52c733f74017d90b0fd1ae2d074a5c33d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 10:00:13 +0200 Subject: [PATCH 204/298] :paperclip: Update changelog --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index e2874e9492..c24676eaf3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,8 @@ ### :bug: Bugs fixed - Fix linear gradients in SVG text exports being emitted as radial gradients [#5972](https://github.com/penpot/penpot/issues/5972) (PR: [#11272](https://github.com/penpot/penpot/pull/11272)) +- Fix typography token becoming detached when editing text content [#11362](https://github.com/penpot/penpot/issues/11362) (PR: [#11366](https://github.com/penpot/penpot/pull/11366)) +- Fix command injection in SVG exporter via legacy fill-color (https://github.com/penpot/penpot/security/advisories/GHSA-4f36-m4hj-cv86) ## 2.17.1 From 03cd3fa70fc35fe4d2e0f210d1dccd4baa9f4bd2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 10:09:52 +0200 Subject: [PATCH 205/298] :recycle: Consolidate auto-link libraries with unified export-type and fix ref integrity (#9958) * :sparkles: Auto-link libraries during import based on slugified name When a Penpot file is exported without bundled libraries and then imported into a different environment, external library links are broken because library UUIDs differ across environments. This feature adds a heuristic to auto-relink libraries by matching slugified library names against shared files in the target team: - Export: embed external library metadata (id, name, slug, used-by) in the manifest when libraries are not included in the export. - Import: resolve external libraries by slugifying shared file names in the destination team and matching against manifest slugs. - Single match: auto-link silently (creates file-library-rel row). - Multiple matches: emit SSE event so the frontend shows a selection dialog for the user to pick the correct library. - No match: import continues without linking (current behavior). Backend changes: - Extended manifest schema with optional :external-libraries field - Added slugify-name, get-files-names, get-shared-files-for-team, find-shared-files-by-slug helpers in app.binfile.common - Threaded team-id into import cfg from RPC layer - Added resolve-external-libraries and auto-link-libraries in v3 - Emit :library-candidates SSE event for multi-match cases Frontend changes: - Worker captures library-candidates SSE events and forwards them - Import dialog shows auto-link notification and multi-match selection UI with select dropdowns - Added link-files-to-library! RPC helper for user selections - Added en/es translations for new UI strings Closes #9263 Signed-off-by: Andrey Antukh * :sparkles: Add UI for the auto-link plumbing * :recycle: Consolidate auto-link libraries with unified export-type and fix ref integrity Consolidates the auto-link libraries feature into a single coherent implementation: - Unify boolean flags (embed-assets, include-libraries, link-later) into single ::bfc/export-type parameter - Fix critical reference-integrity bug: pre-resolution no longer remaps :component-file refs when no link is created (multi-match / no-permission) - compute-link-decisions as single source of truth for auto-link logic - 80+ backend tests covering round-trip, cross-team, permissions, edge cases, and reference integrity AI-assisted-by: longcat-2.0 --------- Signed-off-by: Andrey Antukh Co-authored-by: Eva Marco --- backend/src/app/binfile/common.clj | 48 +- backend/src/app/binfile/v3.clj | 217 ++- backend/src/app/rpc/commands/binfile.clj | 23 +- backend/test/backend_tests/binfile_test.clj | 1645 ++++++++++++++++- .../test_files/file-with-library.penpot | Bin 0 -> 8480 bytes docker/devenv/Dockerfile | 2 +- frontend/deps.edn | 4 +- frontend/src/app/main/data/exports/files.cljs | 26 +- .../src/app/main/ui/dashboard/import.cljs | 690 +++++-- .../src/app/main/ui/dashboard/import.scss | 618 ++++--- .../src/app/main/ui/ds/controls/select.cljs | 94 +- frontend/src/app/main/ui/exports/files.cljs | 135 +- frontend/src/app/main/ui/exports/files.scss | 372 ++-- frontend/src/app/plugins/file.cljs | 8 +- frontend/src/app/worker/import.cljs | 89 +- frontend/translations/en.po | 95 +- frontend/translations/es.po | 90 +- plugins/libs/plugin-types/index.d.ts | 17 +- 18 files changed, 3399 insertions(+), 774 deletions(-) create mode 100644 backend/test/backend_tests/test_files/file-with-library.penpot diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index b0329ce65a..2f37e34e78 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -875,8 +875,8 @@ (defn get-resolved-file-libraries "Get all file libraries including itself. Returns an instance of LoadableWeakValueMap that allows do not have strong references to - the loaded libraries and reduce possible memory pressure on having - all this libraries loaded at same time on processing file validation + the loaded libraries and reduce memory pressure on having + all this libraries at the same time on processing file validation or file migration. This still requires at least one library at time to be loaded while @@ -888,3 +888,47 @@ (cons (:id file))) load-fn #(get-file cfg % :migrate? false)] (weak/loadable-weak-value-map library-ids load-fn {id file}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; EXTERNAL LIBRARY RESOLUTION HELPERS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn slugify-name + "Slugify a library name for cross-environment matching. + Lowercases, replaces non-alphanumeric runs with '-', strips + leading/trailing '-'." + [name] + (str/slug name)) + +(def ^:private sql:get-files-names + "SELECT id, name FROM file WHERE id = ANY(?)") + +(defn get-files-names + "Return [{:id uuid :name string}] for the given file ids." + [cfg ids] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (let [ids-arr (db/create-array conn "uuid" ids)] + (db/exec! conn [sql:get-files-names ids-arr]))))) + +(def ^:private sql:get-shared-files-for-team + "SELECT f.id, f.name, f.project_id + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND f.is_shared = true + AND f.deleted_at IS NULL + AND p.deleted_at IS NULL") + +(defn get-shared-files-for-team + "Return [{:id uuid :name string}] for all shared files in a team." + [cfg team-id] + (db/run! cfg + (fn [{:keys [::db/conn]}] + (db/exec! conn [sql:get-shared-files-for-team team-id])))) + +(defn find-shared-files-by-slug + "Return all shared files in `team-id` whose slugified name equals `slug`." + [cfg team-id slug] + (->> (get-shared-files-for-team cfg team-id) + (filter #(= slug (slugify-name (:name %)))))) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 314320ef39..b6f610000d 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -67,7 +67,16 @@ [:relations {:optional true} [:vector - [:tuple ::sm/uuid ::sm/uuid]]]]) + [:tuple ::sm/uuid ::sm/uuid]]] + + ;; TODO: rename to :links + [:external-libraries {:optional true} + [:vector + [:map + [:id ::sm/uuid] + [:name :string] + [:slug :string] + [:used-by {:optional true} [:vector ::sm/uuid]]]]]]) (def ^:private schema:storage-object [:map {:title "StorageObject"} @@ -217,14 +226,12 @@ (.flush writer)) (.closeEntry output)) + (defn- get-file - [{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id] + [{:keys [::bfc/export-type] :as cfg} file-id] - (when (and include-libraries embed-assets) - (throw (IllegalArgumentException. - "the `include-libraries` and `embed-assets` are mutally excluding options"))) - - (let [detach? (and (not embed-assets) (not include-libraries))] + (let [detach? (= export-type :detach-libraries) + embed? (= export-type :merge-libraries)] (db/tx-run! cfg (fn [cfg] (cond-> (bfc/get-file cfg file-id {:realize? true @@ -234,7 +241,7 @@ (-> (ctf/detach-external-references file-id) (dissoc :libraries)) - embed-assets + embed? (update :data #(bfc/embed-assets cfg % file-id)) :always @@ -371,12 +378,34 @@ (write-entry! output path encoded-tokens))))) (defn- export-files - [{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}] - (let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids))) - rels (if include-libraries + [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}] + + (let [original-ids ids + ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids))) + rels (if (= export-type :include-libraries) (->> (bfc/get-files-rels cfg ids) (mapv (juxt :file-id :library-file-id))) - [])] + []) + + ;; Compute external libraries: referenced by original files but + ;; not included in the export set. Only relevant for :link-later. + external-libs + (when (= export-type :link-later) + (let [original-rels (bfc/get-files-rels cfg original-ids) + lib-ids (into #{} (map :library-file-id) original-rels)] + (when (seq lib-ids) + (let [lib-names (bfc/get-files-names cfg lib-ids)] + (->> lib-names + (mapv (fn [{:keys [id name]}] + (let [slug (bfc/slugify-name name)] + (when-not (str/blank? slug) + {:id id + :name name + :slug slug + :used-by (->> original-rels + (filter #(= (:library-file-id %) id)) + (mapv :file-id))})))) + (filterv some?))))))] (vswap! bfc/*state* assoc :files (d/ordered-map)) @@ -389,12 +418,14 @@ ;; Write manifest file (let [files (:files @bfc/*state*) - params {:type "penpot/export-files" - :version 1 - :generated-by (str "penpot/" (:full cf/version)) - :referer "penpot" - :files (vec (vals files)) - :relations rels}] + params (cond-> {:type "penpot/export-files" + :version 1 + :generated-by (str "penpot/" (:full cf/version)) + :referer "penpot" + :files (vec (vals files)) + :relations rels} + (seq external-libs) + (assoc :external-libraries external-libs))] (write-entry! output "manifest.json" params)))) ;; --- IMPORT IMPL @@ -882,6 +913,104 @@ (vswap! bfc/*state* update :index assoc id (:id sobject))))))) +(defn- add-to-file + "Add a resolved library entry to a file in the file-grouped resolution. + `key` is :done (auto-linked) or :pending (needs resolution)." + [acc file-id file-name key entry] + (update acc file-id (fn [file] + (let [file (or file {:id file-id + :name file-name + :done [] + :pending []})] + (update file key conj entry))))) + +(defn- compute-link-decisions + "Returns a map of {old-lib-id -> {:library-id ... :library ...}} for external + libraries that should be auto-linked (single candidate AND importer has edit + permission). Libraries with zero or multiple candidates, or where the importer + lacks permission, are excluded — their refs should remain dangling." + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/profile-id] :as cfg}] + (reduce + (fn [acc ext-lib] + (let [slug (:slug ext-lib)] + (if (nil? slug) + acc + (let [matching (into [] (bfc/find-shared-files-by-slug cfg team-id slug))] + (if (not= 1 (count matching)) + acc + (let [library (first matching) + perms (bfc/get-file-permissions conn profile-id (:id library))] + (if (:can-edit perms) + (assoc acc (:id ext-lib) {:library-id (:id library) + :library library}) + acc))))))) + {} + (:external-libraries manifest))) + +(defn- resolve-and-link-libraries + "For each external library in the manifest, resolve candidates by slug. + Auto-links single matches (creating DB rows) and builds a file-grouped + resolution map keyed by imported file-id (new UUID)." + + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp] :as cfg} files-info] + (assert (uuid? team-id) "team-id should be provided") + + (let [file-ids (keys files-info) + decisions (compute-link-decisions cfg)] + + (reduce + (fn [acc ext-lib] + (assert (contains? ext-lib :id) "expected `:id` on ext-lib") + (assert (contains? ext-lib :name) "expected `:name` on ext-lib") + (assert (contains? ext-lib :used-by) "expected `:used-by` on ext-lib") + (assert (contains? ext-lib :slug) "expected `:slug` on ext-lib") + + (let [used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))] + (cond + ;; No slug → skip + (nil? (:slug ext-lib)) + acc + + ;; Has decision → auto-link (single match + can-edit) + (contains? decisions (:id ext-lib)) + (let [{:keys [library-id]} (get decisions (:id ext-lib)) + used-by (filter used-by file-ids)] + (doseq [file-id used-by] + (let [rel-params {:file-id file-id :library-file-id library-id}] + (db/insert! conn :file-library-rel rel-params + {::db/on-conflict-do-nothing? true}) + (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))) + (let [entry {:id (:id ext-lib) + :name (:name ext-lib) + :linked-to library-id}] + (reduce (fn [acc file-id] + (add-to-file acc file-id (get files-info file-id) :done entry)) + acc used-by))) + + ;; Has candidates but no decision → multi-match or no permission → pending + :else + (let [matching-libraries (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))] + (if (empty? matching-libraries) + acc + (let [candidates (mapv (fn [lib] + (let [project-id (:project-id lib) + project (bfc/get-project cfg project-id) + project-name (:name project)] + {:id (:id lib) + :name (:name lib) + :project-id project-id + :project-name project-name})) + matching-libraries) + entry {:id (:id ext-lib) + :name (:name ext-lib) + :candidates candidates}] + (reduce (fn [acc file-id] + (add-to-file acc file-id (get files-info file-id) :pending entry)) + acc used-by))))))) + + {} + (:external-libraries manifest)))) + (defn- import-files* [{:keys [::manifest] :as cfg}] (bfc/disable-database-timeouts! cfg) @@ -890,18 +1019,37 @@ (import-storage-objects cfg) - (let [files (get manifest :files) - result (reduce (fn [result file] - (let [name' (get file :name) - file (assoc file :name name')] - (conj result (import-file cfg file)))) - [] - files)] + ;; Pre-resolve external libraries and add their id mappings to the index + ;; BEFORE importing files. This allows relink-refs (inside process-file) + ;; to correctly remap :component-file references to the destination library. + ;; Only remap when a link will actually be created (single match + can-edit). + (let [decisions (compute-link-decisions cfg)] + (doseq [[old-lib-id {:keys [library-id]}] decisions] + (l/trc :hint "pre-resolving external library" + :old-id (str old-lib-id) + :new-id (str library-id)) + (vswap! bfc/*state* update :index assoc old-lib-id library-id))) + + (let [files (get manifest :files) + file-ids (reduce (fn [result file] + (let [name' (get file :name) + file (assoc file :name name')] + (conj result (import-file cfg file)))) + [] + files) + ;; Build map of file-id to file-name for resolution + files-info (into {} (map (fn [file-id manifest-file] + [file-id (:name manifest-file)]) + file-ids + files))] (import-file-relations cfg) - (bfm/apply-pending-migrations! cfg) - result)) + (let [resolution (resolve-and-link-libraries cfg files-info)] + + (bfm/apply-pending-migrations! cfg) + {:file-ids file-ids + :resolution resolution}))) (defn- import-file-and-overwrite* [{:keys [::manifest ::bfc/file-id] :as cfg}] @@ -929,7 +1077,8 @@ (bfc/invalidate-thumbnails cfg file-id) (bfm/apply-pending-migrations! cfg) - [file-id]))) + {:file-ids [file-id] + :resolution {}}))) (defn- import-files [{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}] @@ -977,12 +1126,11 @@ "Do the exportation of a specified file in custom penpot binary format. There are some options available for customize the output: - `::bfc/include-libraries`: additionally to the specified file, all the - linked libraries also will be included (including transitive - dependencies). - - `::bfc/embed-assets`: instead of including the libraries, embed in the - same file library all assets used from external libraries." + `::bfc/export-type`: determines how linked libraries are handled. + Valid values: `:include-libraries` (include linked libraries), + `:merge-libraries` (embed library assets in the file), + `:detach-libraries` (treat assets as basic objects), + `:link-later` (preserve component metadata for relinking on import)." [{:keys [::bfc/ids] :as cfg} output] @@ -998,6 +1146,7 @@ tp (ct/tpoint) ab (volatile! false) cs (volatile! nil)] + (try (l/info :hint "start exportation" :export-id (str id)) (binding [bfc/*state* (volatile! (bfc/initial-state))] diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ed2cec3ed0..839305f623 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -42,17 +42,24 @@ schema:export-binfile [:map {:title "export-binfile"} [:file-id ::sm/uuid] - [:include-libraries ::sm/boolean] - [:embed-assets ::sm/boolean]]) + [:type {:optional true} [::sm/one-of #{:include-libraries :merge-libraries :detach-libraries :link-later}]] + [:include-libraries {:optional true} ::sm/boolean] + [:embed-assets {:optional true} ::sm/boolean]]) (defn- export-binfile - [{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}] - (let [output (tmp/tempfile*)] + [{:keys [::sto/storage] :as cfg} {:keys [type file-id include-libraries embed-assets]}] + (let [output (tmp/tempfile*) + ;; Convert legacy boolean flags to unified export-type + export-type (cond + (some? type) type + (true? include-libraries) :include-libraries + (true? embed-assets) :merge-libraries + :else :detach-libraries)] + (try (-> cfg (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/embed-assets embed-assets) - (assoc ::bfc/include-libraries include-libraries) + (assoc ::bfc/export-type export-type) (bf.v3/export-files! output)) (let [data (sto/content output) @@ -73,7 +80,8 @@ (sv/defmethod ::export-binfile "Export a penpot file in a binary format." {::doc/added "1.15" - ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] + ::doc/changes [["2.12" "Remove version parameter, only one version is supported"] + ["2.19" "Deprecated `include-libraries` and `embed-assets` params"]] ::webhooks/event? true ::sm/params schema:export-binfile} [cfg {:keys [::rpc/profile-id file-id] :as params}] @@ -94,6 +102,7 @@ (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/team-id (:id team)) (assoc ::bfc/name name)) input-path (:path file) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index ab09c3f23c..6eb7eff268 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -10,10 +10,12 @@ [app.binfile.common :as bfc] [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] + [app.common.data :as d] [app.common.features :as cfeat] [app.common.files.validate :as cfv] [app.common.pprint :as pp] [app.common.thumbnails :as thc] + [app.common.time :as ct] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.config :as cf] @@ -161,8 +163,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -170,14 +171,15 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!)) + file-id (first (:file-ids result)) imported (:result (th/command! {::th/type :get-file ::rpc/profile-id (:id profile) - :id (first result) + :id file-id :components-v2 true})) root (get-in imported [:data :pages-index svg-raw-page-id :objects svg-raw-root-id])] - (t/is (= (count result) 1)) + (t/is (= 1 (count (:file-ids result)))) ;; The child ids of an svg-raw shape must survive the JSON round ;; trip as uuids; when they came back as plain strings they no @@ -197,8 +199,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -206,16 +207,18 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!))] - (t/is (= (count result) 1)) - (t/is (every? uuid? result))))) + (t/is (map? result)) + (t/is (= 1 (count (:file-ids result)))) + (t/is (every? uuid? (:file-ids result))) + ;; No external libraries in simple case - resolution should be empty + (t/is (= {} (:resolution result)))))) (t/deftest export-binfile-preserves-public-uri-subpath (let [profile (th/create-profile* 1) file (prepare-simple-file profile) config (assoc cf/config :public-uri "https://example.com/penpot") params {:file-id (:id file) - :include-libraries false - :embed-assets false} + ::bfc/export-type :detach-libraries} uri (binding [cf/config config] (#'binfile/export-binfile th/*system* params))] (t/is (str/starts-with? (str uri) @@ -229,8 +232,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -238,9 +240,9 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!)) - imported (bfc/get-file th/*system* (first result))] + imported (bfc/get-file th/*system* (first (:file-ids result)))] - (t/is (= (count result) 1)) + (t/is (= 1 (count (:file-ids result)))) (t/is (some? (get-in imported [:metadata :generated-by]))) (t/is (= "penpot" (get-in imported [:metadata :referer])))))) @@ -266,3 +268,1618 @@ ;; With the guard, it raises :validation :max-file-size-reached. (t/is (= :validation (:type out))) (t/is (= :max-file-size-reached (:code out)))))))) + +(t/deftest slugify-name-test + (t/is (= "my-design-system" (bfc/slugify-name "My Design System!"))) + (t/is (= "icons" (bfc/slugify-name "Icons"))) + (t/is (= "brand-colors-2024" (bfc/slugify-name "Brand Colors 2024"))) + (t/is (= "" (bfc/slugify-name "---")))) + +(t/deftest export-includes-external-libraries + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Read the manifest and check external-libraries + (let [manifest (v3/get-manifest output)] + (t/is (some? (:external-libraries manifest))) + (t/is (= 1 (count (:external-libraries manifest)))) + (let [ext-lib (first (:external-libraries manifest))] + (t/is (= (:id library) (:id ext-lib))) + (t/is (= "Icons Library" (:name ext-lib))) + (t/is (= "icons-library" (:slug ext-lib))) + (t/is (= [(:id file)] (:used-by ext-lib)))))))) + +(t/deftest import-auto-links-single-candidate + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where the original library does not exist in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Now create a new shared library with the same name in the same team + ;; (simulating the library existing in the target environment) + (let [library2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; Check that the library was auto-linked in the resolution + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; File should have name + (t/is (some? (:name file-res))) + ;; File should have one auto-linked library in :done + (t/is (= 1 (count (:done file-res)))) + (let [done-entry (first (:done file-res))] + (t/is (= (:id library) (:id done-entry))) + (t/is (= (:id library2) (:linked-to done-entry)))) + ;; No pending candidates + (t/is (= [] (:pending file-res)))) + + ;; Verify the file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels)))))))) + +(t/deftest import-no-auto-link-no-match + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where no matching library exists in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Import without any matching library in the team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; No auto-linking should happen - resolution should be empty + (t/is (= {} (:resolution result))))))) + +(t/deftest import-returns-multi-match-candidates + (let [profile (th/create-profile* 1) + ;; Create a shared library file + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + ;; Create a file that uses the library + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library to simulate a cross-environment import + ;; where the original library does not exist in the target team. + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Create TWO shared libraries with the same name + (let [library2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + library3 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; No auto-linking (multi-match) - check resolution structure + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; File should have name + (t/is (some? (:name file-res))) + ;; No auto-linked libraries + (t/is (= [] (:done file-res))) + ;; Should have pending candidates + (t/is (= 1 (count (:pending file-res)))) + (let [pending-entry (first (:pending file-res))] + (t/is (= (:id library) (:id pending-entry))) + (t/is (= 2 (count (:candidates pending-entry)))) + ;; Each candidate should have project info + (doseq [candidate (:candidates pending-entry)] + (t/is (some? (:project-id candidate))) + (t/is (some? (:project-name candidate)))))) + + ;; No file-library-rel should be created automatically + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 0 (count rels)))) + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library3)})] + (t/is (= 0 (count rels)))))))) + +(t/deftest import-auto-link-respects-library-permissions + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + viewer (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + + library (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false})] + + ;; Link file to library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) + :library-file-id (:id library)}) + + ;; Export with link-later to compute external-libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library and recreate a matching one owned by owner + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + ;; Create a project in the team for the matched library and import. + (let [project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + + library2 (th/create-file* 3 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; Auto-link must be skipped because viewer cannot edit the library + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; No auto-linked libraries - file may not be in resolution map at all + (t/is (or (nil? file-res) + (= [] (:done file-res))))) + + ;; No file-library-rel should have been created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 0 (count rels)))) + + ;; Control: the same import performed by the owner (who has edit + ;; permission on the library) should auto-link. + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id owner)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!))] + + (let [resolution (:resolution result) + file-id (first (:file-ids result)) + file-res (get resolution file-id)] + ;; Should have name + (t/is (some? (:name file-res))) + ;; Should have one auto-linked library + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id library2) (:linked-to (first (:done file-res)))))) + + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))))) + +(t/deftest import-auto-link-only-files-that-used-library + (let [profile (th/create-profile* 1) + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Only file1 uses the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) + :library-file-id (:id library)}) + + ;; Export both files without including libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Remove the source library and recreate a matching one + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library)}) + + (let [library2 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + + ;; The library should be auto-linked for the file that used it + (let [resolution (:resolution result) + ;; Find the file that has auto-linked libraries + file-with-done (d/seek #(seq (:done %)) (vals resolution))] + (t/is (some? file-with-done)) + ;; Should have name + (t/is (some? (:name file-with-done))) + (t/is (= 1 (count (:done file-with-done)))) + (t/is (= (:id library2) (:linked-to (first (:done file-with-done))))) + + ;; But only one file-library-rel should exist (for file1) + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))))) + +;; ============================================================================= +;; COMPREHENSIVE LINK-LATER TESTS +;; ============================================================================= + +(defn- import-sample-file + "Import the file-with-library.penpot sample file and return + {:profile :file :library :team}. The sample contains a library and + a file that uses it." + ([] + (import-sample-file th/*system*)) + ([system] + (let [profile (th/create-profile* system 1 {}) + input (th/tempfile "backend_tests/test_files/file-with-library.penpot") + result (-> system + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input input) + (v3/import-files!)) + file-ids (:file-ids result) + ;; Find the file-library-rel to identify which id is the file + ;; and which is the library. Relation: file-id -> library-file-id. + rels (keep #(when-let [r (db/query system :file-library-rel + {:file-id %})] + (first r)) + file-ids) + rel (first rels) + file-id (:file-id rel) + library-id (:library-file-id rel)] + {:profile profile + :file-id file-id + :library-id library-id + :all-file-ids (set file-ids) + :team-id (:default-team-id profile)}))) + +(defn- create-named-library + "Create a shared library with the given name in the given team." + ([team-id name] + (create-named-library th/*system* 1 team-id name)) + ([system i team-id name] + (let [profile (th/create-profile* system i {}) + project (th/create-project* system i {:profile-id (:id profile) + :team-id team-id})] + (th/create-file* system i {:profile-id (:id profile) + :project-id (:id project) + :is-shared true + :name name})))) + +(defn- get-file-shapes + "Get all shapes from a file's data." + [file-data] + (let [pages (vals (:pages-index file-data))] + (mapcat vals (map :objects pages)))) + +;; ----------------------------------------------------------------------------- +;; Category 1: Same-Team Round-Trip +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-same-team-round-trip + (let [{:keys [profile file-id team-id]} (import-sample-file) + _ (t/is (some? file-id)) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs)) + (t/is (pos? (count ext-libs)))) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should have auto-linked library + (t/is (some? file-res)) + (t/is (some? (:name file-res))) + (t/is (= 1 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel {:file-id new-file-id})] + (t/is (pos? (count rels))))))) + +(t/deftest link-later-same-team-idempotent + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; First import + (let [result1 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result1))))) + + ;; Second import (should succeed) + (let [result2 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result2)))) + (let [resolution (:resolution result2) + new-file-id (first (:file-ids result2)) + file-res (get resolution new-file-id)] + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +(t/deftest link-later-overwrite-import-no-resolution + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import with overwrite (file-id set) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/file-id file-id) + (assoc ::bfc/input output) + (v3/import-files!))] + ;; Overwrite should have empty resolution + (t/is (= {} (:resolution result)))))) + +;; ----------------------------------------------------------------------------- +;; Category 2: Cross-Team Migration +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-cross-team-library-pre-exists + (let [{:keys [profile file-id team-id]} (import-sample-file) + ;; Create a second team with a library named "LIbrary" + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later from team 1 + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (where library with same name exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should auto-link to library2 + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id library2) (:linked-to (first (:done file-res))))) + + ;; Verify file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel + {:library-file-id (:id library2)})] + (t/is (= 1 (count rels))))))) + +(t/deftest link-later-cross-team-no-library + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking should happen + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-different-library-name + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "Buttons Library") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library with different name) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (slug mismatch) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-not-shared + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + ;; Create a private (non-shared) library + _ (let [priv-project (th/create-project* th/*system* 20 {:profile-id (:id profile) + :team-id (:id team2)}) + priv-lib (th/create-file* th/*system* 21 {:profile-id (:id profile) + :project-id (:id priv-project) + :is-shared false + :name "LIbrary"})]) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library exists but not shared) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library not shared) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-deleted + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete the library in team 2 + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library2)}) + + ;; Import in team 2 (library deleted) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library deleted) + (t/is (= {} resolution))))) + +;; ----------------------------------------------------------------------------- +;; Category 3: Multiple Libraries +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-libraries-both-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Create two libraries + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + ;; Create file linked to both + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to both libraries + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + ;; Export with link-later + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create new libraries with same names + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be auto-linked + (t/is (some? file-res)) + (t/is (= 2 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify both linked-to ids + (let [linked-ids (set (map :linked-to (:done file-res)))] + (t/is (contains? linked-ids (:id lib1b))) + (t/is (contains? linked-ids (:id lib2b)))))))) + +(t/deftest link-later-multiple-libraries-one-matches + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete both libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Only recreate Icons (not Colors) + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Only Icons should be auto-linked + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))))))) + +(t/deftest link-later-multiple-libraries-both-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create TWO of each library (multi-match) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 13 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be pending (multi-match) + (t/is (some? file-res)) + (t/is (= [] (:done file-res))) + (t/is (= 2 (count (:pending file-res)))) + + ;; Each pending should have 2 candidates + (doseq [pending-entry (:pending file-res)] + (t/is (= 2 (count (:candidates pending-entry))))))))) + +(t/deftest link-later-multiple-libraries-mixed-single-and-multi + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create ONE Icons (single match) and TWO Colors (multi-match) + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Icons done, Colors pending + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))) + (t/is (= 1 (count (:pending file-res)))) + (t/is (= 2 (count (:candidates (first (:pending file-res)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 4: Multiple Files +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-files-same-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Both files use the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Both files should have auto-linked + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res)))))))))) + +(t/deftest link-later-multiple-files-only-one-uses-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file1 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Only file1 uses the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Only file1 should have auto-linked + (let [files-with-done (filter #(seq (:done (val %))) resolution)] + (t/is (= 1 (count files-with-done))) + (let [[file-id file-res] (first files-with-done)] + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))))) + +(t/deftest link-later-multiple-files-different-libraries + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib-icons (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file1 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; file1 uses Icons, file2 uses Colors + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib-icons)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib-colors)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-icons)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-colors)}) + (let [lib-icons-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors-b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Each file should have its respective library + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (let [linked-id (:linked-to (first (:done file-res)))] + (t/is (or (= linked-id (:id lib-icons-b)) + (= linked-id (:id lib-colors-b)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 5: Permission Scenarios +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-permission-viewer-cannot-link + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + viewer (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as viewer (no edit permission on library) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; No auto-link (viewer lacks edit permission) + (t/is (or (nil? file-res) + (= [] (:done file-res)))))))) + +(t/deftest link-later-permission-editor-can-link + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + editor (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as editor (has edit permission) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id editor)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Auto-link should succeed + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +;; ----------------------------------------------------------------------------- +;; Category 6: Edge Cases +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-edge-special-chars-in-name + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons & Buttons!"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify slug in manifest + (let [manifest (v3/get-manifest output) + ext-lib (first (:external-libraries manifest))] + (t/is (= "icons-buttons" (:slug ext-lib))))))) + +(t/deftest link-later-edge-empty-slug-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Library name that slugifies to empty + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "---"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Library with empty slug should be dropped from external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (or (nil? ext-libs) + (empty? ext-libs))))))) + +(t/deftest link-later-edge-file-without-libraries + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with no libraries + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Manifest should have no external-libraries + (let [manifest (v3/get-manifest output)] + (t/is (nil? (:external-libraries manifest)))) + + ;; Import should succeed with empty resolution + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= {} (:resolution result)))))) + +(t/deftest link-later-edge-case-insensitive-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "ICONS Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original and create with different case + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "icons library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should match (slug is lowercase) + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 7: Reference Integrity +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-reference-integrity-component-file-remapped + (let [{:keys [profile file-id team-id file]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Verify auto-link happened + (t/is (= 1 (count (:done file-res)))) + (let [linked-lib-id (:linked-to (first (:done file-res)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + shapes (get-file-shapes (:data imported))] + ;; Check that component-file references point to the new library + (doseq [shape shapes] + (when (contains? shape :component-file) + (t/is (= linked-lib-id (:component-file shape)) + "component-file should reference the linked library")))))))) + +(t/deftest link-later-reference-integrity-no-match-dangling-refs + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result))] + + ;; Get the original library id from the manifest + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + ;; component-file refs should remain as original (dangling) + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file should remain as original UUID when no match"))))))) + +;; ----------------------------------------------------------------------------- +;; Category 8: Resolution Structure Verification +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-resolution-structure-single-file-single-lib + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and recreate library + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify exact structure + (t/is (map? file-res)) + (t/is (= new-file-id (:id file-res))) + (t/is (some? (:name file-res))) + (t/is (vector? (:done file-res))) + (t/is (vector? (:pending file-res))) + (t/is (= 1 (count (:done file-res)))) + + ;; Verify done entry structure + (let [done-entry (first (:done file-res))] + (t/is (contains? done-entry :id)) + (t/is (contains? done-entry :name)) + (t/is (contains? done-entry :linked-to)) + (t/is (= (:id lib) (:id done-entry))) + (t/is (= "Icons Library" (:name done-entry))) + (t/is (= (:id lib-b) (:linked-to done-entry)))))))) + +(t/deftest link-later-resolution-structure-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and create two libraries with same name + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify pending structure + (t/is (= [] (:done file-res))) + (t/is (= 1 (count (:pending file-res)))) + + (let [pending-entry (first (:pending file-res))] + (t/is (contains? pending-entry :id)) + (t/is (contains? pending-entry :name)) + (t/is (contains? pending-entry :candidates)) + (t/is (= (:id lib) (:id pending-entry))) + (t/is (= "Icons Library" (:name pending-entry))) + (t/is (= 2 (count (:candidates pending-entry)))) + + ;; Verify candidate structure + (doseq [candidate (:candidates pending-entry)] + (t/is (contains? candidate :id)) + (t/is (contains? candidate :name)) + (t/is (contains? candidate :project-id)) + (t/is (contains? candidate :project-name)))))))) + +;; ----------------------------------------------------------------------------- +;; Code Review Regression Tests: Reference Integrity Bug Fix +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multi-match-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Create a SECOND library with the same name in the same team + (create-named-library th/*system* 2 team-id "LIbrary") + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Multi-match should produce pending candidates, not auto-link + (t/is (seq (:pending file-res)) + "multi-match should produce pending candidates") + (t/is (= [] (:done file-res)) + "multi-match should NOT auto-link") + + ;; Refs must remain as original UUID (dangling), NOT remapped to any + ;; of the candidate libraries + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + ;; The key assertion: refs should NOT be remapped to any candidate + ;; (they should remain as the original UUID from the manifest) + (let [slug (-> manifest :external-libraries first :slug) + matching (into #{} (map :id (bfc/find-shared-files-by-slug th/*system* team-id slug))) + candidate-ids (disj matching original-lib-id)] + (doseq [shape shapes-with-refs] + (t/is (not (contains? candidate-ids (:component-file shape))) + "component-file must NOT be remapped to any candidate library") + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID on multi-match"))))))) + +(t/deftest link-later-no-edit-permission-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Create a viewer profile (no edit permission on the library) + (let [viewer (th/create-profile* th/*system* 2 {}) + _ (th/create-team-role* {:team-id team-id + :profile-id (:id viewer) + :role :viewer})] + + ;; Import as viewer + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Viewer lacks edit permission, so no auto-link + (t/is (or (nil? file-res) + (= [] (:done file-res))) + "viewer should NOT auto-link") + + ;; Refs must remain as original UUID (dangling) + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id viewer) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID when viewer has no edit permission"))))))) + +(t/deftest export-type-takes-precedence-over-legacy-boolean + (let [{:keys [file-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Call export with BOTH type=:link-later AND include-libraries=true + ;; type should win + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/include-libraries true)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries (only produced by link-later) + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs) + "type :link-later should produce external-libraries even with include-libraries=true") + (t/is (pos? (count ext-libs)))))) diff --git a/backend/test/backend_tests/test_files/file-with-library.penpot b/backend/test/backend_tests/test_files/file-with-library.penpot new file mode 100644 index 0000000000000000000000000000000000000000..c17bb86a3d3c8431628a5377daeb1037f86ff649 GIT binary patch literal 8480 zcmd5>2T+sSx(yxaO+cC;ARR&vRRIx5s6mh-9YXIVw1X%jy+~D>AV^1gmm&xP(tDAn z^xh-=0p9aOXRhyf=bX83XYyrd{+T^%?^)lszqNj42ns3*0231vK;}oG1GqG}&gL+4 zD-%a91Abl*kBJa3hk=lh0S5?ZEO-`xAP$g$5x=02k&uwF0Fcwd(bgtJT_KE85T|DT zKD*}|m-#Uln-03$-aApreoh|AGCLZr!3?rRLpI^3G@-^GunK8PeS_QDw3uhZRlkof3RCjc1> zI$Rzn1h;LWkbbh#?jb2Jt((i#W=+p@+9@=9RnSgdC?ncYSBOb{1B^{mG9_^lAi59s!M3^& zT7%ex!Xmbr&qHR#(p4sPd&6(xe(q;^w$74^Feh)>Su!WTeBU&NH&3W9?jjIdx{l5b zm~A9?h?&KhRN8@_6YH)k6vANyiM%q)rkRpvU(grKgPS^^>z45K^KBSbG0|tQ5|+rq zU*CA*BhZ0BWo^<;^yR(D@To!CV6h78hVI&FTq-G1ULz^833+m(6Sb1}&1S(G5~Qw2 zSkOOib=3u&=r)Z-JM1*O*=NK2S4)E#VMqX$fK+U(gXd5CManRu3@4=oedaXV=k)5a zCMjPT;V!;%l6h?L$aK~yKqX+mR&-7E;7;7fQ$>|Knf$sTP%iO+83l()+l9=P2C>1V zwHKatPIzwt9F-1kSEY2it_0A}^z5-ZOL9J9S=iAb(AsTSsWqgWs~480%JCt{#IL5= zDSw8_Qi3|66vM}rMb8VDBK44a(sxC-MIu3!$a-cab7t9zl`Zm?D=qHN9@{N%qi?2Yf)-o&02r(ySg}4_vb)_49CA zXdWjmtR~9l@3Y3TRmjZzW-K7Qc+`MH#K7lemS=C;otS(5q0@@;`th|yM z@QJ_4-0L)T02*$E1=9JR;)N!@%AX;=clQ1FdwTd!?THKcufcKt`&0Py*~P|7p-PsG zv{W?+RaCfjpn!GUny@-E4wi^@Ftt_xU5ixm^3o_zM3(tUw3TwRsQ>!@MAyrM&Qw}J zg7+by{v#$vy1RE_-~89xiC;9q#JMF>NrFtXZi7&npcK8txB)Vzn~rA4&zN}H;4c<= zv?#19+9{PJeABb~5;#^!*J5ax#qUjws0>Y_eK6N^%P#bNy)G@66yH!}tE<9)ubi*4 zKWi-RHvY%WWYS}~U@z5E+OlnrT}_I4w8(?*T9z`%J&|{1+8AkKcih3FV@4ecZ6^}T zN0Cd9PY`ZcmT~dBAusk`)Qm7xxEID8N%=o@T_w84)m_|NZ#9o10pIZxOH7fXNkyuA7n#=sHjEblSgWb< zv$R4@dX*e62a%1_4paZxwo?1_cZ)OFL;GpwAwGvgiZy(bZ-d;I4hg@WP%JQfffuc> z4}9?H5s*)zJ+4o6u-rZQ=w?6j=%owG*OH~284nxNJh28WL4j0kb_lnrCKt>OVzd zW2b&=t3t>e3w|eC9XFJ@E%qZO*UPWAGG5anRRvy1C&?bvRWUV{K}_0pgAQeY#h0aE~O#WW2k1$R+M^3jI^1#iLs4a5`B)JMw17CfPYf zvvWS$?wwKeA=>{#it-rq@)!VlzEM_j{rB#JE={T!cSq50|gS>y7HMOyTHHBJHu41rYnJP_9$mb%XW16THX9 zsbJJ9$c3qsh!}95n>KLDTD3TmrV_5=6kmTeFuLyX)qvvTQw=0KfjZM(O-4IOpT zCiu^iBKGz)Zm$-MN;>Y#IZK^Z>xS@0!%iG?k?Kc~P3iLF#~z#>giq}TiQTyM#4KP) z^;NIMwv#7Cur13KjM#+kO3yhbsnT&n4L&~OTjr{ImTGLPGz~SZ_r=sga*Xob;5R~%2vLMzw|L<2B?7aW3J*{3oJR+^$cyo3JbL`gQm6NOH=~iQwVkbviH(yZ*Dp&M zzY&Os*8p_E6W`E6yapi79eC}lHu><#@b|0e+Jfje1q5wqrBqSJ2~HKIo{^Jlu#fkN zEz{&=69q`CUfCN&ti0tT$h!jsUcVi{U!8}WiJPJk>y@6u)$El<$+ZCPd!5XPOP}%F zeL}(4yj!s|tVE2}D%ZATG*c^4TnAj)fe3I|7(0~$v^dXrjiV$^rh4itr$}9+Ll0o> z9TLb4&Y}w(*hV`)DTgK1`oyzy*#Bh@@jK#(|68+jE{?_o2rk4C9(>nYq$#Dq1G=rJ0hP_g3(uQIF)gl8 zK9wn%SQ|Um9sJ;=vERjH7o1WkaZOBphLUUz@i_NgqCL?%facaUYVn+hhEO*nJ(SkT~ZeR2W`0)v`~&2>hm6j_GHi zZc+1GB30yabT~B#){-s3)5at* z@W$(N7Db6$nB0M)dg4ZfFCCg3>3murc=xCp8ax5w;v7IOeA%mEqz(v=097R?hZ)G$ zIC@C3go-&~n=>joJn{!l_ z2AH0?M1De03bR&^(l|OJWW_;CC|tK8ZyxAM-&cJZ4w6i=&WAG2P&`lQoJ31e<7%I_ zwQ3~NULp6@+KdG(l8Lh0S8wBsnrK<_tP%Mgh+3#FC@P2ZjkosLdAB)lJ-BV|Y! zFI5s1xtj7iQkY$Nw}T%~;6dc)KAf&DMhaUc=M;Re;t=%Z_RPgDv6Q7%r*#`G8T`hV zUZ}X_w0O< z>s^6nm|LP#=ccD>?J#j4C>;9?w~rkZ zlW>A>_NRELTE%4AKTgxbNJF`U4i+9ZT2;uNmRSBAnH_PajfmK^7S6Ph%b7Nca@@CI zZ<0wWf3N+r1(d>;G#~$Yk=eJ1#Tdk8p19=}Z=3aGT;SP5(nYW22v@i{JNU{SbD~3> zqW&<#MlT$Ll@ADp2f(;wC&K=atmM4hQ9Bxa)WGPzHGP!6Tv+>RR7 zDn9h`B$Hfwl;M*&Q_ypZVWs|nLt+46zR}9JMZG#r`d<4PxB**|P_DCvzou|{)suh4 zU~ar`b@M6z7tG?aRx~1Z+k#{!7i6vWwi_cOhV4e1BpsUD0Q;l$uI#7Rqi1HB$)H)- zwv63+!8|D_BN_ab)McHqs`QtA=EHhqK*(tjVhlRx}AYyn29;59$|0R z29`UlV(cP3UofU|T7t8MIRI>`m{|Hu0M+5}Y*yCcInDTXYQ1EBXQoF3JQ-S!1ZHNudCjO`7d11r@ zshQDz7SY(dhWgO_Fd=D|7k`L ztxvTWy$F=dI>iG8Y9Qb2LV~XY1 zn9z`!PWC4H49aXK&%_U2^$vMy1&Ft?y+Zs4jCN1)SlpFHJXYTV0BRiBlCrN~tD1_e z`MvLoOvcJbv-;yq0Q!%3_#D(TpGg}LTL=)1H6-QF7Sf~gojC&NUC`}AzR zII!^$j$B^4@m}*isD17bY$?1gI0A|7o9WBD-g@MOP^4=Qv5Z~G)qLf27;{potXZER zARS1t(tCSJMYO?We5!fEAeZozRz~RI_J_~!(P~HPE_}p@7PW%?GnU5vQg!2=DFkZ+ z8*`Y6qtgXv#-Sf=e1HURx37}UgG6sov=sV*qYp5A8%eoPeTN4>C%m}`K3bew;~u;Z>t4A2JYU7aWD9Vs^hRS&dQ<0Qa3D>VWmL(lmPE@aS$$ zO2#o)3%cC!a8mcj84&!HaT@aKDvRm6!IH8r=D{POQtHS`QibJ=@@`lrMO|&|#7ZgE z&AYN7kulr+>!^I}cYO6ZOWa|SXyh#@xN|KZCT(kGQC$=`Dpi=GbZB;Z;#M9}gwG+_ z8Yq*AI4cYuV{r{=vIVHhyoJHA#|im_@jpr5!p1_>Sf9kmL&a`x8#cmv9$sl4G}O%_ z4c7T!5_-s2*S;uoI5*BgGO>z%p6=ssOy1$09VFLTGo=hcLM8!xf7f)`fIgQ~KaSsg z^YruLm!05q5BvK-JF7f@eeu80wf*2||BU0Z#du!TzK@ZA3ddjXw0;JA*=+g|EGEW3 zbZEae4laA3KZCvOX#5B^=gNNt>}5CXXRwz|1-vj@xPFGKggw@aa?X+f27CmKZWBzPrS=r(T`vmN&cZj z`(@(&&|m+2vX?K%AHjN%{ujZ1YukPXd-(!7zpuX!x@$k(-pUX(^z+9!XP@M=nFYo9 GzyARbZt4mE literal 0 HcmV?d00001 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 450cff5b5a..00d27014bc 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.19 +ENV OPENCODE_VERSION=1.18.21 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/frontend/deps.edn b/frontend/deps.edn index f060448343..5c749f4c80 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -21,8 +21,8 @@ :exclusions [funcool/beicon2]} funcool/beicon2 - {:git/tag "v2.2" - :git/sha "8744c66" + {:git/tag "v2.3" + :git/sha "df7058a" :git/url "https://github.com/funcool/beicon.git"} funcool/rumext diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 1acefa5880..d50948e26e 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -17,18 +17,21 @@ [potok.v2.core :as ptk])) (def valid-types - (d/ordered-set :all :merge :detach)) + (d/ordered-set :include-libraries :merge-libraries :detach-libraries :link-later)) (def valid-formats - #{:binfile-v1 :binfile-v3 :legacy-zip}) + #{:binfile-v1 :binfile-v3}) + +(def ^:private schema:export-file-param + [:map {:title "FileParam"} + [:id ::sm/uuid] + [:name :string] + [:project-id ::sm/uuid] + [:is-shared ::sm/boolean] + #_[:has-libraries ::sm/boolean]]) (def ^:private schema:export-files - [:sequential {:title "Files"} - [:map {:title "FileParam"} - [:id ::sm/uuid] - [:name :string] - [:project-id ::sm/uuid] - [:is-shared ::sm/boolean]]]) + [:sequential {:title "Files"} schema:export-file-param]) (def check-export-files (sm/check-fn schema:export-files)) @@ -57,14 +60,17 @@ :files files})))))))))) (defn export-files + "Start files exportation process" [& {:keys [type files]}] + (assert (check-export-files files) "expected a sequence of files") + (assert (valid-types type) "expected valid export type") + (->> (rx/from files) (rx/mapcat (fn [file] (->> (rp/cmd! ::sse/export-binfile {:file-id (:id file) :version 3 - :include-libraries (= type :all) - :embed-assets (= type :merge)}) + :type type}) (rx/filter sse/end-of-stream?) (rx/map sse/get-payload) (rx/map (fn [uri] diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 85481d2460..882adcbb35 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -15,11 +15,20 @@ [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] + [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.notifications.context-notification :refer [context-notification]] [app.main.worker :as mw] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -54,7 +63,7 @@ {::mf/forward-ref true} [{:keys [project-id on-finish-import]} external-ref] (let [on-file-selected (use-import-file project-id on-finish-import)] - [:form.import-file {:aria-hidden "true"} + [:form {:aria-hidden "true"} [:& file-uploader {:accept ".penpot,.zip" :multi true :ref external-ref @@ -156,6 +165,19 @@ (and (= :import-ready (:status item)) (not (:deleted item)))) +(defn- has-unresolved? + "Return true if a file-resolution has any :pending needing user choice." + [file-resolution] + (some? (seq (:pending file-resolution)))) + +(defn- count-auto-linked + "Count auto-linked libraries across all file resolutions." + [resolution] + (reduce-kv (fn [acc _ {:keys [done]}] + (+ acc (count done))) + 0 + resolution)) + (defn- analyze-entries [state entries] (let [features (get @st/state :features)] @@ -173,7 +195,7 @@ (swap! state update-with-analyze-result message)))))) (defn- import-files - [state project-id entries] + [state library-resolution-data* project-id entries] (st/emit! (ev/event {::ev/name "import-files" :num-files (count entries)})) @@ -183,27 +205,40 @@ :project-id project-id :files entries :features features}) - (rx/filter (comp uuid? :file-id)) + (rx/filter some?) (rx/subs! (fn [message] - (swap! state update-entry-status message)))))) + ;; Capture library-resolution data if present (same for all + ;; entries from the same zip, so first one wins) + (if-let [resolution (-> (:libraries-resolution message) + (not-empty))] + (reset! library-resolution-data* resolution) + (swap! state update-entry-status message))))))) (mf/defc import-entry* {::mf/memo true ::mf/private true} - [{:keys [entries entry edition can-be-deleted importing? on-edit on-change on-delete]}] + [{:keys [entries entry edition can-be-deleted is-progress on-edit on-change on-delete]}] (let [status (:status entry) ;; FIXME: rename to format format (:type entry) loading? (or (= :analyze status) (= :import-progress status) - (and importing? (= :import-ready status))) + (and is-progress (= :import-ready status))) analyze-error? (= :analyze-error status) import-success? (= :import-success status) import-error? (= :import-error status) import-ready? (= :import-ready status) + level (cond + import-success? :success + import-ready? :success + import-error? :error + analyze-error? :error + loading? nil + :else :default) + is-shared? (:shared entry) progress (:progress entry) @@ -251,46 +286,65 @@ :editable (and import-ready? (not editing?)))} [:div {:class (stl/css :file-name)} - (if loading? - [:> loader* {:width 16 :title (tr "labels.loading")}] - [:div {:class (stl/css-case - :file-icon true - :icon-fill import-ready?)} - (cond - import-ready? deprecated-icon/logo-icon - import-error? deprecated-icon/close - import-success? deprecated-icon/tick - analyze-error? deprecated-icon/close)]) + (when loading? [:> loader* {:width 26 :title (tr "labels.loading")}]) (if editing? [:div {:class (stl/css :file-name-edit)} [:input {:type "text" :auto-focus true + :class (stl/css :file-name-input) + ;;TODO: Add translation for aria-label + :aria-label "File name" :default-value (:name entry) :on-key-press on-edit-key-press :on-blur on-edit-blur}]] [:div {:class (stl/css :file-name-label)} - (:name entry) - (when ^boolean is-shared? - [:span {:class (stl/css :icon)} - deprecated-icon/library])]) - - [:div {:class (stl/css :edit-entry-buttons)} - (when ^boolean editable? - [:button {:on-click on-edit'} deprecated-icon/curve]) - (when ^boolean can-be-deleted - [:button {:on-click on-delete'} deprecated-icon/delete])]] + (if loading? + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])] + [:> context-notification* + {:level level + :appearance :ghost + :class (stl/css :file-name-notification)} + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]])]) + (when ^boolean (or editable? can-be-deleted) + [:div {:class (stl/css :edit-entry-buttons)} + (when ^boolean editable? + [:> icon-button* {:on-click on-edit' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.edit") + :icon i/curve}]) + (when ^boolean can-be-deleted + [:> icon-button* {:on-click on-delete' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.delete") + :icon i/delete}])])] (cond analyze-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "dashboard.import.analyze-error"))] import-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "labels.error"))] @@ -318,6 +372,327 @@ (fn [] (mapv #(assoc % :status :analyze) entries))) +(defn- link-files-to-library! + "Call the link-file-to-library RPC for each file-id with the given + library-id. Returns an observable that completes when all links are done." + [file-ids library-id] + (->> (rx/from file-ids) + (rx/merge-map (fn [file-id] + (->> (rp/cmd! :link-file-to-library + {:file-id file-id + :library-id library-id}) + (rx/catch (fn [cause] + (log/error :hint "failed to link library" + :file-id file-id + :library-id library-id + :cause cause) + (rx/of nil)))))))) + +(mf/defc library-resolution* + {::mf/private true} + [{:keys [unresolved-file selection on-select]}] + (let [candidates (:pending unresolved-file) + disconnected* (mf/use-state #{}) + disconnected (deref disconnected*) + on-change-disconnected + (mf/use-fn + (fn [id] + (swap! disconnected* + (fn [s] + (if (contains? s id) (disj s id) (conj s id))))))] + + ;; Pre-select first candidate for each library + (mf/with-effect [candidates] + (doseq [{:keys [id candidates]} candidates] + (when-not (contains? selection id) + (when-let [first-c (first candidates)] + (on-select id (str (:id first-c))))))) + + [:div {:class (stl/css :library-resolution)} + [:> text* {:class (stl/css :library-resolution-message) + :as "p" + :typography t/body-large} + "Some libraries couldn't be linked automatically. Select the correct library for each:"] + + + [:table {:class (stl/css :library-resolution-table)} + [:thead + [:tr {:class (stl/css :library-resolution-header)} + [:th {:class (stl/css :library-origin-name)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "original library"] + [:th {:class (stl/css :library-resolution-arrow)}] + [:th {:class (stl/css :library-resolution-connection)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "connect to"]]] + [:tbody {:class (stl/css :library-resolution-body)} + (for [{:keys [id name candidates]} candidates] + (let [options (mapv (fn [c] + {:id (str (:id c)) + :label (str (:name c) " (" (:project-name c) ")")}) + candidates) + selected (get selection id) + is-conected (not (contains? disconnected id))] + [:tr {:class (stl/css :library-resolution-item) + :key (dm/str id)} + [:td {:class (stl/css :library-resolution-item-name)} + [:> checkbox* {:id (dm/str id) + :label name + :checked is-conected + :on-change #(on-change-disconnected id)}]] + [:td {:class (stl/css :library-resolution-arrow)} + [:> icon* {:icon-id i/row + :size "m"}]] + [:td + (if is-conected + [:> select* {:options options + :class (stl/css :library-resolution-select) + :default-selected (or (some-> selected str) "") + :has-portal true + :on-change (partial on-select id)}] + + [:> text* {:class (stl/css :library-resolution-no-selection) + :as "span" + :typography t/body-small} + (let [selected-c (or (some #(when (= (str (:id %)) selected) %) candidates) + (first candidates))] + (dm/str (:name selected-c) " (" (:project-name selected-c) ")"))])]]))]]])) + +(mf/defc library-resolution-summary-file* + {::mf/private true} + [{:keys [resolution-file selection]}] + (let [done (:done resolution-file) + pending (:pending resolution-file)] + [:div {:class (stl/css :summary-file)} + [:div {:class (stl/css :summary-file-header)} + [:> icon* {:icon-id i/document + :class (stl/css :summary-file-icon) + :size "s"}] + [:> text* {:class (stl/css :summary-file-name) + :as "span" + :typography t/body-medium} + (:name resolution-file)]] + + (when (seq done) + [:div {:class (stl/css :summary-section)} + [:ul {:class (stl/css :summary-list)} + (for [{:keys [name]} done] + [:li {:class (stl/css :summary-list-item) + :key (dm/str name)} + [:span {:class (stl/css :summary-item-name)} name] + [:span {:class (stl/css :summary-linked-badge)} + [:> icon* {:icon-id i/status-tick + :class (stl/css :summary-badge-icon) + :size "s"}] + (tr "dashboard.import.summary.linked")]])]]) + + (when (seq pending) + [:div {:class (stl/css :summary-section)} + [:div {:class (stl/css :summary-section-header)} + ;; TODO: Add translation for this string + + [:> text* {:as "span" + :class (stl/css :summary-section-title) + :typography t/headline-small} + "linked manually"]] + [:ul {:class (stl/css :summary-list)} + [:li {:class (stl/css :summary-list-item) + :key "summary-list-header"} + [:span {:class (stl/css :summary-item-name-header)} + "Original"] + + [:span {:class (stl/css :summary-item-name-header)} + "New"]] + (for [{:keys [id name] :as cand} pending] + (let [selected-id (get selection id) + selected-c (when selected-id + (d/seek #(= (str (:id %)) (str selected-id)) (:candidates cand)))] + [:li {:class (stl/css :summary-list-item) + :key (dm/str id)} + [:span {:class (stl/css :summary-item-name)} name] + [:> icon* {:icon-id i/row + :size "m" + :class (stl/css :summary-linked-arrow)}] + (if selected-c + [:span {:class (stl/css :summary-linked-info)} + [:span {:class (stl/css :summary-linked-name)} + (:name selected-c)] + [:span {:class (stl/css :summary-linked-project)} + (:project-name selected-c)]] + [:span {:class (stl/css :summary-no-selection)} + (tr "dashboard.import.summary.no-selection")])]))]])])) + +(mf/defc library-resolution-summary* + {::mf/private true} + [{:keys [resolution selection]}] + [:div {:class (stl/css :library-resolution)} + [:p {:class (stl/css :library-resolution-message)} + (tr "dashboard.import.resolve-libraries-summary")] + + (for [[file-id resolution-file] resolution] + [:> library-resolution-summary-file* + {:key (dm/str file-id) + :resolution-file resolution-file + :selection selection}])]) + + +;; ── Stage components ──────────────────────────────────────────────── + +(mf/defc import-files-stage* + {::mf/private true} + [{:keys [entries template status errors? import-success-total auto-linked-count + edition on-edit on-change on-delete + on-cancel on-continue on-accept pending-analysis?]}] + [:* + [:div {:class (stl/css :modal-content)} + (when (and (= :analyze status) errors?) + [:> context-notification* + {:level :warning + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-warning")]) + + (when (= :import-success status) + [:* + [:> context-notification* + {:level (if (zero? import-success-total) :warning :success)} + (tr "dashboard.import.import-message" (i18n/c import-success-total))] + (when (pos? auto-linked-count) + [:> context-notification* + {:level :success} + (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))])]) + + (when (= :import-error status) + [:> context-notification* + {:level :error + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-error.disclaimer")]) + + (when (or (= :import-error status) (and (= :analyze status) errors?)) + [:div {:class (stl/css :import-error-disclaimer)} + [:div (tr "dashboard.import.import-error.message1")] + [:ul {:class (stl/css :import-error-list)} + (for [entry entries] + (when (contains? #{:import-error :analyze-error} (:status entry)) + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (cond + (and (string? err) + (str/includes? (str/lower err) "check error")) + (tr "dashboard.import.import-error.check-error") + + (and (string? err) + (str/includes? (str/lower err) "corrupt")) + (tr "dashboard.import.import-error.corrupt-file") + + :else + (tr "dashboard.import.import-error.unknown-error"))])]))] + [:div (tr "dashboard.import.import-error.message2")]]) + + (for [entry entries] + [:> import-entry* {:edition edition + :key (dm/str (:uri entry) "/" (:file-id entry)) + :entry entry + :entries entries + :is-progress (= :import-progress status) + :on-edit on-edit + :on-change on-change + :on-delete on-delete + :can-be-deleted (> (count entries) 1)}]) + + (when (some? template) + [:> import-entry* {:entry (assoc template :status status) + :can-be-deleted false}]) + + (when (= :import-progress status) + [:div {:class (stl/css :status-message) + :role "status" + :aria-live "polite"} + (tr "labels.uploading-file")])] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (case status + :analyze + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-cancel} + (tr "labels.cancel")] + + :import-ready + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled pending-analysis? + :on-click on-continue} + (tr "labels.continue")] + + :import-progress + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled true + :on-click on-accept} + (tr "labels.accept")] + + (:import-success :import-error) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-accept} + (tr "labels.accept")])]]]) + +(mf/defc import-library-resolution-stage* + {::mf/private true} + [{:keys [current-unresolved-file selection on-select + visited all-visited? + on-wizard-prev on-wizard-next]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution* + {:unresolved-file current-unresolved-file + :selection selection + :on-select on-select}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-wizard-prev} + (tr "labels.previous")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-wizard-next} + (if all-visited? + (tr "labels.next") + (tr "dashboard.import.review-links"))]]]]) + +(mf/defc import-library-summary-stage* + {::mf/private true} + [{:keys [resolution selection visited + on-summary-back on-confirm-library-links]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution-summary* + {:resolution resolution + :selection selection}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-summary-back} + (tr "labels.back")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-confirm-library-links} + (tr "dashboard.import.confirm-library-links")]]]]) + (mf/defc import-dialog {::mf/register modal/components ::mf/register-as :import @@ -329,14 +704,49 @@ ;; Revoke all uri's on commonent unmount (fn [] (run! wapi/revoke-uri (map :uri entries)))) - (let [state* (mf/use-state (initialize-state entries)) - entries (deref state*) + (let [state* (mf/use-state (initialize-state entries)) + entries (deref state*) - status* (mf/use-state :analyze) - status (deref status*) + status* (mf/use-state :analyze) + status (deref status*) - edition* (mf/use-state nil) - edition (deref edition*) + edition* (mf/use-state nil) + edition (deref edition*) + + ;; Library resolution data from the backend (auto-linked + multi-match) + resolution* (mf/use-state nil) + resolution (not-empty (deref resolution*)) + + ;; User selection for multi-match candidates: {old-lib-id candidate-id} + selection* (mf/use-state {}) + selection (deref selection*) + + ;; Wizard progression as an ordered "visited" stack of file-ids. + ;; `current-file` is derived: the first unresolved file NOT yet in `visited`. + ;; No numeric step counter — forward = conj, back = pop. + visited* (mf/use-state #(d/ordered-set)) + visited (deref visited*) + + ;; Derived: files that need user resolution (have :candidates) + unresolved-files + (mf/with-memo [resolution] + (when resolution + (reduce-kv (fn [acc _ v] + (if (has-unresolved? v) + (conj acc v) + acc)) + [] + resolution))) + + all-visited? + (mf/with-memo [visited unresolved-files] + (when (seq unresolved-files) + (every? #(contains? visited (:id %)) unresolved-files))) + + ;; Current file shown in the wizard step: first unresolved file not yet visited. + current-unresolved-file + (mf/with-memo [unresolved-files visited] + (d/seek #(not (contains? visited (:id %))) unresolved-files)) continue-entries (mf/use-fn @@ -344,7 +754,7 @@ (fn [] (let [entries (filterv has-status-ready? entries)] (reset! status* :import-progress) - (import-files state* project-id entries)))) + (import-files state* resolution* project-id entries)))) continue-template (mf/use-fn @@ -407,6 +817,52 @@ (continue-template template) (continue-entries)))) + on-confirm-library-links + (mf/use-fn + (mf/deps resolution selection on-finish-import) + (fn [event] + (dom/prevent-default event) + (let [slc selection] + ;; For each file with pending candidates, link it to the selected libraries + (->> (rx/from (seq resolution)) + (rx/merge-map + (fn [[file-id resolution-file]] + (->> (rx/from (:pending resolution-file)) + (rx/merge-map + (fn [{:keys [id]}] + (when-let [selected-lib (get slc id)] + (link-files-to-library! [file-id] selected-lib))))))) + (rx/subs! (constantly nil) + (constantly nil) + (fn [] + (st/emit! (modal/hide)) + (when (fn? on-finish-import) + (on-finish-import)))))))) + + on-wizard-next + (mf/use-fn + (mf/deps current-unresolved-file visited) + (fn [] + (let [file-id (:id current-unresolved-file)] + (swap! visited* conj file-id)))) + + on-wizard-prev + (mf/use-fn + (mf/deps current-unresolved-file) + (fn [] + ;; Remove the current file from visited; it becomes current again after re-render, + ;; because it's no longer in visited. + (let [file-id (:id current-unresolved-file)] + (swap! visited* disj file-id)))) + + on-summary-back + (mf/use-fn + (mf/deps visited) + (fn [] + (let [last-id (last visited)] + (swap! visited* disj last-id) + (reset! status* :library-resolution)))) + on-accept (mf/use-fn (mf/deps on-finish-import) @@ -432,9 +888,25 @@ (zero? (count entries)))) pending-analysis? - (some has-status-analyze? entries)] + (some has-status-analyze? entries) - (mf/with-effect [entries] + auto-linked-count + (if (some? resolution) + (count-auto-linked resolution) + 0) + + manage-on-select + (mf/use-fn + (mf/deps selection) + (fn [old-lib-id candidate-id] + (swap! selection* assoc old-lib-id candidate-id)))] + + (mf/with-effect [visited unresolved-files] + (when (and (seq unresolved-files) + (every? #(contains? visited (:id %)) unresolved-files)) + (reset! status* :library-summary))) + + (mf/with-effect [entries resolution] (cond (some? template) (reset! status* :import-ready) @@ -445,8 +917,11 @@ (and (seq entries) (every? #(= :import-success (:status %)) entries)) - (reset! status* :import-success) - + (reset! status* (if (seq resolution) + (if (seq (filter has-unresolved? (vals resolution))) + :library-resolution + :library-summary) + :import-success)) (and (seq entries) (and (every? #(not= :import-ready (:status %)) entries) (some #(= :import-error (:status %)) entries))) @@ -460,99 +935,50 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "dashboard.import")] + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} + (tr "dashboard.import")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] + (case status + (:analyze :import-ready :import-progress :import-success :import-error) + [:> import-files-stage* + {:entries entries + :template template + :status status + :errors? errors? + :import-success-total import-success-total + :auto-linked-count auto-linked-count + :edition edition + :on-edit on-edit + :on-change on-entry-change + :on-delete on-entry-delete + :on-cancel on-cancel + :on-continue on-continue + :on-accept on-accept + :pending-analysis? pending-analysis?}] - [:div {:class (stl/css :modal-content)} - (when (and (= :analyze status) errors?) - [:& context-notification - {:level :warning - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-warning")}]) + :library-resolution + [:> import-library-resolution-stage* + {:current-unresolved-file current-unresolved-file + :selection selection + :on-select manage-on-select + :visited visited + :all-visited? all-visited? + :on-wizard-prev on-wizard-prev + :on-wizard-next on-wizard-next}] - (when (= :import-success status) - [:& context-notification - {:level (if (zero? import-success-total) :warning :success) - :content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]) + :library-summary + [:> import-library-summary-stage* + {:resolution resolution + :selection selection + :visited visited + :on-summary-back on-summary-back + :on-confirm-library-links on-confirm-library-links}] - (when (= :import-error status) - [:& context-notification - {:level :error - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-error.disclaimer")}]) - - (if (or (= :import-error status) (and (= :analyze status) errors?)) - [:div {:class (stl/css :import-error-disclaimer)} - [:div (tr "dashboard.import.import-error.message1")] - [:ul {:class (stl/css :import-error-list)} - (for [entry entries] - (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry) - :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} - [:div (:name entry)] - (when-let [err (:error entry)] - [:div {:class (stl/css :import-error-detail)} - ;; Temporary frontend-side error translations to provide more meaningful - ;; messages until backend error handling is improved and standardized. - ;; These mappings are only a short-term workaround and should be removed - ;; once the error handling enhancement is implemented. - ;; https://github.com/penpot/penpot/issues/9884 - (cond - (and (string? err) - (str/includes? (str/lower err) "check error")) - (tr "dashboard.import.import-error.check-error") - - (and (string? err) - (str/includes? (str/lower err) "corrupt")) - (tr "dashboard.import.import-error.corrupt-file") - - :else - (tr "dashboard.import.import-error.unknown-error"))])]))] - [:div (tr "dashboard.import.import-error.message2")]] - - (for [entry entries] - [:> import-entry* {:edition edition - :key (dm/str (:uri entry) "/" (:file-id entry)) - :entry entry - :entries entries - :importing? (= :import-progress status) - :on-edit on-edit - :on-change on-entry-change - :on-delete on-entry-delete - :can-be-deleted (> (count entries) 1)}])) - - (when (some? template) - [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}]) - - (when (= :import-progress status) - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} - (tr "labels.uploading-file")])] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - (when (= :analyze status) - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}]) - - (when (= status :import-ready) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :disabled pending-analysis? - :on-click on-continue}]) - - (when (or (= :import-success status) - (= :import-error status) - (= :import-progress status)) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.accept") - :disabled (= :import-progress status) - :on-click on-accept}])]]]])) + nil)]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 6550c84c2a..6866eb381f 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -4,252 +4,210 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - + position: relative; display: flex; flex-direction: column; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercase-title-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: px2rem(800); } .modal-content { - @include deprecated.body-small-typography; - flex: 1; overflow: hidden auto; display: grid; grid-template-columns: 1fr; - gap: deprecated.$s-16; - margin-bottom: deprecated.$s-24; - min-height: 40px; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); + min-block-size: px2rem(40); +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: px2rem(6); } .status-message { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-small-typography; - - color: var(--modal-text-foreground-color); - line-height: 1.5; + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .file-entry { + --file-entry-fg-color: var(--color-foreground-secondary); + display: flex; - - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-16; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.icon-fill svg { - fill: var(--icon-foreground); - } - } - - .file-name-edit { - @extend %input-element; - @include deprecated.body-small-typography; - - flex-grow: 1; - } - - .file-name-label { - @include deprecated.body-small-typography; - - display: flex; - align-items: center; - gap: deprecated.$s-12; - flex-grow: 1; - - .icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - } - - .edit-entry-buttons { - @include deprecated.flex-row; - - button { - @extend %button-tertiary; - - width: deprecated.$s-28; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - } - } - } - - .error-message, - .progress-message { - display: flex; - align-items: center; - min-height: deprecated.$s-32; - color: var(--modal-text-foreground-color); - } - - .error-message { - align-items: flex-start; - white-space: pre-wrap; - overflow-wrap: anywhere; - } - - .linked-library { - display: flex; - align-items: center; - gap: deprecated.$s-12; - color: var(--modal-text-foreground-color); - - .linked-library-tag { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.error { - svg { - stroke: var(--element-foreground-error); - } - } - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.warning { - .file-name { - color: var(--element-foreground-warning); - - .file-icon svg { - stroke: var(--element-foreground-warning); - } - - .file-icon.icon-fill svg { - fill: var(--element-foreground-warning); - } - } - } + flex-direction: column; + gap: var(--sp-m); &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-sucess); } &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-error); } &.editable { - .file-name { - color: var(--modal-text-foreground-color); + --file-entry-fg-color: var(--color-foreground-primary); + } +} - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } +.error-message, +.progress-message { + display: flex; + align-items: center; + min-block-size: $sz-32; + color: var(--file-entry-fg-color); +} - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } +.error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.linked-library { + display: flex; + align-items: center; + gap: var(--sp-m); + color: var(--file-entry-fg-color); +} + +.linked-library-tag { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; + + svg { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: transparent; + fill: none; + stroke-width: 1px; + stroke: var(--file-entry-fg-color); + } + + &.error { + svg { + stroke: var(--element-foreground-error); } } } +.file-name { + display: flex; + align-items: center; + gap: var(--sp-l); + color: var(--file-entry-fg-color); +} + +.edit-entry-buttons { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.file-name-edit { + display: flex; + align-items: center; + block-size: $sz-32; + border: $b-1 solid var(--color-background-tertiary); + color: var(--file-entry-fg-color); + flex-grow: 1; + position: relative; + border-radius: $br-4; + background-color: var(--color-background-tertiary); +} + +.file-name-label { + display: flex; + align-items: center; + gap: var(--sp-m); + flex-grow: 1; +} + +.file-label-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: var(--file-entry-fg-color); +} + +.file-name-input { + @include use-typography("body-medium"); + + --edit-input-background-color: var(--color-background-tertiary); + --edit-input-border-color: transparent; + + block-size: $sz-32; + inline-size: 100%; + padding: px2rem(6); + margin: 0; + border-radius: $br-8; + border: $b-1 solid var(--edit-input-border-color); + color: var(--color-foreground-primary); + background-color: var(--edit-input-background-color); + + &:focus-visible { + --edit-input-background-color: var(--color-background-primary); + --edit-input-border-color: var(--color-accent-primary); + + outline: none; + } +} + .context-notification-error { - --context-notification-bg-color: var(--modal-background-color); + --context-notification-bg-color: var(--color-background-primary); +} + +.file-name-notification { + flex-grow: 1; } .import-error-disclaimer { @@ -266,10 +224,252 @@ } .import-error-detail { - @include deprecated.body-small-typography; - - margin-top: var(--sp-xs); - color: var(--modal-text-foreground-color); + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + margin-block-start: var(--sp-xs); + color: var(--color-foreground-secondary); white-space: pre-wrap; overflow-wrap: anywhere; } + +// ################################ +// LIBRARY RESOLUTION +// ################################ + +.library-resolution { + display: flex; + flex-direction: column; + gap: var(--sp-m); +} + +.library-resolution-message { + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-s); +} + +.library-resolution-header { + @include use-typography("body-medium"); + + color: var(--color-foreground-secondary); + display: grid; + grid-template-columns: 1fr 32px 1fr; + border-block-end: $b-1 solid var(--color-foreground-secondary); +} + +.library-origin-name, +.library-resolution-connection { + block-size: $sz-32; + text-align: start; + color: var(--color-foreground-primary); + display: flex; + align-items: center; +} + +.library-resolution-item { + display: grid; + grid-template-columns: 1fr 32px 1fr; + block-size: $sz-32; + margin-block: var(--sp-s); +} + +.library-resolution-icon { + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: var(--sp-s); + color: var(--color-foreground-secondary); +} + +.library-resolution-arrow { + color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; +} + +.library-resolution-body { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.library-resolution-item-name { + @include use-typography("body-medium"); + + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + color: var(--color-foreground-secondary); + padding-inline-start: var(--sp-s); +} + +.library-resolution-no-selection { + display: flex; + align-items: center; + color: var(--color-foreground-secondary); + padding: var(--sp-s); + margin-inline-start: var(--sp-xxs); + block-size: $sz-32; +} + +// ################################ +// Summary file card +// ################################ + +.summary-file { + display: flex; + flex-direction: column; + gap: var(--sp-s); + padding: var(--sp-m); + border-radius: $br-8; + background: var(--color-background-primary); +} + +.summary-list { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + list-style: none; + padding: 0; + margin: 0; + border-inline-start: $b-1 solid var(--color-background-quaternary); +} + +.summary-file-header { + display: flex; + align-items: center; + gap: var(--sp-s); + padding-block-end: var(--sp-s); + border-bottom: $b-1 solid var(--color-background-quaternary); +} + +.summary-section-title { + color: var(--color-foreground-primary); +} + +.summary-linked-arrow { + color: var(--color-foreground-secondary); +} + +.summary-file-icon { + color: var(--color-foreground-secondary); + flex-shrink: 0; +} + +.summary-file-name { + color: var(--color-foreground-primary); +} + +// Section within a file (auto-linked or user selection) +.summary-section { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding-inline-start: var(--sp-s); +} + +.summary-section-header { + display: flex; + align-items: center; + gap: px2rem(6); + padding: var(--sp-xs) 0; +} + +.summary-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + padding: var(--sp-xs) var(--sp-s); + border-radius: $br-4; + + &:hover { + background: var(--color-background-secondary); + } +} + +.summary-item-name { + color: var(--color-foreground-primary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary-item-name-header { + color: var(--color-foreground-secondary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Auto-linked badge +.summary-linked-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-xs); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-accent-success-bg); + color: var(--color-accent-success); + font-size: px2rem(11); + font-weight: 500; + flex-shrink: 0; +} + +.summary-badge-icon { + color: var(--color-accent-success); +} + +// User-selected library info +.summary-linked-info { + display: flex; + align-items: center; + gap: var(--sp-xs); + flex-shrink: 0; +} + +.summary-linked-name { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + line-height: 1.4; + color: var(--color-foreground-secondary); + font-weight: 500; +} + +.summary-linked-project { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-background-quaternary); + font-size: px2rem(11); +} + +// No selection state +.summary-no-selection { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + font-style: italic; + flex-shrink: 0; +} diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index cea8878201..3d7a65b9a3 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -13,9 +13,11 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.timers :as timers] [clojure.string :as str] [rumext.v2 :as mf] [rumext.v2.util :as mfu])) @@ -58,11 +60,12 @@ [:empty-to-end {:optional true} [:maybe :boolean]] [:on-change {:optional true} fn?] [:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]] - [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]]) + [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]] + [:has-portal {:optional true} :boolean]]) (mf/defc select* {::mf/schema schema:select} - [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment] :rest props}] + [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment has-portal] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. @@ -88,6 +91,9 @@ options-ref (mf/use-ref nil) select-ref (mf/use-ref nil) + container (hooks/use-portal-container :popup) + dropdown-wrapper-ref (mf/use-ref nil) + empty-selected-id? (str/blank? selected-id) @@ -208,10 +214,63 @@ (reset! selected-id* (get-selected-option-id options default-selected))) + ;; Portal mode: click-outside + floating positioning + (mf/with-effect [is-open has-portal] + (when (and is-open has-portal) + (let [handler + (fn [event] + (let [wrapper-node (mf/ref-val select-ref) + dropdown-node (mf/ref-val dropdown-wrapper-ref) + target (dom/get-target event)] + (when (and wrapper-node dropdown-node + (not (dom/child? target wrapper-node)) + (not (dom/child? target dropdown-node))) + (reset! is-open* false) + (reset! focused-id* nil)))) + + calculate + (fn [] + (timers/raf + (fn [] + (when-let [select-node (mf/ref-val select-ref)] + (when-let [dropdown-node (mf/ref-val dropdown-wrapper-ref)] + (let [select-rect (dom/get-bounding-rect select-node) + dropdown-rect (dom/get-bounding-rect dropdown-node) + window-height (.-innerHeight js/window) + space-below (- window-height (:bottom select-rect)) + open-up? (> (:height dropdown-rect) space-below)] + (if open-up? + (let [bottom (+ (- window-height (:top select-rect)) 4)] + (dom/set-css-property! dropdown-node "top" "unset") + (dom/set-css-property! dropdown-node "bottom" (str bottom "px"))) + (let [top (+ (:bottom select-rect) 4)] + (dom/set-css-property! dropdown-node "bottom" "unset") + (dom/set-css-property! dropdown-node "top" (str top "px")))) + (dom/set-css-property! dropdown-node "left" (str (:left select-rect) "px")) + (dom/set-css-property! dropdown-node "width" (str (:width select-rect) "px")) + (dom/set-css-property! dropdown-node "position" "fixed")))))))] + + (.addEventListener js/document "mousedown" handler) + + (let [ro (js/ResizeObserver. (fn [_] (calculate)))] + (when-let [node (mf/ref-val select-ref)] + (.observe ro node)) + + (.addEventListener js/window "resize" calculate) + (.addEventListener js/window "scroll" calculate true) + + (calculate) + + (fn [] + (.removeEventListener js/document "mousedown" handler) + (.disconnect ro) + (.removeEventListener js/window "resize" calculate) + (.removeEventListener js/window "scroll" calculate true)))))) + [:div {:class [wrapper-class (stl/css :select-wrapper)] :on-click on-click :ref select-ref - :on-blur on-blur} + :on-blur (when-not has-portal on-blur)} [:> :button props [:span {:class (stl/css-case :select-header true @@ -241,11 +300,24 @@ :aria-hidden true}]] (when ^boolean is-open - [:> options-dropdown* {:on-click on-option-click - :id listbox-id - :options options - :selected selected-id - :focused focused-id - :align dropdown-alignment - :empty-to-end empty-to-end - :ref set-option-ref}])])) + (if has-portal + (mf/portal + (mf/html + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref + :wrapper-ref dropdown-wrapper-ref}]) + container) + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref}]))])) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index cd8fe2465c..05621603ee 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -13,8 +13,14 @@ [app.main.data.exports.files :as fexp] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -41,28 +47,33 @@ [files] (let [files (mapv (fn [file] (assoc file :loading true)) files)] {:status :prepare - :selected :all + :selected :include-libraries :files files})) (mf/defc export-entry* {::mf/private true} [{:keys [file]}] - [:div {:class (stl/css-case - :file-entry true - :loading (:loading file) - :success (:export-success? file) - :error (:export-error? file))} + (let [level (cond + (:export-success? file) :success + (:export-error? file) :error + :else :info)] + [:div {:class (stl/css-case + :file-entry true + :loading (:loading file) + :success (:export-success? file) + :error (:export-error? file))} - [:div {:class (stl/css :file-name)} - (if (:loading file) - [:> loader* {:width 16 - :title (tr "labels.loading")}] - [:span {:class (stl/css :file-icon)} - (cond (:export-success? file) deprecated-icon/tick - (:export-error? file) deprecated-icon/close)]) + (if (:loading file) + [:div {:class (stl/css :file-name)} + [:> loader* {:width 26 + :title (tr "labels.loading")}] + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-large} + (:name file)]] - [:div {:class (stl/css :file-name-label)} - (:name file)]]]) + [:> context-notification {:level level + :content (:name file)}])])) (mf/defc export-dialog {::mf/register modal/components @@ -109,6 +120,7 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] + (prn "AAA" selected type) (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?] @@ -119,38 +131,59 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} (tr "files-download-modal.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] - + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] (cond (= status :prepare) [:* [:div {:class (stl/css :modal-content)} - [:p {:class (stl/css :modal-msg)} (tr "files-download-modal.description-1")] - [:p {:class (stl/css :modal-scd-msg)} (tr "files-download-modal.description-2")] + ;; TODO: Add translation + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + "What do you want to do with linked libraries?"] (for [type fexp/valid-types] [:div {:class (stl/css :export-option true) :key (name type)} [:label {:for (str "export-" type) - :class (stl/css-case :global/checked (= selected type))} + :class (stl/css :export-option-label)} ;; Execution time translation strings: - ;; (tr "files-download-modal.options.all.message") - ;; (tr "files-download-modal.options.all.title") - ;; (tr "files-download-modal.options.detach.message") - ;; (tr "files-download-modal.options.detach.title") - ;; (tr "files-download-modal.options.merge.message") - ;; (tr "files-download-modal.options.merge.title") - [:span {:class (stl/css-case :global/checked (= selected type))} + ;; (tr "files-export-modal.options.include-libraries.title") + ;; (tr "files-export-modal.options.include-libraries.message") + + ;; (tr "files-export-modal.options.merge-libraries.title") + ;; (tr "files-export-modal.options.merge-libraries.message") + + ;; (tr "files-export-modal.options.detach-libraries.title") + ;; (tr "files-export-modal.options.detach-libraries.message") + + ;; (tr "files-export-modal.options.link-later.title") + ;; (tr "files-export-modal.options.link-later.message") + + [:span {:class (stl/css-case + :option-icon-wrapper true + :checked (= selected type))} (when (= selected type) - deprecated-icon/status-tick)] + [:svg {:class (stl/css :option-icon) + :viewBox "0 0 8 8" + :width 8 + :height 8 + :aria-hidden true} + [:circle {:cx 4 :cy 4 :r 4}]])] + [:div {:class (stl/css :option-content)} - [:h3 {:class (stl/css :modal-subtitle)} - (tr (dm/str "files-download-modal.options." (d/name type) ".title"))] - [:p {:class (stl/css :modal-msg)} - (tr (dm/str "files-download-modal.options." (d/name type) ".message"))]] + [:> heading* {:level 3 + :typography t/body-large + :class (stl/css :option-title)} + (tr (dm/str "files-export-modal.options." (d/name type) ".title"))] + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + (tr (dm/str "files-export-modal.options." (d/name type) ".message"))]] [:input {:type "radio" :class (stl/css :option-input) @@ -162,15 +195,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}] + [:> button* {:variant "secondary" + :type "button" + :on-click on-cancel} + (tr "labels.cancel")] - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :on-click on-accept}]]]] + [:> button* {:variant "primary" + :type "button" + :on-click on-accept} + (tr "labels.continue")]]]] (= status :exporting) (let [in-progress? (->> state :files (some :loading))] @@ -180,15 +213,15 @@ [:> export-entry* {:file file :key (dm/str (:id file))}]) (when in-progress? - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} + [:> text* {:as "span" :typography t/body-large :class (stl/css :status-message) + :role "status" + :aria-live "polite"} (tr "labels.downloading-file")])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.close") - :disabled in-progress? - :on-click on-cancel}]]]]))]])) + [:> button* {:variant "primary" + :type "button" + :disabled in-progress? + :on-click on-cancel} + (tr "labels.close")]]]]))]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index ad4da9b955..62cc1deeab 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -4,289 +4,205 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; // EXPORT MODAL .modal-overlay { - @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - - max-height: calc(10 * deprecated.$s-80); -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.headline-medium-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: calc(10 * px2rem(80)); } .modal-content { - @include deprecated.body-small-typography; + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); +} - margin-bottom: deprecated.$s-24; +.modal-content-extended { + gap: var(--sp-xxl); +} - .modal-link { - @include deprecated.body-large-typography; +.modal-title { + color: var(--color-foreground-primary); +} - text-decoration: none; - cursor: pointer; - color: var(--modal-link-foreground-color); - } +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-m); + inset-inline-end: var(--sp-m); +} - .selection-header { - @include deprecated.flex-row; +.modal-msg { + color: var(--color-foreground-secondary); + margin: 0; +} - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; - - .selection-btn { - @include deprecated.button-style; - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - margin-left: deprecated.$s-16; - - span { - @extend %checkbox-icon; - } - } - - .selection-title { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); - } - } - - .selection-wrapper { - position: relative; - width: 100%; - height: fit-content; - } - - .selection-shadow { - width: 100%; - height: 100%; - - &::after { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; - background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); - content: ""; - pointer-events: none; - } - } - - .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; - overflow-y: auto; - padding-bottom: deprecated.$s-12; - - .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; - - .selection-btn { - @include deprecated.button-style; - - display: grid; - grid-template-columns: min-content auto 1fr auto auto; - align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; - - .checkbox-wrapper { - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - - .checkobox-tick { - @extend %checkbox-icon; - } - } - - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - flex-grow: 1; - color: var(--modal-text-foreground-color); - text-align: start; - } - - .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - - .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - } - - .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; - margin: auto 0; - - img, - svg { - object-fit: contain; - max-height: deprecated.$s-40; - } - } - } - } +.option-content { + display: flex; + flex-direction: column; } .status-message { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); + display: flex; + justify-content: flex-end; + gap: var(--sp-s); } .export-option { - @extend %input-checkbox; - - width: 100%; + display: flex; align-items: flex-start; + inline-size: 100%; +} - label { - align-items: flex-start; +.export-option-label { + --input-border-color: var(--input-checkbox-border-color-rest); + --input-icon-color: var(--color-background-primary); - .modal-subtitle { - @include deprecated.body-large-typography; + display: flex; + align-items: flex-start; + gap: px2rem(6); + cursor: pointer; + color: var(--color-foreground-primary); - color: var(--modal-title-foreground-color); - padding: 0.25rem 0; - } + &:hover { + --input-border-color: var(--color-accent-primary-muted); } - span { - margin-top: deprecated.$s-8; + &:focus, + &:focus-within { + --input-border-color: var(--color-accent-primary); } } -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; +.option-icon-wrapper { + --icon-display: none; + --background-color: var(--color-background-quaternary); + + display: flex; + justify-content: center; + align-items: center; + inline-size: px2rem(16); + min-inline-size: px2rem(16); + block-size: px2rem(16); + margin-block-start: px2rem(10); + background-color: var(--background-color); + border: px2rem(1) solid var(--input-border-color); + border-radius: $br-circle; + + &.checked { + --icon-display: block; + --input-border-color: var(--color-background-quaternary); + --input-icon-color: var(--color-background-primary); + --background-color: var(--color-accent-primary); + } + + &:hover { + --input-border-color: var(--color-accent-primary-muted); + } + + &:focus { + --input-border-color: var(--color-accent-primary); + } +} + +.option-icon { + inline-size: px2rem(8); + block-size: px2rem(8); + display: var(--icon-display); + fill: var(--input-icon-color); +} + +.option-input { + margin: 0; } .file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } + --file-entry-color: var(--color-foreground-secondary); &.loading { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } &.error { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } &.success { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } } + +.file-name { + display: flex; + align-items: center; + gap: var(--sp-m); + + .file-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: px2rem(16); + inline-size: px2rem(16); + color: var(--color-foreground-secondary); + } + + .file-name-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.icon-status-tick { + fill: none; + stroke: var(--color-accent-primary); +} diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 92d9f359e8..74d2c141ca 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -258,7 +258,13 @@ (fn [format type] (js/Promise. (fn [resolve reject] - (let [type (or (parser/parse-keyword type) :all)] + (let [type (or (parser/parse-keyword type) :all) + ;; Backward compatibility: convert old values to new + type (case type + :all :include-libraries + :merge :merge-libraries + :detach :detach-libraries + type)] (cond (and (some? format) (not (contains? #{"penpot" "zip"} format))) (u/reject-not-valid reject :format (dm/str "Invalid format: " format)) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 564b537278..e8310efa89 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -171,8 +171,9 @@ (defmethod impl/handler :import-files [{:keys [project-id files]}] - (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) - binfile-v3 (filter #(= :binfile-v3 (:type %)) files)] + (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) + binfile-v3 (filter #(= :binfile-v3 (:type %)) files) + resolutions (volatile! {})] (rx/merge (->> (rx/from binfile-v1) @@ -203,40 +204,50 @@ :error (import-cause-message cause (tr "labels.error")) :file-id (:file-id data)}))))))) - (->> (rx/from binfile-v3) - (rx/reduce (fn [result file] - (update result (:uri file) (fnil conj []) file)) - {}) - (rx/mapcat identity) - (rx/merge-map - (fn [[uri entries]] - (->> (import-blob-via-upload uri - {:name (-> entries first :name) - :version 3 - :project-id project-id}) - (rx/tap (fn [event] - (let [payload (sse/get-payload event) - type (sse/get-type event)] - (if (= type "progress") - (log/dbg :hint "import-binfile: progress" - :section (:section payload) - :name (:name payload)) - (log/dbg :hint "import-binfile: end"))))) - (rx/filter sse/end-of-stream?) - (rx/mapcat (fn [_] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :finish - :file-id (:file-id entry)}))))) - (rx/catch - (fn [cause] - (log/error :hint "unexpected error on import process" - :project-id project-id - ::log/sync? true - :cause cause) - (let [err (import-cause-message cause (tr "labels.error"))] - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error err - :file-id (:file-id entry)}))))))))))))) + + (rx/concat + (->> (rx/from binfile-v3) + (rx/reduce (fn [result file] + (update result (:uri file) (fnil conj []) file)) + {}) + (rx/mapcat identity) + (rx/merge-map + (fn [[uri entries]] + (->> (import-blob-via-upload uri + {:name (-> entries first :name) + :version 3 + :project-id project-id}) + (rx/tap (fn [event] + (let [payload (sse/get-payload event) + type (sse/get-type event)] + (cond + (= type "progress") + (log/dbg :hint "import-binfile: progress" + :section (:section payload) + :name (:name payload)) + + :else + (log/dbg :hint "import-binfile: end"))))) + (rx/filter sse/end-of-stream?) + (rx/mapcat (fn [message] + (let [{:keys [resolution]} (sse/get-payload message)] + (when (seq resolution) + (vswap! resolutions merge resolution)) + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :finish + :file-id (:file-id entry)})))))) + (rx/catch (fn [cause] + (log/error :hint "import-binfile: unexpected error on importing" + :project-id project-id + ::log/sync? true + :cause cause) + (let [err (import-cause-message cause (tr "labels.error"))] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :error + :error err + :file-id (:file-id entry)})))))))))) + (->> (rx/defer #(rx/of @resolutions)) + (rx/map (fn [resolutions] + {:libraries-resolution resolutions}))))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b85b0f4cc0..2be53cb160 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -761,6 +761,35 @@ msgstr[1] "%s files have been imported successfully." msgid "dashboard.import.import-warning" msgstr "Some files containted invalid objects that have been removed." +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 library was automatically linked by name." +msgstr[1] "%s libraries were automatically linked by name." + +msgid "dashboard.import.resolve-libraries" +msgstr "Some libraries couldn't be linked automatically. Select the correct library for each:" + +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Review the library links before confirming:" + +msgid "dashboard.import.confirm-library-links" +msgstr "Confirm library links" + +msgid "dashboard.import.review-links" +msgstr "Review links" + +msgid "dashboard.import.summary.auto-linked" +msgstr "Auto-linked" + +msgid "dashboard.import.summary.your-selection" +msgstr "Your selection" + +msgid "dashboard.import.summary.linked" +msgstr "Linked" + +msgid "dashboard.import.summary.no-selection" +msgstr "No library selected" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "You don’t have permission to import to this team" @@ -2122,34 +2151,70 @@ msgid "files-download-modal.description-2" msgstr "* Might include components, graphics, colors and/or typographies." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Files with shared libraries will be included in the export, maintaining " -"their linkage." + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Files with linked libraries will be included in the export, maintaining " +"their linkage. " #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Export shared libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Export file + libraries" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Export file + libraries" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" -msgstr "" -"Shared libraries will not be included in the export and no assets will be " -"added to the library. " +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Link matching libraries on import" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "When imported, you'll be able to link existing libraries with matching names." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Treat shared library assets as basic objects" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Treat assets as basic objects" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Treat assets as basic objects" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Your file will be exported with all external assets merged into the file " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Your file will be exported with library asset merged into the local " "library." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" -msgstr "Include shared library assets in file libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Embed library assets in the file" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" +msgstr "Embed library assets in the file" #: src/app/main/ui/exports/files.cljs:123 msgid "files-download-modal.title" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index acfeeb8fab..897a41f317 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -765,6 +765,35 @@ msgstr[1] "%s ficheros se han importado correctamente." msgid "dashboard.import.import-warning" msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados." +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 biblioteca fue vinculada automáticamente por nombre." +msgstr[1] "%s bibliotecas fueron vinculadas automáticamente por nombre." + +msgid "dashboard.import.resolve-libraries" +msgstr "Algunas bibliotecas no pudieron vincularse automáticamente. Selecciona la biblioteca correcta para cada una:" + +msgid "dashboard.import.confirm-library-links" +msgstr "Confirmar vínculos de biblioteca" + +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Revisa los vínculos de biblioteca antes de confirmar:" + +msgid "dashboard.import.review-links" +msgstr "Revisar vínculos" + +msgid "dashboard.import.summary.auto-linked" +msgstr "Vinculadas automáticamente" + +msgid "dashboard.import.summary.your-selection" +msgstr "Tu selección" + +msgid "dashboard.import.summary.linked" +msgstr "Vinculada" + +msgid "dashboard.import.summary.no-selection" +msgstr "Ninguna biblioteca seleccionada" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "No tienes permisos para importar en este equipo" @@ -2066,33 +2095,70 @@ msgid "files-download-modal.description-2" msgstr "* Pueden incluir components, gráficos, colores y/o tipografias." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Ficheros con librerias compartidas se inclurán en el paquete de exportación " + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Los ficheros con librerias compartidas se inclurán en el paquete de exportación " "y mantendrán los enlaces." #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Exportar librerias compartidas" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Exportar archivo + librerías" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Exportar archivo + librerías" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" msgstr "" -"Las biblioteca compartidas no se incluirán en la exportación y ningún " -"recurso será incluido en la biblioteca. " + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "" +"Las recursos de las bibliotecas compartidas no se incluirán en la exportación." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Vincular librerías al importar" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "Al importar, podrás vincular bibliotecas existentes con el mismo nombre." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Usar los recursos como objetos básicos" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Tratar los recursos como objetos básicos" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Tratar los recursos como objetos básicos" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Tu fichero será exportado con todos los recursos dentro de la libreria del " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Tu fichero será exportado con todos los recursos externos dentro de la libreria del " "propio fichero." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Incluir librerias compartidas dentro de las librerias del fichero" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" msgstr "Incluir librerias compartidas dentro de las librerias del fichero" #: src/app/main/ui/exports/files.cljs:123 diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index abb4e981c1..83a0c9ec66 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1646,19 +1646,24 @@ export interface File extends PluginData { * - `'penpot'` will create a *.penpot file with a binary representation of the file * - `'zip'` will create a *.zip with the file exported in several SVG files with some JSON metadata * @param `libraryExportType` indicates what to do with the linked libraries of the file when - * exporting it. Defaults to `all` if not sent. - * - `'all'` will include the libraries as external files that will be exported in a single bundle - * - `'merge'` will add all the assets into the main file and only one file will be imported - * - `'detach'` will unlink all the external assets and no libraries will be imported + * exporting it. Defaults to `'include-libraries'` if not sent. + * - `'include-libraries'` will include the libraries as external files that will be exported in a single bundle + * - `'merge-libraries'` will add all the assets into the main file and only one file will be imported + * - `'detach-libraries'` will unlink all the external assets and no libraries will be imported + * - `'link-later'` will preserve component metadata so instances can be relinked on import * * @example * ```js - * const exportedData = await file.export('penpot', 'all'); + * const exportedData = await file.export('penpot', 'include-libraries'); * ``` */ export( exportType: 'penpot' | 'zip', - libraryExportType?: 'all' | 'merge' | 'detach', + libraryExportType?: + | 'include-libraries' + | 'merge-libraries' + | 'detach-libraries' + | 'link-later', ): Promise; /** From 6f35348c7c701b220651f8665de028b2814182cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 27 Aug 2026 10:51:08 +0200 Subject: [PATCH 206/298] :whale: Pin docker images to 2.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 59b326c76d..45b2361510 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}" restart: always ports: - 9001:8080 @@ -111,7 +111,7 @@ services: # PENPOT_DISABLE_IPV6_LISTEN: "true" penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.17}" restart: always volumes: @@ -180,13 +180,13 @@ services: PENPOT_SMTP_SSL: "false" penpot-mcp: - image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}" + image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}" restart: always networks: - penpot penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}" restart: always depends_on: From 217284b1e145dd7509806623ab64f1a274b2cafe Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 27 Aug 2026 10:51:10 +0200 Subject: [PATCH 207/298] :sparkles: Improve path operations and edition (#10807) * :sparkles: Improve path operations and edition * :bug: Fix floating-point equality issues in path editing Replace exact equality checks with tolerance-based comparisons in path editing functions to handle floating-point rounding differences after transforms, rotations, or curve fitting. Changes: - distribute-content: Round coordinates to 0.1 precision before grouping to ensure coincident nodes move together - separate-node: Use gpt/close? instead of exact equality to find nodes with floating-point imprecision - collision-step: Use mth/close? for tolerance-based comparison to detect paste collisions correctly - resolve-edit-fills: Add cycle detection to prevent infinite loops with corrupted parent chains Made collision-step, available-offset-step, and resolve-edit-fills public for better testability. Added comprehensive tests for all fixes covering both exact and floating-point coordinate scenarios. AI-assisted-by: qwen3.7-plus * :bug: Fix path editor code review findings Fix issues identified during code review of path editor enhancements: - Fix unused binding lint warning in distribute-content that blocked CI - Fix collision-step floor comparison to use round instead of floor, correctly detecting collisions when coordinates drift slightly below integer boundaries - Fix resolve-edit-fills to recurse through empty parent groups when searching for inherited fills in nested group hierarchies - Fix expand-coincident-node-indices to use fuzzy comparison (gpt/close?) instead of exact equality, handling floating-point divergence after transforms or rotations - Remove unreachable dead code in path-point* on-pointer-down handler - Add tests for collision-step boundary cases, nested group fill inheritance, and coincident node alignment/flipping AI-assisted-by: mimo-v2.5-pro --------- Co-authored-by: Andrey Antukh --- common/src/app/common/types/path.cljc | 189 ++- common/src/app/common/types/path/fit.cljc | 209 ++++ common/src/app/common/types/path/helpers.cljc | 82 +- common/src/app/common/types/path/segment.cljc | 826 ++++++++++---- .../src/app/common/types/path/selection.cljc | 213 ++++ common/src/app/common/types/path/subpath.cljc | 39 +- common/src/app/common/types/shape.cljc | 3 +- .../common_tests/types/path_data_test.cljc | 1013 ++++++++++++++++- .../playwright/ui/specs/workspace.spec.js | 34 +- .../resources/images/cursors/draw-add.svg | 6 + .../resources/images/cursors/draw-node.svg | 6 + .../resources/images/cursors/draw-remove.svg | 6 + frontend/resources/images/cursors/draw.svg | 6 + .../resources/images/cursors/move-add.svg | 8 + .../resources/images/cursors/move-copy.svg | 6 + .../resources/images/cursors/move-curve.svg | 7 + .../resources/images/cursors/move-handles.svg | 8 + .../resources/images/cursors/move-move.svg | 8 + .../resources/images/cursors/move-node.svg | 7 + .../resources/images/cursors/move-remove.svg | 7 + frontend/resources/images/cursors/move.svg | 6 + .../resources/images/icons/handlers-equal.svg | 3 + .../images/icons/handlers-independent.svg | 3 + .../images/icons/handlers-mirror.svg | 3 + frontend/resources/images/icons/snap.svg | 3 + .../styles/common/refactor/color-defs.scss | 1 + frontend/src/app/main/data/workspace.cljs | 1 + .../app/main/data/workspace/clipboard.cljs | 6 +- .../main/data/workspace/drawing/common.cljs | 16 +- .../main/data/workspace/drawing/curve.cljs | 6 +- .../src/app/main/data/workspace/edition.cljs | 47 +- .../src/app/main/data/workspace/path.cljs | 29 + .../app/main/data/workspace/path/changes.cljs | 117 +- .../main/data/workspace/path/clipboard.cljs | 209 ++++ .../app/main/data/workspace/path/common.cljs | 30 +- .../app/main/data/workspace/path/drawing.cljs | 448 ++++++-- .../app/main/data/workspace/path/edition.cljs | 852 ++++++++++++-- .../app/main/data/workspace/path/helpers.cljs | 495 +++++++- .../main/data/workspace/path/selection.cljs | 160 ++- .../data/workspace/path/shapes_to_path.cljs | 40 +- .../main/data/workspace/path/shortcuts.cljs | 70 +- .../app/main/data/workspace/path/state.cljs | 50 +- .../app/main/data/workspace/path/streams.cljs | 119 +- .../app/main/data/workspace/path/tools.cljs | 358 +++++- .../app/main/data/workspace/path/undo.cljs | 32 +- .../app/main/data/workspace/transforms.cljs | 67 ++ frontend/src/app/main/refs.cljs | 3 + frontend/src/app/main/ui/css_cursors.cljs | 12 + frontend/src/app/main/ui/cursors.clj | 19 +- frontend/src/app/main/ui/cursors.cljs | 12 + frontend/src/app/main/ui/icons.cljs | 4 + frontend/src/app/main/ui/measurements.cljs | 111 +- .../app/main/ui/workspace/context_menu.cljs | 5 +- .../main/ui/workspace/shapes/path/editor.cljs | 517 ++++++--- .../main/ui/workspace/sidebar/options.cljs | 31 +- .../sidebar/options/menus/align.cljs | 30 +- .../sidebar/options/menus/measures.cljs | 23 + .../sidebar/options/shapes/path.cljs | 100 +- .../app/main/ui/workspace/top_toolbar.cljs | 15 +- .../src/app/main/ui/workspace/viewport.cljs | 67 +- .../main/ui/workspace/viewport/drawarea.cljs | 60 +- .../app/main/ui/workspace/viewport/hooks.cljs | 11 +- .../ui/workspace/viewport/path_actions.cljs | 313 +++-- .../ui/workspace/viewport/path_actions.scss | 96 +- .../ui/workspace/viewport/path_state.cljs | 39 + .../main/ui/workspace/viewport/selection.cljs | 233 ++-- .../main/ui/workspace/viewport/top_bar.cljs | 20 + .../app/main/ui/workspace/viewport/utils.cljs | 2 + .../ui/workspace/viewport/viewport_ref.cljs | 5 + .../app/main/ui/workspace/viewport_wasm.cljs | 69 +- .../data/workspace_path_edition_test.cljs | 29 + .../logic/path_actions_test.cljs | 120 ++ .../logic/path_clipboard_test.cljs | 257 +++++ .../logic/path_helpers_test.cljs | 117 ++ .../logic/path_lifecycle_test.cljs | 542 +++++++++ .../logic/path_test_helpers.cljs | 94 ++ .../frontend_tests/logic/path_tools_test.cljs | 804 +++++++++++++ frontend/test/frontend_tests/runner.cljs | 10 + frontend/translations/en.po | 24 + frontend/translations/es.po | 23 + render-wasm/src/shapes.rs | 6 +- 81 files changed, 8414 insertions(+), 1263 deletions(-) create mode 100644 common/src/app/common/types/path/fit.cljc create mode 100644 common/src/app/common/types/path/selection.cljc create mode 100644 frontend/resources/images/cursors/draw-add.svg create mode 100644 frontend/resources/images/cursors/draw-node.svg create mode 100644 frontend/resources/images/cursors/draw-remove.svg create mode 100644 frontend/resources/images/cursors/draw.svg create mode 100644 frontend/resources/images/cursors/move-add.svg create mode 100644 frontend/resources/images/cursors/move-copy.svg create mode 100644 frontend/resources/images/cursors/move-curve.svg create mode 100644 frontend/resources/images/cursors/move-handles.svg create mode 100644 frontend/resources/images/cursors/move-move.svg create mode 100644 frontend/resources/images/cursors/move-node.svg create mode 100644 frontend/resources/images/cursors/move-remove.svg create mode 100644 frontend/resources/images/cursors/move.svg create mode 100644 frontend/resources/images/icons/handlers-equal.svg create mode 100644 frontend/resources/images/icons/handlers-independent.svg create mode 100644 frontend/resources/images/icons/handlers-mirror.svg create mode 100644 frontend/resources/images/icons/snap.svg create mode 100644 frontend/src/app/main/data/workspace/path/clipboard.cljs create mode 100644 frontend/src/app/main/ui/workspace/viewport/path_state.cljs create mode 100644 frontend/test/frontend_tests/logic/path_actions_test.cljs create mode 100644 frontend/test/frontend_tests/logic/path_clipboard_test.cljs create mode 100644 frontend/test/frontend_tests/logic/path_helpers_test.cljs create mode 100644 frontend/test/frontend_tests/logic/path_lifecycle_test.cljs create mode 100644 frontend/test/frontend_tests/logic/path_test_helpers.cljs create mode 100644 frontend/test/frontend_tests/logic/path_tools_test.cljs diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index a2e3b14b16..a198e57cfa 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -18,6 +18,7 @@ [app.common.types.path.helpers :as helpers] [app.common.types.path.impl :as impl] [app.common.types.path.segment :as segment] + [app.common.types.path.selection :as selection] [app.common.types.path.shape-to-path :as stp] [app.common.types.path.subpath :as subpath])) @@ -209,6 +210,120 @@ (let [content (impl/path-data content)] (segment/get-points content))) +(defn segment-entries + "Selectable path segments with their command index and endpoints." + [content] + (let [content (impl/path-data content)] + (segment/segment-entries content))) + +(defn single-line? + "True when the content is a single straight segment: a move-to + followed by exactly one line-to." + [content] + (and (some? content) + (= 2 (count content)) + (= :move-to (:command (nth content 0))) + (= :line-to (:command (nth content 1))))) + +(defn close-loops + "Closes subpaths whose endpoints meet and returns PathData." + [content] + (-> (subpath/close-loops content) + (impl/from-plain))) + +(defn extract-content + "Extracts selected segments and segments between selected nodes into new + subpaths." + [content {:keys [nodes segments]}] + (let [content (impl/path-data content) + nodes (or nodes #{}) + segments (or segments #{}) + selected? (fn [{:keys [index from-index to-index]}] + (or (contains? segments index) + (and (contains? nodes from-index) + (contains? nodes to-index)))) + entries (filterv selected? (segment/segment-entries content)) + plain (loop [entries (seq entries) + prev nil + result (transient [])] + (if-let [{:keys [from from-index to segment] :as entry} (first entries)] + (let [result (cond-> result + (not= from-index (:to-index prev)) + (conj! {:command :move-to + :params {:x (:x from) :y (:y from)}})) + result (conj! result + (if (= :close-path (:command segment)) + {:command :line-to + :params {:x (:x to) :y (:y to)}} + segment))] + (recur (next entries) entry result)) + (persistent! result)))] + (-> (close-subpaths (impl/path-data plain)) + (close-loops)))) + +(defn splice-content + "Appends sub-content as new subpaths." + [content sub-content] + (impl/path-data (into (vec content) (vec sub-content)))) + +(defn- move-segment-end + "Moves a segment endpoint and its incoming handle by `delta`." + [segment {dx :x dy :y}] + (cond-> (-> segment + (update-in [:params :x] + dx) + (update-in [:params :y] + dy)) + (= :curve-to (:command segment)) + (-> (update-in [:params :c2x] + dx) + (update-in [:params :c2y] + dy)))) + +(defn- segment->end + "Returns the command arriving at an entry's end node as a drawable segment." + [{:keys [to segment]}] + (if (= :close-path (:command segment)) + {:command :line-to :params {:x (:x to) :y (:y to)}} + segment)) + +(defn- reverse-segment + "Reverses a segment toward `from`, swapping curve handles." + [segment {fx :x fy :y}] + (if (= :curve-to (:command segment)) + (let [{:keys [c1x c1y c2x c2y]} (:params segment)] + {:command :curve-to + :params {:x fx :y fy :c1x c2x :c1y c2y :c2x c1x :c2y c1y}}) + {:command :line-to :params {:x fx :y fy}})) + +(defn duplicate-node-content + "Copies a node and its incident segments, keeping their far ends attached. + Returns copied content and the relative indices of the new node." + [content index node-offset] + (let [content (impl/path-data content) + entries (segment-entries content) + incident (filterv #(or (= index (:to-index %)) + (= index (:from-index %))) + entries)] + (if (seq incident) + (reduce (fn [{:keys [content selected]} {:keys [from to to-index segment] :as entry}] + (let [incoming? (= index to-index) + start (if incoming? from to) + end (if incoming? + (segment->end entry) + (reverse-segment segment from)) + end (cond-> end + (some? node-offset) (move-segment-end node-offset))] + {:content (conj content + {:command :move-to + :params {:x (:x start) :y (:y start)}} + end) + :selected (conj selected (inc (count content)))})) + {:content [] :selected #{}} + incident) + (when-let [{:keys [x y]} (:params (nth content index nil))] + {:content [{:command :move-to + :params (if (some? node-offset) + {:x (+ x (:x node-offset)) :y (+ y (:y node-offset))} + {:x x :y y})}] + :selected #{0}})))) + (defn calc-selrect "Calculate selrect from a content. The content can be in a PathData instance or plain vector of segments." @@ -279,6 +394,11 @@ [points & {:keys [close]}] (segment/points->content points :close close)) +(defn smooth-points->content + "Fits smooth path content through `points`." + [points tolerance] + (segment/smooth-points->content points tolerance)) + (defn closest-point "Returns the closest point in the path to position, at a given precision." [content position precision] @@ -304,6 +424,30 @@ (let [content (impl/path-data content)] (segment/split-segments content points value))) +(defn is-curve-point? + "True when a node has at least one visible handler." + [content point] + (let [content (impl/path-data content)] + (boolean (segment/is-curve? content point)))) + +(defn collapse-handler + "Collapses a handler onto its node and simplifies flat curves to lines." + [content index prefix] + (let [content (impl/path-data content)] + (segment/collapse-handler content index prefix))) + +(defn toggle-segment-curve + "Toggles a segment between a line and a curve." + [content index] + (let [content (impl/path-data content)] + (segment/toggle-segment-curve content index))) + +(defn remove-segments + "Removes segments, opening their subpaths and dropping empty ones." + [content indices] + (let [content (impl/path-data content)] + (segment/remove-segments content indices))) + (defn remove-nodes "Removes the given points from content, reconstructing paths as needed." [content points] @@ -323,10 +467,49 @@ (segment/join-nodes content points))) (defn separate-nodes - "Removes the segments between the given points." - [content points] + "Removes segments between points or splits one node into offset open ends." + ([content points] + (let [content (impl/path-data content)] + (segment/separate-nodes content points))) + ([content points offset] + (let [content (impl/path-data content)] + (segment/separate-nodes content points offset)))) + +(defn flip-content + "Flips selected nodes and handles across their bounding box." + [content indices axis] (let [content (impl/path-data content)] - (segment/separate-nodes content points))) + (selection/flip-content content indices axis))) + +(defn align-content + "Aligns selected nodes and handles within their bounding box." + [content indices axis] + (let [content (impl/path-data content)] + (selection/align-content content indices axis))) + +(defn distribute-content + "Distributes selected nodes evenly along `axis`." + [content indices axis] + (let [content (impl/path-data content)] + (selection/distribute-content content indices axis))) + +(defn set-nodes-coordinate + "Sets one coordinate of selected nodes and their handles." + [content indices axis value] + (let [content (impl/path-data content)] + (selection/set-nodes-coordinate content indices axis value))) + +(defn set-handler-points + "Moves each handler in `pts` to its target point." + [content pts] + (let [content (impl/path-data content)] + (selection/set-handler-points content pts))) + +(defn translate-selected-nodes + "Moves selected nodes and their handles by `delta`." + [content indices delta] + (let [content (impl/path-data content)] + (selection/translate-selected-nodes content indices delta))) (defn- calc-bool-content* "Calculate the boolean content from shape and objects. Returns plain diff --git a/common/src/app/common/types/path/fit.cljc b/common/src/app/common/types/path/fit.cljc new file mode 100644 index 0000000000..486822a79f --- /dev/null +++ b/common/src/app/common/types/path/fit.cljc @@ -0,0 +1,209 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.path.fit + "Curve fitting helpers." + (:require + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.types.path.helpers :as helpers])) + +(defn- chord-length-params + "Returns normalized chord-length parameters for `points`." + [points] + (let [dists (->> (map gpt/distance points (rest points)) + (reductions + 0) + (vec)) + total (peek dists)] + (if (mth/almost-zero? total) + (let [n (max 1 (dec (count points)))] + (mapv #(/ (double %) n) (range (count points)))) + (mapv #(/ % total) dists)))) + +(defn fit-cubic + "Fits one cubic through `points` with fixed endpoints and tangents." + ([points tan1 tan2] + (let [points (vec points)] + (fit-cubic points (chord-length-params points) tan1 tan2))) + ([points params tan1 tan2] + (let [points (vec points) + p0 (first points) + p3 (peek points) + + [c00 c01 c11 x0 x1] + (reduce + (fn [[c00 c01 c11 x0 x1] [point u]] + (let [u' (- 1.0 u) + b0 (* u' u' u') + b1 (* 3.0 u u' u') + b2 (* 3.0 u u u') + b3 (* u u u) + a1 (gpt/scale tan1 b1) + a2 (gpt/scale tan2 b2) + tmp (-> point + (gpt/subtract (gpt/scale p0 (+ b0 b1))) + (gpt/subtract (gpt/scale p3 (+ b2 b3))))] + [(+ c00 (gpt/dot a1 a1)) + (+ c01 (gpt/dot a1 a2)) + (+ c11 (gpt/dot a2 a2)) + (+ x0 (gpt/dot a1 tmp)) + (+ x1 (gpt/dot a2 tmp))])) + [0.0 0.0 0.0 0.0 0.0] + (map vector points params)) + + det-c (- (* c00 c11) (* c01 c01)) + alpha1 (when-not (mth/almost-zero? det-c) + (/ (- (* x0 c11) (* x1 c01)) det-c)) + alpha2 (when-not (mth/almost-zero? det-c) + (/ (- (* c00 x1) (* c01 x0)) det-c)) + + chord (gpt/distance p0 p3) + epsilon (* 0.000001 chord) + + [alpha1 alpha2] + (if (or (nil? alpha1) (nil? alpha2) + (< alpha1 epsilon) (< alpha2 epsilon)) + [(/ chord 3.0) (/ chord 3.0)] + [alpha1 alpha2])] + + [(gpt/add p0 (gpt/scale tan1 alpha1)) + (gpt/add p3 (gpt/scale tan2 alpha2))]))) + +(defn- curve-d1 + "Returns the first derivative at `t`." + [[start end h1 h2] t] + (let [t' (- 1.0 t) + a (* 3.0 t' t') + b (* 6.0 t' t) + c (* 3.0 t t)] + (gpt/point (+ (* a (- (:x h1) (:x start))) + (* b (- (:x h2) (:x h1))) + (* c (- (:x end) (:x h2)))) + (+ (* a (- (:y h1) (:y start))) + (* b (- (:y h2) (:y h1))) + (* c (- (:y end) (:y h2))))))) + +(defn- curve-d2 + "Returns the second derivative at `t`." + [[start end h1 h2] t] + (let [t' (- 1.0 t)] + (gpt/point (+ (* 6.0 t' (+ (:x h2) (* -2.0 (:x h1)) (:x start))) + (* 6.0 t (+ (:x end) (* -2.0 (:x h2)) (:x h1)))) + (+ (* 6.0 t' (+ (:y h2) (* -2.0 (:y h1)) (:y start))) + (* 6.0 t (+ (:y end) (* -2.0 (:y h2)) (:y h1))))))) + +(defn- refine-parameter + "Moves `u` toward the closest point on `curve`." + [curve point u] + (let [d (gpt/subtract (helpers/curve-values curve u) point) + d1 (curve-d1 curve u) + d2 (curve-d2 curve u) + den (+ (gpt/dot d1 d1) (gpt/dot d d2))] + (if (mth/almost-zero? den) + u + (mth/clamp (- u (/ (gpt/dot d d1) den)) 0.0 1.0)))) + +(defn- max-fit-error + "Returns the largest interior fit error and its index." + [points params curve] + (let [n (count points)] + (loop [i 1 + max-err 0.0 + split (quot n 2)] + (if (>= i (dec n)) + [max-err split] + (let [d (gpt/subtract (helpers/curve-values curve (nth params i)) + (nth points i)) + err (gpt/dot d d)] + (if (> err max-err) + (recur (inc i) err i) + (recur (inc i) max-err split))))))) + +(def ^:private ^:const max-fit-iterations 4) + +(defn- fit-curve* + "Fits one or more curves through at least two points." + [points tan1 tan2 tol2] + (let [n (count points) + p0 (first points) + p3 (peek points)] + (if (= n 2) + (let [alpha (/ (gpt/distance p0 p3) 3.0)] + [[p0 p3 + (gpt/add p0 (gpt/scale tan1 alpha)) + (gpt/add p3 (gpt/scale tan2 alpha))]]) + + (let [params (chord-length-params points) + [h1 h2] (fit-cubic points params tan1 tan2) + curve [p0 p3 h1 h2] + [err split] (max-fit-error points params curve) + + [curve err split] + (if (and (> err tol2) (<= err (* 16.0 tol2))) + (loop [it 0 + params params + curve curve + err err + split split] + (if (or (>= it max-fit-iterations) (<= err tol2)) + [curve err split] + (let [params (mapv #(refine-parameter curve %1 %2) points params) + [h1 h2] (fit-cubic points params tan1 tan2) + curve [p0 p3 h1 h2] + [err split] (max-fit-error points params curve)] + (recur (inc it) params curve err split)))) + [curve err split])] + + (if (<= err tol2) + [curve] + (let [split (mth/clamp split 1 (- n 2)) + center (let [v (gpt/to-vec (nth points (inc split)) + (nth points (dec split)))] + (if (mth/almost-zero? (gpt/length v)) + (gpt/unit (gpt/to-vec (nth points split) + (nth points (dec split)))) + (gpt/unit v)))] + (into (fit-curve* (subvec points 0 (inc split)) tan1 center tol2) + (fit-curve* (subvec points split) (gpt/negate center) tan2 tol2)))))))) + +(def ^:private default-corner-angle 60.0) + +(defn- corner-index? + "True when point `i` turns more than `corner-angle` degrees." + [points i corner-angle] + (let [v-in (gpt/to-vec (nth points (dec i)) (nth points i)) + v-out (gpt/to-vec (nth points i) (nth points (inc i)))] + (and (not (mth/almost-zero? (gpt/length v-in))) + (not (mth/almost-zero? (gpt/length v-out))) + (> (gpt/angle-with-other v-in v-out) corner-angle)))) + +(defn fit-curve + "Fits chained cubic curves through `points` within `tolerance`." + ([points tolerance] + (fit-curve points tolerance default-corner-angle)) + ([points tolerance corner-angle] + (let [points (reduce (fn [acc point] + (if (and (seq acc) + (< (gpt/distance (peek acc) point) 0.01)) + acc + (conj acc point))) + [] + points) + n (count points)] + (when (>= n 2) + (let [tol2 (* (double tolerance) (double tolerance)) + corners (into [] (filter #(corner-index? points % corner-angle)) + (range 1 (dec n))) + bounds (concat [0] corners [(dec n)])] + (into [] + (mapcat (fn [[a b]] + (let [span (subvec points a (inc b)) + m (count span)] + (when (>= m 2) + (let [tan1 (gpt/unit (gpt/to-vec (nth span 0) (nth span 1))) + tan2 (gpt/unit (gpt/to-vec (nth span (dec m)) (nth span (- m 2))))] + (fit-curve* span tan1 tan2 tol2)))))) + (partition 2 1 bounds))))))) diff --git a/common/src/app/common/types/path/helpers.cljc b/common/src/app/common/types/path/helpers.cljc index 0cec40d119..fd9ecdf64e 100644 --- a/common/src/app/common/types/path/helpers.cljc +++ b/common/src/app/common/types/path/helpers.cljc @@ -72,16 +72,9 @@ nil)) (defn- closest-angle + "Snaps an angle (in degrees) to the nearest 15° increment." [angle] - (cond - (or (> angle 337.5) (<= angle 22.5)) 0 - (and (> angle 22.5) (<= angle 67.5)) 45 - (and (> angle 67.5) (<= angle 112.5)) 90 - (and (> angle 112.5) (<= angle 157.5)) 135 - (and (> angle 157.5) (<= angle 202.5)) 180 - (and (> angle 202.5) (<= angle 247.5)) 225 - (and (> angle 247.5) (<= angle 292.5)) 270 - (and (> angle 292.5) (<= angle 337.5)) 315)) + (mth/round angle 15)) (defn position-fixed-angle [point from-point] @@ -119,6 +112,13 @@ (gpt/point (-> segment :params :c1x) (-> segment :params :c1y)) (gpt/point (-> segment :params :c2x) (-> segment :params :c2y))])) +(defn entry->bezier + "Returns a segment entry as `[start end h1 h2]`." + [{:keys [from to segment]}] + (if (= :curve-to (:command segment)) + (command->bezier segment from) + [from to from to])) + (declare curve-extremities) (declare curve-values) @@ -189,6 +189,70 @@ (gpt/point (coord-v :x) (coord-v :y))))) +(defn curve-closest-t + "Finds the cubic parameter closest to `position`." + [[start end h1 h2] position precision] + (let [d (fn [t] (gpt/distance position (curve-values start end h1 h2 t)))] + (loop [t1 0.0 + t2 1.0] + (if (<= (mth/abs (- t1 t2)) precision) + t1 + (let [ht (+ t1 (/ (- t2 t1) 2)) + ht1 (+ t1 (/ (- t2 t1) 4)) + ht2 (+ t1 (/ (* 3 (- t2 t1)) 4)) + + [t1 t2] (cond + (< (d ht1) (d ht2)) [t1 ht] + (< (d ht2) (d ht1)) [ht t2] + (and (< (d ht) (d t1)) (< (d ht) (d t2))) [ht1 ht2] + (< (d t1) (d t2)) [t1 ht] + :else [ht t2])] + (recur (double t1) (double t2))))))) + +(def ^:private arc-length-samples + "Samples for approximating a cubic's length." + 100) + +(defn curve-arc-length-t + "Finds the cubic parameter at half its arc length." + [[start end h1 h2]] + (let [n arc-length-samples + pts (mapv (fn [i] (curve-values start end h1 h2 (/ (double i) n))) + (range (inc n))) + dists (->> (map gpt/distance pts (rest pts)) + (reductions + 0.0) + (vec)) + total (peek dists)] + (if (mth/almost-zero? total) + 0.5 + (let [half (/ total 2.0) + i (loop [i 0] + (if (and (< (inc i) (count dists)) + (< (nth dists (inc i)) half)) + (recur (inc i)) + i)) + d0 (nth dists i) + d1 (nth dists (inc i)) + frac (if (mth/almost-zero? (- d1 d0)) + 0.0 + (/ (- half d0) (- d1 d0)))] + (/ (+ i frac) n))))) + +(defn bend-curve-deltas + "Returns the smallest handler deltas that move the point at `t` to `target`." + [curve t target] + (let [t' (- 1.0 t) + b (* 3.0 t' t' t) + c (* 3.0 t' t t) + delta (gpt/subtract target (curve-values curve t)) + denom (+ (* b b) (* c c))] + (if (mth/almost-zero? denom) + {:c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0} + (let [k1 (/ b denom) + k2 (/ c denom)] + {:c1x (* k1 (:x delta)) :c1y (* k1 (:y delta)) + :c2x (* k2 (:x delta)) :c2y (* k2 (:y delta))})))) + (defn solve-roots* "Solvers a quadratic or cubic equation given by the parameters a b c d. diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index c26813d1e0..82c83483ca 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -13,8 +13,10 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.math :as mth] + [app.common.types.path.fit :as fit] [app.common.types.path.helpers :as helpers] [app.common.types.path.impl :as impl] + [app.common.types.path.subpath :as subpath] [clojure.set :as set])) #?(:clj (set! *warn-on-reflection* true)) @@ -139,6 +141,42 @@ (gpt/point x y))) []))) +(defn segment-entries + "Returns selectable segments with their command index and endpoints." + [content] + (loop [index 0 + pending (seq content) + previous nil + previous-index nil + subpath-start nil + subpath-start-index nil + result []] + (if-let [{:keys [command] :as segment} (first pending)] + (let [close-path? (= command :close-path) + move-to? (= command :move-to) + point (if close-path? + subpath-start + (helpers/segment->point segment)) + point-index (if close-path? subpath-start-index index) + result (cond-> result + (and previous point (not move-to?)) + (conj {:index index + :from previous + :from-index previous-index + :to point + :to-index point-index + :segment segment})) + subpath-start (if move-to? point subpath-start) + subpath-start-index (if move-to? index subpath-start-index)] + (recur (inc index) + (next pending) + point + point-index + subpath-start + subpath-start-index + result)) + result))) + ;; FIXME: incorrect API, don't need full shape (defn path->lines "Given a path returns a list of lines that approximate the path" @@ -312,34 +350,6 @@ (impl/from-plain content))) -(defn- line->curve - [from-p segment] - - (let [to-p (helpers/segment->point segment) - - v (gpt/to-vec from-p to-p) - d (gpt/distance from-p to-p) - - dv1 (-> (gpt/normal-left v) - (gpt/scale (/ d 3))) - - h1 (gpt/add from-p dv1) - - dv2 (-> (gpt/to-vec to-p h1) - (gpt/unit) - (gpt/scale (/ d 3))) - - h2 (gpt/add to-p dv2)] - (-> segment - (assoc :command :curve-to) - (update :params (fn [params] - ;; ensure plain map - (-> (into {} params) - (assoc :c1x (:x h1)) - (assoc :c1y (:y h1)) - (assoc :c2x (:x h2)) - (assoc :c2y (:y h2)))))))) - ;; FIXME: optimize (defn is-curve? [content point] @@ -353,111 +363,123 @@ (mapcat #(list (:next-p %) (:prev-p %))) (remove nil?))) +(defn- curve-neighbourhood + "Returns the adjacent segments and points for one node." + [content index] + (let [segment (get content index) + prev-i (dec index) + prev (when (not= :move-to (:command segment)) + (get content prev-i)) + next-i (inc index) + next (get content next-i) + next (when (not= :move-to (:command next)) next)] + {:index index + :prev-i (when (some? prev) prev-i) + :prev-c prev + :prev-p (helpers/segment->point prev) + :next-i (when (some? next) next-i) + :next-c next + :next-p (helpers/segment->point next) + :segment segment})) + +(defn- smooth-tangent + "Returns tangent data for a smooth curve node." + [content point indices neighbourhoods neighbour-points] + (let [[first-point second-point] (vec neighbour-points) + prev-neighbour (some :prev-p neighbourhoods) + next-neighbour (some :next-p neighbourhoods) + seam? (and (= 2 (count indices)) + (= :move-to (:command (get content (first indices)))) + (let [end-index (last indices)] + (or (= end-index (dec (count content))) + (= :close-path + (:command (get content (inc end-index))))))) + first-unit (gpt/unit (gpt/to-vec point first-point)) + second-unit (gpt/unit (gpt/to-vec point second-point)) + angle-tangent (let [delta (gpt/subtract second-unit first-unit)] + (if (mth/almost-zero? (gpt/length delta)) + (gpt/perpendicular first-unit) + (gpt/unit delta))) + tangent (if seam? + (let [chord (gpt/to-vec prev-neighbour next-neighbour)] + (if (mth/almost-zero? (gpt/length chord)) + angle-tangent + (gpt/unit chord))) + angle-tangent) + length (/ (min (gpt/distance point first-point) + (gpt/distance point second-point)) + 3)] + {:tangent tangent + :length length + :seam? seam? + :prev-neighbour prev-neighbour + :next-neighbour next-neighbour})) + +(defn- smooth-handle + "Returns a smooth handle toward `neighbour`." + [point {:keys [tangent length seam? prev-neighbour next-neighbour]} neighbour] + (when (some? neighbour) + (let [direction (gpt/unit (gpt/to-vec point neighbour)) + side (cond + (and seam? (= neighbour prev-neighbour)) -1 + (and seam? (= neighbour next-neighbour)) 1 + :else (if (neg? (gpt/dot direction tangent)) -1 1))] + (gpt/add point (gpt/scale tangent (* side length)))))) + +(defn- apply-smooth-neighbour + "Adds smooth handles around one matching node." + [content point tangent-data {:keys [index prev-p next-p next-i]}] + (let [curr-command (:command (get content index)) + next-command (:command (get content next-i)) + prev-h (smooth-handle point tangent-data prev-p) + next-h (smooth-handle point tangent-data next-p)] + (cond-> content + (and (= :line-to curr-command) (some? prev-p)) + (update index helpers/update-curve-to prev-p prev-h) + + (and (= :line-to next-command) (some? next-p)) + (update next-i helpers/update-curve-to next-h next-p) + + (and (= :curve-to curr-command) (some? prev-p)) + (update index update-handler :c2 prev-h) + + (and (= :curve-to next-command) (some? next-p)) + (update next-i update-handler :c1 next-h)))) + +(defn- corner-handle + [point neighbour] + (gpt/add point (gpt/scale (gpt/to-vec point neighbour) (/ 1 3)))) + +(defn- apply-corner-neighbour + "Adds independent handles around one matching node." + [content point {:keys [index segment prev-p next-c next-i next-p]}] + (cond-> content + (and (= :line-to (:command segment)) (some? prev-p)) + (update index helpers/update-curve-to prev-p (corner-handle point prev-p)) + + (and (= :curve-to (:command segment)) (some? prev-p)) + (update index update-handler :c2 (corner-handle point prev-p)) + + (and (= :line-to (:command next-c)) (some? next-p)) + (update next-i helpers/update-curve-to (corner-handle point next-p) next-p) + + (and (= :curve-to (:command next-c)) (some? next-p)) + (update next-i update-handler :c1 (corner-handle point next-p)))) + (defn make-curve-point - "Changes the content to make the point a 'curve'. The handlers will be - positioned in the same vector that results from the previous->next - points but with fixed length; return a plain segments vector" + "Adds curve handles to every node at `point`." [content point] - - (let [;; We perform this operation before because it can be - ;; optimized with internal reduction so is better to use the - ;; PathData type before converting it to plain vector. - indices - (point-indices content point) - - ;; We transform content to a plain format for execute the - ;; algorithm because right now is the only way to execute it - content - (vec content) - - vectors - (map (fn [index] - (let [segment (get content index) - prev-i (dec index) - prev (when (not (= :move-to (:command segment))) - (get content prev-i)) - next-i (inc index) - next (get content next-i) - next (when (not (= :move-to (:command next))) - next)] - {:index index - :prev-i (when (some? prev) prev-i) - :prev-c prev - :prev-p (helpers/segment->point prev) - :next-i (when (some? next) next-i) - :next-c next - :next-p (helpers/segment->point next) - :segment segment})) - indices) - - points - (into #{} xf:mapcat-points vectors)] - - (if (= (count points) 2) - (let [[fpoint spoint] (vec points) - v1 (gpt/to-vec fpoint point) - v2 (gpt/to-vec fpoint spoint) - vp (gpt/project v1 v2) - vh (gpt/subtract v1 vp) - - add-curve - (fn [content {:keys [index prev-p next-p next-i]}] - (let [curr-segment (get content index) - curr-command (get curr-segment :command) - - next-segment (get content next-i) - next-command (get next-segment :command) - - ;; New handlers for prev-point and next-point - prev-h - (when (some? prev-p) (gpt/add prev-p vh)) - - next-h - (when (some? next-p) (gpt/add next-p vh)) - - ;; Correct 1/3 to the point improves the curve - prev-correction - (when (some? prev-h) (gpt/scale (gpt/to-vec prev-h point) (/ 1 3))) - - next-correction - (when (some? next-h) (gpt/scale (gpt/to-vec next-h point) (/ 1 3))) - - prev-h - (when (some? prev-h) (gpt/add prev-h prev-correction)) - - next-h - (when (some? next-h) (gpt/add next-h next-correction))] - - (cond-> content - (and (= :line-to curr-command) (some? prev-p)) - (update index helpers/update-curve-to prev-p prev-h) - - (and (= :line-to next-command) (some? next-p)) - (update next-i helpers/update-curve-to next-h next-p) - - (and (= :curve-to curr-command) (some? prev-p)) - (update index update-handler :c2 prev-h) - - (and (= :curve-to next-command) (some? next-p)) - (update next-i update-handler :c1 next-h))))] - - (reduce add-curve content vectors)) - - (let [add-curve - (fn [content {:keys [index segment prev-p next-c next-i]}] - (cond-> content - (= :line-to (:command segment)) - (update index #(line->curve prev-p %)) - - (= :curve-to (:command segment)) - (update index #(line->curve prev-p %)) - - (= :line-to (:command next-c)) - (update next-i #(line->curve point %)) - - (= :curve-to (:command next-c)) - (update next-i #(line->curve point %))))] - (reduce add-curve content vectors))))) + (let [indices (vec (point-indices content point)) + content (vec content) + neighbourhoods (mapv #(curve-neighbourhood content %) indices) + neighbour-points (into #{} xf:mapcat-points neighbourhoods)] + (if (= (count neighbour-points) 2) + (let [tangent-data (smooth-tangent + content point indices neighbourhoods neighbour-points)] + (reduce #(apply-smooth-neighbour %1 point tangent-data %2) + content + neighbourhoods)) + (reduce #(apply-corner-neighbour %1 point %2) content neighbourhoods)))) (defn get-segments-with-points "Given a content and a set of points return all the segments in the path @@ -528,6 +550,103 @@ (into [] (mapcat process-segments) (d/enumerate content)))) +(defn collapse-handler + "Collapses a handler onto its node and simplifies flat curves to lines." + [content index prefix] + (let [content (vec content) + node (handler->node content index prefix) + [cx cy] (helpers/prefix->coords prefix)] + (if (and (some? node) + (= :curve-to (dm/get-in content [index :command]))) + (impl/from-plain + (-> content + (assoc-in [index :params cx] (:x node)) + (assoc-in [index :params cy] (:y node)) + (remove-line-curves))) + (impl/from-plain content)))) + +(def ^:private curve-toggle-bow + "Perpendicular handle offset used when curving a line." + 0.25) + +(defn toggle-segment-curve + "Toggles a segment between a line and a bowed curve." + [content index] + (let [content (vec content) + segment (get content index) + from (helpers/segment->point (get content (dec index))) + to (helpers/segment->point segment)] + (impl/from-plain + (case (:command segment) + :line-to + (if (some? from) + (let [v (gpt/to-vec from to) + perp (gpt/scale (gpt/point (- (:y v)) (:x v)) curve-toggle-bow) + h1 (-> from (gpt/add (gpt/scale v (/ 1 3))) (gpt/add perp)) + h2 (-> from (gpt/add (gpt/scale v (/ 2 3))) (gpt/add perp))] + (update content index helpers/update-curve-to h1 h2)) + content) + + :curve-to + (assoc content index {:command :line-to + :params (select-keys (:params segment) [:x :y])}) + + content)))) + +(defn- subpath-start-indices + "Returns the starting command index for every command in `content`." + [content] + (loop [i 0 + start 0 + result (transient [])] + (if (>= i (count content)) + (persistent! result) + (let [start (if (= :move-to (:command (nth content i))) i start)] + (recur (inc i) start (conj! result start)))))) + +(defn remove-segments + "Removes segments and opens their subpaths. Closing segments become + lines when needed to preserve geometry." + [content indices] + (let [content (vec content) + indices (set indices) + starts (subpath-start-indices content) + + broken (into #{} (keep #(nth starts % nil)) indices) + + content + (into [] + (comp + (map-indexed + (fn [i cmd] + (cond + (contains? indices i) + (when-not (= :close-path (:command cmd)) + {:command :move-to + :params (select-keys (:params cmd) [:x :y])}) + + ;; Preserve the closing edge of broken subpaths. + (and (= :close-path (:command cmd)) + (contains? broken (nth starts i))) + {:command :line-to + :params (-> (nth content (nth starts i)) + (get :params) + (select-keys [:x :y]))} + + :else cmd))) + (remove nil?)) + content) + + subpaths + (reduce (fn [acc cmd] + (if (or (= :move-to (:command cmd)) (empty? acc)) + (conj acc [cmd]) + (update acc (dec (count acc)) conj cmd))) + [] + content)] + (impl/from-plain + (into [] (comp (filter #(> (count %) 1)) cat) subpaths)))) + ;; FIXME: rename to next-segment (defn next-node "Calculates the next-node to be inserted." @@ -543,78 +662,237 @@ :params (helpers/make-curve-params position prev-handler)} :else {:command :move-to :params position}))) -(defn remove-nodes - "Removes from content the points given. Will try to reconstruct the paths - to keep everything consistent" - [content points] +(def ^:private ^:const chain-samples-per-segment 8) +(defn- chain-samples + "Returns ordered samples along a segment chain." + [chain] + (into [(:start (first chain))] + (mapcat + (fn [{:keys [start end segment]}] + (let [ts (map #(/ (double %) chain-samples-per-segment) + (range 1 (inc chain-samples-per-segment)))] + (if (= :curve-to (:command segment)) + (let [curve (helpers/command->bezier segment start)] + (map #(helpers/curve-values curve %) ts)) + (map #(helpers/line-values [start end] %) ts))))) + chain)) + +(defn- chain-tangent + "Returns an inward unit tangent at one end of a chain." + [{:keys [start end segment]} at-start? origin samples] + (let [tangent + (if (= :curve-to (:command segment)) + (let [curve (helpers/command->bezier segment start)] + (cond-> (helpers/curve-tangent curve (if at-start? 0 1)) + (not at-start?) (gpt/negate))) + (if at-start? + (gpt/to-vec start end) + (gpt/to-vec end start))) + tangent (gpt/unit tangent)] + (if (gpt/almost-zero? tangent) + (->> samples + (map #(gpt/to-vec origin %)) + (remove gpt/almost-zero?) + (map gpt/unit) + (first)) + tangent))) + +(defn- flat-chain? + "True when a sampled chain is nearly straight." + [start end samples] + (or (mth/almost-zero? (gpt/distance start end)) + (every? #(< (gpt/point-line-distance % start end) 0.01) samples))) + +(defn- restore-split-curve + "Rejoins two untouched De Casteljau pieces into one cubic." + [chain] + (when (= 2 (count chain)) + (let [{left-segment :segment left-start :start} (first chain) + {right-segment :segment} (second chain)] + (when (and (= :curve-to (:command left-segment)) + (= :curve-to (:command right-segment))) + (let [[start split left-h1 left-h2 :as left-curve] + (helpers/command->bezier left-segment left-start) + [_ end right-h1 right-h2 :as right-curve] + (helpers/command->bezier right-segment split) + left-length (gpt/distance left-h2 split) + right-length (gpt/distance split right-h1)] + (when (and (not (mth/almost-zero? left-length)) + (not (mth/almost-zero? right-length))) + (let [t (/ left-length (+ left-length right-length)) + original-h1 (-> (gpt/to-vec start left-h1) + (gpt/scale (/ 1.0 t)) + (gpt/add start)) + original-h2 (-> (gpt/to-vec end right-h2) + (gpt/scale (/ 1.0 (- 1.0 t))) + (gpt/add end)) + candidate [start end original-h1 original-h2] + [left' right'] (helpers/curve-split candidate t)] + (when (every? true? + (map gpt/close? + (concat left-curve right-curve) + (concat left' right'))) + (helpers/make-curve-to end original-h1 original-h2))))))))) + +(defn- approximate-chain + "Replaces a segment chain with a line or fitted curve." + [chain] + (or (restore-split-curve chain) + (let [start (:start (first chain)) + end (:end (peek chain)) + samples (chain-samples chain) + tan1 (chain-tangent (first chain) true start (rest samples)) + tan2 (chain-tangent (peek chain) false end (rest (rseq samples)))] + (if (or (flat-chain? start end samples) + (nil? tan1) + (nil? tan2)) + (helpers/make-line-to end) + (let [[h1 h2] (fit/fit-cubic samples tan1 tan2)] + (helpers/make-curve-to end h1 h2)))))) + +(defn- split-content-subpaths + "Splits plain path commands into subpath command vectors." + [content] + (reduce + (fn [subpaths segment] + (if (= :move-to (:command segment)) + (conj subpaths [segment]) + (if (seq subpaths) + (update subpaths (dec (count subpaths)) conj segment) + subpaths))) + [] + content)) + +(defn- removed-point-joins-subpaths? + "True when a removed point is an endpoint shared by open subpaths." + [subpaths points] + (let [open-endpoints + (keep (fn [subpath] + (let [start (some-> subpath first helpers/segment->point) + end (some-> subpath peek helpers/segment->point)] + (when (and (some? start) + (some? end) + (not (subpath/pt= start end))) + #{start end}))) + subpaths)] + (some (fn [point] + (< 1 (count (filter (fn [endpoints] + (some #(subpath/pt= point %) endpoints)) + open-endpoints)))) + points))) + +(defn- rotate-removed-closed-start + "Rotates a closed subpath so a removed seam becomes an interior node." + [subpath points] + (let [subpath (vec subpath) + close? (= :close-path (:command (peek subpath))) + body (cond-> subpath close? pop) + start (some-> body first helpers/segment->point) + end (some-> body peek helpers/segment->point) + closed? (or close? (= start end))] + (if-not (and closed? (contains? points start)) + subpath + (let [segments (subvec body 1) + ;; Materialize an implicit close segment before rotating. + segments (cond-> segments + (and close? (not= start end)) + (conj (helpers/make-line-to start))) + new-start-index + (first + (keep-indexed + (fn [index segment] + (when-not (contains? points (helpers/segment->point segment)) + index)) + segments))] + (if (nil? new-start-index) + [] + (let [new-start (helpers/segment->point + (nth segments new-start-index)) + rotated (into [] + (concat + (subvec segments (inc new-start-index)) + (subvec segments 0 (inc new-start-index))))] + (cond-> (into [(helpers/make-move-to new-start)] rotated) + close? (conj {:command :close-path :params {}})))))))) + +(defn- remove-nodes* + "Removes interior nodes from prepared content." + [content points] + (loop [result [] + pending [] + subpath-start nil + prev-point nil + segments (seq content)] + + (if (nil? segments) + ;; Drop subpaths left with only a start point. + (into [] (comp (filter #(> (count %) 1)) cat) result) + + (let [segment (first segments) + move? (= :move-to (:command segment)) + close? (= :close-path (:command segment)) + point (if close? subpath-start (helpers/segment->point segment)) + remove? (and (not close?) (contains? points point)) + + ;; Start a result subpath for each move command. + result (if move? (conj result []) result) + head (dec (count result)) + subpath (peek result) + + [result pending] + (cond + ;; Collect removed interior nodes until the next kept node. + remove? + [result (if (seq subpath) + (conj pending {:start prev-point :end point :segment segment}) + [])] + + move? + [(update result head conj segment) []] + + ;; Promote the first kept node to the subpath start. + (empty? subpath) + [(update result head conj (helpers/make-move-to point)) []] + + (seq pending) + (if (and close? (contains? points subpath-start)) + ;; Close straight onto the new start. + [(update result head conj segment) []] + (let [chain (conj pending {:start prev-point :end point :segment segment}) + approx (approximate-chain chain) + ;; The close command already draws a zero-length replacement. + skip? (and close? + (= :line-to (:command approx)) + (< (gpt/distance (:start (first chain)) point) 0.01)) + result (cond-> result + (not skip?) (update head conj approx) + close? (update head conj segment))] + [result []])) + + :else + [(update result head conj segment) []])] + + (recur result + pending + (if move? point subpath-start) + point + (next segments)))))) + +(defn remove-nodes + "Removes nodes and joins surrounding segments with a fitted replacement." + [content points] (if (empty? points) content - - (let [content (d/with-prev content)] - - (loop [result [] - last-handler nil - [cur-segment prev-segment] (first content) - content (rest content)] - - (if (nil? cur-segment) - ;; The result with be an array of arrays were every entry is a subpath - (->> result - ;; remove empty and only 1 node subpaths - (filter #(> (count %) 1)) - ;; flatten array-of-arrays plain array - (flatten) - (into [])) - - (let [move? (= :move-to (:command cur-segment)) - curve? (= :curve-to (:command cur-segment)) - - ;; When the old command was a move we start a subpath - result (if move? (conj result []) result) - - subpath (peek result) - - point (helpers/segment->point cur-segment) - - old-prev-point (helpers/segment->point prev-segment) - new-prev-point (helpers/segment->point (peek subpath)) - - remove? (contains? points point) - - - ;; We store the first handler for the first curve to be removed to - ;; use it for the first handler of the regenerated path - cur-handler (cond - (and (not last-handler) remove? curve?) - (select-keys (:params cur-segment) [:c1x :c1y]) - - (not remove?) - nil - - :else - last-handler) - - cur-segment (cond-> cur-segment - ;; If we're starting a subpath and it's not a move make it a move - (and (not move?) (empty? subpath)) - (assoc :command :move-to - :params (select-keys (:params cur-segment) [:x :y])) - - ;; If have a curve the first handler will be relative to the previous - ;; point. We change the handler to the new previous point - (and curve? (seq subpath) (not= old-prev-point new-prev-point)) - (update :params merge last-handler)) - - head-idx (dec (count result)) - - result (cond-> result - (not remove?) - (update head-idx conj cur-segment))] - (recur result - cur-handler - (first content) - (rest content)))))))) + (let [subpaths (split-content-subpaths content) + content (if (removed-point-joins-subpaths? subpaths points) + (subpath/close-subpaths content) + content) + content (into [] + (mapcat #(rotate-removed-closed-start % points)) + (split-content-subpaths + content))] + (remove-nodes* content points)))) (defn join-nodes "Creates new segments between points that weren't previously. @@ -649,41 +927,119 @@ (into content new-content))) +(def ^:private separate-node-offset (gpt/point 8 8)) + +(defn- separate-node + "Splits a node into offset open ends, preserving adjacent handles." + [content point offset] + (let [content (vec content) + n (count content) + {ox :x oy :y} offset + seg? (fn [c] (and (some? c) + (not= :move-to (:command c)) + (not= :close-path (:command c))))] + (loop [i 0 + k 0 + result (transient [])] + (if (>= i n) + (persistent! result) + (let [cmd (nth content i) + nxt (nth content (inc i) nil) + at-p? (and (not= :close-path (:command cmd)) + (gpt/close? point (helpers/segment->point cmd)))] + (cond + ;; Offset a subpath start. + (and at-p? (= :move-to (:command cmd))) + (let [off (gpt/point (* k ox) (* k oy))] + (recur (inc i) (inc k) + (conj! result (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off)))))) + + ;; Split an interior node into two subpaths. + (and at-p? (seg? cmd) (seg? nxt)) + (let [off (gpt/point (* k ox) (* k oy)) + cmd' (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off)))) + k2 (inc k) + off2 (gpt/point (* k2 ox) (* k2 oy)) + mv (helpers/make-move-to (gpt/add point off2)) + nxt' (cond-> nxt + (= :curve-to (:command nxt)) + (-> (update-in [:params :c1x] + (:x off2)) + (update-in [:params :c1y] + (:y off2))))] + (recur (+ i 2) (inc k2) + (-> result (conj! cmd') (conj! mv) (conj! nxt')))) + + ;; Open and offset a closed seam. + (and at-p? (seg? cmd) (= :close-path (:command nxt))) + (let [off (gpt/point (* k ox) (* k oy)) + cmd' (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off))))] + ;; Drop the close command so the seam stays open. + (recur (+ i 2) (inc k) (conj! result cmd'))) + + ;; Offset the end of an open subpath. + (and at-p? (seg? cmd) (not= :close-path (:command nxt))) + (let [off (gpt/point (* k ox) (* k oy))] + (recur (inc i) (inc k) + (conj! result (cond-> (-> cmd + (update-in [:params :x] + (:x off)) + (update-in [:params :y] + (:y off))) + (= :curve-to (:command cmd)) + (-> (update-in [:params :c2x] + (:x off)) + (update-in [:params :c2y] + (:y off))))))) + + :else + (recur (inc i) k (conj! result cmd)))))))) + (defn separate-nodes - "Removes the segments between the points given" - [content points] + "Removes segments between points or splits one node into offset open ends." + ([content points] + (separate-nodes content points separate-node-offset)) + ([content points offset] + (if (= 1 (count points)) + (separate-node (vec content) (first points) offset) - (let [content (d/with-prev content)] - (loop [result [] - [cur-segment prev-segment] (first content) - content (rest content)] + (let [content (d/with-prev content)] + (loop [result [] + [cur-segment prev-segment] (first content) + content (rest content)] - (if (nil? cur-segment) - (->> result - (filter #(> (count %) 1)) - (flatten) - (into [])) + (if (nil? cur-segment) + (->> result + (filter #(> (count %) 1)) + (flatten) + (into [])) - (let [prev-point (helpers/segment->point prev-segment) - cur-point (helpers/segment->point cur-segment) + (let [prev-point (helpers/segment->point prev-segment) + cur-point (helpers/segment->point cur-segment) - cur-segment (cond-> cur-segment - (and (contains? points prev-point) - (contains? points cur-point)) + cur-segment (cond-> cur-segment + (and (contains? points prev-point) + (contains? points cur-point)) - (assoc :command :move-to - :params (select-keys (:params cur-segment) [:x :y]))) + (assoc :command :move-to + :params (select-keys (:params cur-segment) [:x :y]))) - move? (= :move-to (:command cur-segment)) + move? (= :move-to (:command cur-segment)) - result (if move? (conj result []) result) - head-idx (dec (count result)) + result (if move? (conj result []) result) + head-idx (dec (count result)) - result (-> result - (update head-idx conj cur-segment))] - (recur result - (first content) - (rest content))))))) + result (-> result + (update head-idx conj cur-segment))] + (recur result + (first content) + (rest content))))))))) (defn- add-to-set @@ -753,9 +1109,10 @@ (mapv replace-command)))) (defn merge-nodes - "Reduces the contiguous segments in points to a single point" + "Joins and merges `points` into one point." [content points] - (let [segments (get-segments-with-points content points)] + (let [content (join-nodes content points) + segments (get-segments-with-points content points)] (if (seq segments) (let [point->merge-point (-> segments (group-segments) @@ -889,3 +1246,16 @@ (conj result {:command :close-path}) result)] (impl/from-plain result)))))) + +(defn smooth-points->content + "Fits smooth path content through `points`, falling back to lines." + [points tolerance] + (let [curves (when (>= (count points) 3) + (fit/fit-curve points tolerance))] + (if (empty? curves) + (points->content points) + (impl/from-plain + (into [(helpers/make-move-to (ffirst curves))] + (map (fn [[_ end h1 h2]] + (helpers/make-curve-to end h1 h2))) + curves))))) diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc new file mode 100644 index 0000000000..826047431b --- /dev/null +++ b/common/src/app/common/types/path/selection.cljc @@ -0,0 +1,213 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.path.selection + "Transforms selected path nodes and handlers." + (:require + [app.common.data :as d] + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.types.path.helpers :as helpers] + [app.common.types.path.impl :as impl])) + +(def align-nodes-axis + "Valid alignment axes." + #{:hleft :hcenter :hright :vtop :vcenter :vbottom}) + +(def distribute-nodes-axis + "Valid distribution axes." + #{:horizontal :vertical}) + +(defn- selected-node-entries + "Returns selected nodes as `[index point]` pairs." + [content indices] + (into [] + (comp (filter (fn [[i seg]] + (and (contains? indices i) + (not= :close-path (:command seg))))) + (map (fn [[i seg]] [i (helpers/segment->point seg)]))) + (d/enumerate content))) + +(defn- expand-coincident-node-indices + "Includes every command that represents a selected logical node." + [content indices] + (let [indices (set indices) + points (into #{} (map second) (selected-node-entries content indices))] + (into indices + (comp + (filter (fn [[_ segment]] + (and (not= :close-path (:command segment)) + (some #(gpt/close? % (helpers/segment->point segment)) points)))) + (map first)) + (d/enumerate content)))) + +(defn- translate-nodes + "Moves selected nodes and handlers by their node deltas." + [content indices deltas] + (let [node-sel? (fn [i] (contains? indices i)) + add-delta (fn [params xk yk delta] + (if (and delta (contains? params xk)) + (-> params + (update xk + (:x delta)) + (update yk + (:y delta))) + params)) + move-cmd (fn [i {:keys [command params] :as seg}] + (let [curve? (= :curve-to command) + params (cond-> params + (and (not= :close-path command) (node-sel? i)) + (add-delta :x :y (get deltas i)) + + (and curve? (node-sel? (dec i))) + (add-delta :c1x :c1y (get deltas (dec i))) + + (and curve? (node-sel? i)) + (add-delta :c2x :c2y (get deltas i)))] + (assoc seg :params params)))] + (into [] (map-indexed move-cmd) content))) + +(defn flip-content + "Flips selected nodes and handlers across their bounds." + [content indices axis] + (let [content (vec content) + indices (set indices) + node-sel? (fn [i] (contains? indices i)) + positions (into [] + (comp (filter (fn [[i seg]] + (and (node-sel? i) + (not= :close-path (:command seg))))) + (map (fn [[_ seg]] (helpers/segment->point seg)))) + (d/enumerate content))] + (if (empty? positions) + (impl/from-plain content) + (let [xs (map :x positions) + ys (map :y positions) + cx (/ (+ (reduce min xs) (reduce max xs)) 2.0) + cy (/ (+ (reduce min ys) (reduce max ys)) 2.0) + flip-x? (= axis :horizontal) + reflect (fn [params xk yk] + (if flip-x? + (cond-> params + (contains? params xk) (update xk #(- (* 2.0 cx) %))) + (cond-> params + (contains? params yk) (update yk #(- (* 2.0 cy) %))))) + flip-cmd (fn [i {:keys [command params] :as seg}] + (let [curve? (= :curve-to command) + params (cond-> params + (and (not= :close-path command) (node-sel? i)) + (reflect :x :y) + + (and curve? (node-sel? (dec i))) + (reflect :c1x :c1y) + + (and curve? (node-sel? i)) + (reflect :c2x :c2y))] + (assoc seg :params params)))] + (impl/from-plain + (into [] (map-indexed flip-cmd) content)))))) + +(defn align-content + "Aligns two or more selected nodes within their bounds." + [content indices axis] + (let [content (vec content) + indices (set indices) + entries (selected-node-entries content indices)] + (if (< (count entries) 2) + (impl/from-plain content) + (let [pts (map second entries) + xs (map :x pts) + ys (map :y pts) + minx (reduce min xs) + maxx (reduce max xs) + miny (reduce min ys) + maxy (reduce max ys) + [coord target] (case axis + :hleft [:x minx] + :hcenter [:x (/ (+ minx maxx) 2.0)] + :hright [:x maxx] + :vtop [:y miny] + :vcenter [:y (/ (+ miny maxy) 2.0)] + :vbottom [:y maxy]) + deltas (into {} + (map (fn [[i p]] + [i (if (= coord :x) + (gpt/point (- target (:x p)) 0) + (gpt/point 0 (- target (:y p))))])) + entries)] + (impl/from-plain (translate-nodes content indices deltas)))))) + +(defn set-nodes-coordinate + "Sets one coordinate of selected nodes and handlers." + [content indices axis value] + (let [content (vec content) + indices (expand-coincident-node-indices content indices) + entries (selected-node-entries content indices) + deltas (into {} + (map (fn [[i p]] + [i (if (= axis :x) + (gpt/point (- value (:x p)) 0) + (gpt/point 0 (- value (:y p))))])) + entries)] + (impl/from-plain (translate-nodes content indices deltas)))) + +(defn set-handler-points + "Moves handlers to their target points." + [content pts] + (impl/from-plain + (reduce + (fn [content [[index prefix] pt]] + (if (= :curve-to (:command (get content index))) + (let [[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])] + (-> content + (assoc-in [index :params cx] (:x pt)) + (assoc-in [index :params cy] (:y pt)))) + content)) + (vec content) + pts))) + +(defn translate-selected-nodes + "Moves selected nodes and handlers by `delta`." + [content indices delta] + (let [content (vec content) + indices (expand-coincident-node-indices content indices)] + (impl/from-plain + (translate-nodes content indices (into {} (map (fn [i] [i delta])) indices))))) + +(defn distribute-content + "Distributes three or more selected positions along `axis`." + [content indices axis] + (let [content (vec content) + indices (set indices) + entries (selected-node-entries content indices) + index->point (into {} entries) + horizontal? (= axis :horizontal) + coord (fn [p] (if horizontal? (:x p) (:y p))) + groups (->> entries + (group-by (fn [[_ p]] [(mth/round (:x p) 0.1) (mth/round (:y p) 0.1)])) + (mapv (fn [[_ es]] + {:point (second (first es)) + :indices (mapv first es)}))) + sorted (sort-by (comp coord :point) groups)] + (if (< (count groups) 3) + (impl/from-plain content) + (let [lo (coord (:point (first sorted))) + hi (coord (:point (last sorted))) + step (/ (- hi lo) (dec (count sorted))) + deltas (into {} + (comp + (map-indexed + (fn [k {:keys [indices]}] + (let [target (+ lo (* k step))] + (map (fn [i] + (let [node-point (get index->point i) + d (- target (coord node-point)) + dp (if horizontal? + (gpt/point d 0) + (gpt/point 0 d))] + [i dp])) + indices)))) + cat) + sorted)] + (impl/from-plain (translate-nodes content indices deltas)))))) diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc index 3570311312..95e16664f2 100644 --- a/common/src/app/common/types/path/subpath.cljc +++ b/common/src/app/common/types/path/subpath.cljc @@ -28,13 +28,17 @@ (defn add-subpath-command "Adds a command to the subpath" [subpath command] - (let [command (if (= :close-path (:command command)) - (helpers/make-line-to (:from subpath)) - command) - p (helpers/segment->point command)] - (-> subpath - (assoc :to p) - (update :data conj command)))) + (let [close? (= :close-path (:command command))] + (if (and close? (pt= (:from subpath) (:to subpath))) + ;; Avoid adding a duplicate node at an already closed seam. + subpath + (let [command (if close? + (helpers/make-line-to (:from subpath)) + command) + p (helpers/segment->point command)] + (-> subpath + (assoc :to p) + (update :data conj command)))))) (defn reverse-command "Reverses a single command" @@ -189,6 +193,27 @@ (into [] xf-mapcat-data closed-subpaths))) +(defn- close-loop + "Adds an explicit close command when a subpath's endpoints meet." + [{:keys [from to data] :as subpath}] + (let [last-seg (peek data)] + (if (or (< (count data) 2) + (= :close-path (:command last-seg)) + (not (pt= from to))) + subpath + (let [data (cond-> data + (= :line-to (:command last-seg)) (pop))] + (assoc subpath + :to from + :data (conj data {:command :close-path :params {}})))))) + +(defn close-loops + "Adds close commands to subpaths whose endpoints meet." + [content] + (->> (get-subpaths content) + (mapv close-loop) + (into [] xf-mapcat-data))) + ;; FIXME: revisit this fn impl for perfromance (defn reverse-content "Given a content reverse the order of the commands" diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index d6b48c6ac7..6a617c21cc 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -624,8 +624,9 @@ {:type :path :name "Path" :fills [] + ;; Paths use centered strokes by default. :strokes [{:stroke-style :solid - :stroke-alignment :inner + :stroke-alignment :center :stroke-width 1 :stroke-color clr/black :stroke-opacity 1}]}) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 6205e22a14..70285f21ad 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -17,6 +17,7 @@ [app.common.transit :as trans] [app.common.types.path :as path] [app.common.types.path.bool :as path.bool] + [app.common.types.path.fit :as path.fit] [app.common.types.path.helpers :as path.helpers] [app.common.types.path.impl :as path.impl] [app.common.types.path.segment :as path.segment] @@ -656,6 +657,21 @@ (t/testing "content that is already a closed triangle stays closed" (let [result (path.subpath/close-subpaths simple-closed-content)] (t/is (seq result)))) + (t/testing "a close after a curve already landing on the start is not materialized twice" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to + :params {:c1x 3.0 :c1y -2.0 + :c2x 6.0 :c2y -2.0 + :x 10.0 :y 0.0}} + {:command :curve-to + :params {:c1x 6.0 :c1y 2.0 + :c2x 3.0 :c2y 2.0 + :x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.subpath/close-subpaths content)] + (t/is (= [:move-to :curve-to :curve-to] (mapv :command result))) + ;; Rendering/persistence can still recover the explicit SVG close. + (t/is (= content (path.subpath/close-loops content))))) (t/testing "two open fragments that form a closed loop get merged" ;; fragment A: 0,0 → 5,0 ;; fragment B: 10,0 → 5,0 (reversed, connects to A's end) @@ -667,6 +683,65 @@ result (path.subpath/close-subpaths content)] (t/is (seq result))))) +(t/deftest subpath-close-loops + (t/testing "trailing line-to landing on the subpath start becomes a close-path" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :line-to :close-path] (mapv :command result))))) + + (t/testing "coincident endpoints within tolerance also close" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.05 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :close-path] (mapv :command result))))) + + (t/testing "curve landing on the subpath start keeps the curve and appends a close-path" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 10.0 :c1y 5.0 :c2x 5.0 :c2y 5.0 :x 0.0 :y 0.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))))) + + (t/testing "already command-closed content is unchanged" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (path.subpath/close-loops content)] + (t/is (= content (vec result))))) + + (t/testing "open subpaths are left untouched" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}] + result (path.subpath/close-loops content)] + (t/is (= content (vec result))))) + + (t/testing "multi-subpath content closes only the coincident loops" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 20.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 20.0}}] + result (path.subpath/close-loops content)] + (t/is (= [:move-to :line-to :close-path :move-to :line-to] + (mapv :command result)))))) + +(t/deftest path-close-loops-path-data + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]) + result (path/close-loops content)] + (t/is (path.impl/path-data? result)) + (t/is (= [:move-to :line-to :line-to :close-path] + (mapv :command (vec result)))))) + (t/deftest subpath-merge-touching-subpaths (t/testing "adjacent subpaths sharing an endpoint collapse into one chain" ;; Heroicons-style fragment: continuous polyline split as M-L M-L M-L @@ -785,6 +860,70 @@ (t/is (= 3.0 (get-in cmd [:params :c1x]))) (t/is (= 7.0 (get-in cmd [:params :c2x]))))) +(t/deftest segment-make-curve-point-keeps-neighbors-corners + ;; Curving a node leaves its neighbours as corners. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 100.0 :y 0.0}} + {:command :line-to :params {:x 200.0 :y 0.0}}]] + + (t/testing "curving the first (endpoint) node keeps its neighbour a corner" + (let [r (vec (seq (path/make-curve-point content (gpt/point 0.0 0.0)))) + seg1 (get r 1)] + (t/is (= :curve-to (:command seg1))) + ;; The neighbour's handle stays collapsed. + (t/is (= 100.0 (get-in seg1 [:params :c2x]))) + (t/is (= 0.0 (get-in seg1 [:params :c2y]))) + ;; The selected node gets a handle. + (t/is (not= 0.0 (get-in seg1 [:params :c1x]))))) + + (t/testing "curving the last node keeps its neighbour a corner" + (let [r (vec (seq (path/make-curve-point content (gpt/point 200.0 0.0)))) + seg2 (get r 2)] + (t/is (= :curve-to (:command seg2))) + ;; The neighbour's handle stays collapsed. + (t/is (= 100.0 (get-in seg2 [:params :c1x]))) + (t/is (= 0.0 (get-in seg2 [:params :c1y]))))) + + (t/testing "curving a middle node keeps both neighbours corners" + (let [r (vec (seq (path/make-curve-point content (gpt/point 100.0 0.0)))) + seg1 (get r 1) + seg2 (get r 2)] + (t/is (= 0.0 (get-in seg1 [:params :c1x]))) + (t/is (= 200.0 (get-in seg2 [:params :c2x]))))))) + +(t/deftest segment-make-curve-point-acute-corner-is-smooth + ;; Acute corners get equal and opposite handles. + (let [content [{:command :move-to :params {:x 10.0 :y 1.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y -1.0}}] + r (vec (seq (path/make-curve-point (path/content content) + (gpt/point 0.0 0.0)))) + ;; Read the node's incoming and outgoing handles. + c2y (get-in (get r 1) [:params :c2y]) + c1y (get-in (get r 2) [:params :c1y])] + ;; Both handles extend from the node. + (t/is (not (zero? c2y))) + ;; The node is the midpoint between equal-length handles. + (t/is (= c2y (- c1y))))) + +(t/deftest segment-make-curve-point-closed-seam-follows-neighbour-tangent + ;; Closed seams follow the chord between their neighbours. + (let [point (gpt/point 0.0 0.0) + content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y -8.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 6.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (vec (path/make-curve-point (path/content content) point)) + outgoing (get result 1) + incoming (get result 4)] + ;; Seam handles lie on the chord and oppose each other. + (t/is (mth/close? 0.0 (get-in outgoing [:params :c1x]) 0.001)) + (t/is (mth/close? 0.0 (get-in incoming [:params :c2x]) 0.001)) + (t/is (neg? (get-in outgoing [:params :c1y]))) + (t/is (pos? (get-in incoming [:params :c2y]))))) + (t/deftest helpers-prefix->coords (t/is (= [:c1x :c1y] (path.helpers/prefix->coords :c1))) (t/is (= [:c2x :c2y] (path.helpers/prefix->coords :c2))) @@ -794,15 +933,22 @@ (t/testing "returns point unchanged when from-point is nil" (let [pt (gpt/point 5.0 3.0)] (t/is (= pt (path.helpers/position-fixed-angle pt nil))))) - (t/testing "snaps to nearest 45-degree angle" - (let [from (gpt/point 0 0) - ;; Angle ~30° from from, should snap to 45° - to (gpt/point 10 6) - snapped (path.helpers/position-fixed-angle to from)] - ;; result should have same distance - (let [d-orig (gpt/distance to from) - d-snapped (gpt/distance snapped from)] - (t/is (mth/close? d-orig d-snapped 0.01)))))) + (t/testing "snaps to nearest 15-degree angle" + (let [from (gpt/point 0 0) + ;; ~31° from `from`: snaps to 30° (15° granularity), not 45° + to (gpt/point 10 6) + snapped (path.helpers/position-fixed-angle to from) + d-orig (gpt/distance to from) + d-snapped (gpt/distance snapped from) + snap-ang (gpt/angle snapped from) + orig-ang (gpt/angle to from) + delta (let [d (mod (- snap-ang orig-ang) 360)] (min d (- 360 d)))] + ;; distance preserved + (t/is (mth/close? d-orig d-snapped 0.01)) + ;; snapped onto a 15° multiple + (t/is (let [m (mod snap-ang 15)] (or (< m 0.01) (> m 14.99)))) + ;; Stay within half a 15° bucket of the input angle. + (t/is (<= delta 7.5))))) (t/deftest helpers-command->line (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} @@ -821,6 +967,18 @@ (t/is (= (gpt/point 3.0 5.0) h1)) (t/is (= (gpt/point 7.0 5.0) h2)))) +(t/deftest helpers-entry->bezier + (let [from (gpt/point 0 0) + to (gpt/point 10 0) + line {:from from :to to :segment {:command :line-to}} + curve {:from from + :to to + :segment {:command :curve-to + :params {:x 10 :y 0 :c1x 3 :c1y 5 :c2x 7 :c2y 5}}}] + (t/is (= [from to from to] (path.helpers/entry->bezier line))) + (t/is (= [from to (gpt/point 3 5) (gpt/point 7 5)] + (path.helpers/entry->bezier curve))))) + (t/deftest helpers-line-values (let [from (gpt/point 0.0 0.0) to (gpt/point 10.0 0.0) @@ -1102,6 +1260,637 @@ ;; should have fewer segments (t/is (< (count result) (count simple-open-content))))) +(t/deftest helpers-fit-cubic-recovers-curve + ;; fitting samples of a known cubic recovers control points close to it + (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0) + (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)] + samples (mapv #(path.helpers/curve-values curve (/ % 20.0)) (range 21)) + tan1 (path.helpers/curve-tangent curve 0) + tan2 (gpt/negate (path.helpers/curve-tangent curve 1)) + [h1 h2] (path.fit/fit-cubic samples tan1 tan2)] + (t/is (mth/close? 10.0 (:x h1) 1.0)) + (t/is (mth/close? 10.0 (:y h1) 1.0)) + (t/is (mth/close? 20.0 (:x h2) 1.0)) + (t/is (mth/close? 10.0 (:y h2) 1.0)))) + +(t/deftest helpers-curve-closest-t + ;; A degenerate cubic maps points back onto the same line. + (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (t/is (mth/close? 0.5 (path.helpers/curve-closest-t curve (gpt/point 5.0 0.0) 0.001) 0.01)) + (doseq [q [(gpt/point 2.5 0.0) (gpt/point 7.0 0.0)]] + (let [t (path.helpers/curve-closest-t curve q 0.001) + p (path.helpers/curve-values curve t)] + (t/is (mth/close? (:x q) (:x p) 0.05)))))) + +(t/deftest helpers-bend-curve-deltas-passes-through-target + ;; the handle deltas move the point at t exactly onto the target, for any t + (let [curve [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (doseq [t [0.3 0.5 0.7] + target [(gpt/point 5.0 4.0) (gpt/point 3.0 -6.0)]] + (let [{:keys [c1x c1y c2x c2y]} (path.helpers/bend-curve-deltas curve t target) + bent [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point c1x c1y) (gpt/point (+ 10.0 c2x) c2y)] + p (path.helpers/curve-values bent t)] + (t/is (mth/close? (:x target) (:x p) 0.001)) + (t/is (mth/close? (:y target) (:y p) 0.001)))))) + +(t/deftest segment-flip-content-horizontal + ;; mirror every node across the bbox center on the vertical axis + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (path/flip-content content #{0 1 2} :horizontal) + pts (mapv (comp (juxt :x :y) :params) (vec result))] + (t/is (= [[10.0 0.0] [0.0 0.0] [0.0 10.0]] pts)))) + +(t/deftest segment-flip-content-curve-handles + ;; a curve mirrors its anchors and both handles, keeping shape symmetry + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}]) + result (vec (path/flip-content content #{0 1} :horizontal))] + (t/is (= {:x 10.0 :y 0.0} (:params (first result)))) + (t/is (= {:c1x 8.0 :c1y 5.0 :c2x 2.0 :c2y 5.0 :x 0.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-flip-content-vertical + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 5.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 0.0}}]) + result (vec (path/flip-content content #{0 1} :vertical))] + (t/is (= {:c1x 2.0 :c1y -5.0 :c2x 8.0 :c2y -5.0 :x 10.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-separate-single-node + ;; Separating an interior node creates two open ends. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)}))] + ;; move-to, line-to (to node1 kept at 10,0), move-to (node2 offset), line-to + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1)))) + (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2)))) + (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-custom-offset + ;; The supplied offset controls the gap between split ends. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)} (gpt/point 2.0 2.0)))] + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (nth result 1)))) + (t/is (= {:x 12.0 :y 2.0} (:params (nth result 2)))) + (t/is (= {:x 20.0 :y 0.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-closed-seam + ;; Separating a closed seam creates two endpoints. + (let [point (gpt/point 0.0 0.0) + content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to + :params {:c1x 2.0 :c1y -2.0 + :c2x 8.0 :c2y -2.0 + :x 10.0 :y 0.0}} + {:command :curve-to + :params {:c1x 8.0 :c1y 2.0 + :c2x 2.0 :c2y 2.0 + :x 0.0 :y 0.0}} + {:command :close-path :params {}}]) + result (vec (path/separate-nodes content #{point} (gpt/point 2.0 2.0)))] + (t/is (= [:move-to :curve-to :curve-to] (mapv :command result))) + (t/is (= {:x 0.0 :y 0.0} + (select-keys (:params (first result)) [:x :y]))) + (t/is (= {:x 2.0 :y 2.0} + (select-keys (:params (peek result)) [:x :y]))) + ;; The incoming c2 stays attached to the shifted endpoint. + (t/is (= {:c2x 4.0 :c2y 4.0} + (select-keys (:params (peek result)) [:c2x :c2y]))))) + +(t/deftest segment-separate-single-node-endpoint-noop + ;; an endpoint node has no following segment, so nothing is split + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 20.0 0.0)}))] + (t/is (= [:move-to :line-to :line-to] (mapv :command result))))) + +(t/deftest segment-separate-single-node-curve-carries-handler + ;; the outgoing curve's leading handler is shifted with the new start + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 10.0 0.0)})) + curve (nth result 3)] + (t/is (= [:move-to :line-to :move-to :curve-to] (mapv :command result))) + (t/is (= {:x 18.0 :y 8.0} (:params (nth result 2)))) + ;; c1 shifted by the same (8,8) offset, c2/end untouched + (t/is (= 20.0 (get-in curve [:params :c1x]))) + (t/is (= 8.0 (get-in curve [:params :c1y]))) + (t/is (= 18.0 (get-in curve [:params :c2x]))) + (t/is (= 20.0 (get-in curve [:params :x]))))) + +(t/deftest segment-separate-single-node-junction + ;; Separating coincident subpaths creates one open end per line. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))] + (t/is (= [:move-to :line-to :move-to :line-to] (mapv :command result))) + ;; the first line keeps (5,5); the second subpath's start is offset by (8,8) + (t/is (= {:x 5.0 :y 5.0} (:params (nth result 1)))) + (t/is (= {:x 13.0 :y 13.0} (:params (nth result 2)))) + (t/is (= {:x 10.0 :y 10.0} (:params (nth result 3)))))) + +(t/deftest segment-separate-single-node-junction-three-lines + ;; three lines meeting at a point separate into three distinct offset ends + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (vec (path/separate-nodes content #{(gpt/point 5.0 5.0)}))] + (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} + {:x 20.0 :y 0.0} {:x 13.0 :y 13.0} + {:x 21.0 :y 21.0} {:x 10.0 :y 10.0}] + (mapv #(select-keys (:params %) [:x :y]) result))))) + +(t/deftest segment-flip-content-partial-selection + ;; only the selected nodes and their handles move; others stay put + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + result (path/flip-content content #{0 1} :horizontal) + pts (mapv (comp (juxt :x :y) :params) (vec result))] + (t/is (= [[10.0 0.0] [0.0 0.0] [10.0 10.0]] pts)))) + +(t/deftest segment-align-content + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 2.0}} + {:command :line-to :params {:x 4.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; align to the left edge: every selected x becomes the min x + (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]] + (pts (path/align-content content #{0 1 2} :hleft)))) + ;; align to horizontal center: x becomes the bbox center + (t/is (= [[5.0 0.0] [5.0 2.0] [5.0 20.0]] + (pts (path/align-content content #{0 1 2} :hcenter)))) + ;; align to the top edge: every selected y becomes the min y + (t/is (= [[0.0 0.0] [10.0 0.0] [4.0 0.0]] + (pts (path/align-content content #{0 1 2} :vtop)))))) + +(t/deftest segment-align-content-partial-and-guard + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 2.0}} + {:command :line-to :params {:x 4.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; only the selected nodes align; the unselected node stays put + (t/is (= [[0.0 0.0] [0.0 2.0] [4.0 20.0]] + (pts (path/align-content content #{0 1} :hleft)))) + ;; fewer than two selected nodes is a no-op + (t/is (= (pts content) + (pts (path/align-content content #{0} :hleft)))))) + +(t/deftest segment-align-content-moves-handles + ;; a selected node's attached handles move rigidly with its anchor + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0}}]) + result (vec (path/align-content content #{0 1} :vtop))] + ;; both nodes already share y=0, so vtop is a no-op on the anchors and + ;; leaves the handles untouched + (t/is (= {:c1x 2.0 :c1y 1.0 :c2x 8.0 :c2y 1.0 :x 10.0 :y 0.0} + (:params (second result)))))) + +(t/deftest segment-set-nodes-coordinate + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}])] + ;; setting x for two nodes moves each to that x (per-node delta), the + ;; unselected node is untouched + (t/is (= [[:move-to {:x 5.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:line-to {:x 5.0 :y 0.0}]] + (mapv (juxt :command :params) + (vec (path/set-nodes-coordinate content #{0 2} :x 5.0))))) + ;; a single node's y moves only that node, and its attached handle moves + ;; rigidly with the anchor + (let [curved (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 0.0 :x 10.0 :y 0.0}}]) + r (vec (path/set-nodes-coordinate curved #{1} :y 5.0))] + ;; node 1 anchor y 0 -> 5 (delta +5); its :c2 handle (owned by node 1) + ;; moves +5 too; :c1 (owned by node 0, unselected) stays + (t/is (= {:c1x 2.0 :c1y 0.0 :c2x 8.0 :c2y 5.0 :x 10.0 :y 5.0} + (:params (second r))))))) + +(t/deftest segment-set-nodes-coordinate-keeps-coincident-nodes-together + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]) + result (vec (path/set-nodes-coordinate content #{0} :y 5.0))] + ;; The first and last commands are the same logical closed-seam node. + (t/is (= (gpt/point 0.0 5.0) + (path.helpers/segment->point (nth result 0)))) + (t/is (= (gpt/point 0.0 5.0) + (path.helpers/segment->point (nth result 2)))) + (t/is (= (gpt/point 10.0 0.0) + (path.helpers/segment->point (nth result 1)))))) + +(t/deftest segment-align-content-coincident-nodes + (t/testing "align-content groups coincident nodes with sub-epsilon coordinate differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 2.0}} + {:command :line-to :params {:x 10.0 :y 20.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; Nodes at ~10.0 should be grouped together for alignment + (t/is (= [[0.0 0.0] [0.0 2.0] [0.0 20.0]] + (pts (path/align-content content #{0 1 2} :hleft))))))) + +(t/deftest segment-flip-content-coincident-nodes + (t/testing "flip-content handles coincident nodes with sub-epsilon coordinate differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c))) + result (path/flip-content content #{0 1 2} :horizontal)] + ;; All nodes should flip across the horizontal center. + ;; Floating-point imprecision from the 10.0001 input is expected. + (let [[[x0 y0] [x1 y1] [x2 y2]] (pts result)] + (t/is (mth/close? 10.0 x0 0.001)) + (t/is (mth/close? 0.0 y0 0.001)) + (t/is (mth/close? 0.0 x1 0.001)) + (t/is (mth/close? 0.0 y1 0.001)) + (t/is (mth/close? 0.0 x2 0.001)) + (t/is (mth/close? 10.0 y2 0.001)))))) + +(t/deftest segment-set-handler-points + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 2.0 :x 10.0 :y 0.0}}]) + r (vec (path/set-handler-points content {[1 :c2] (gpt/point 7.0 6.0)}))] + ;; c2 set to the target point; c1 and the anchor stay put + (t/is (= {:c1x 2.0 :c1y 2.0 :c2x 7.0 :c2y 6.0 :x 10.0 :y 0.0} + (:params (second r)))))) + +(t/deftest segment-translate-selected-nodes + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + ;; translate nodes 1 and 2 by (0, 5): both move down, node 0 stays + r (vec (path/translate-selected-nodes content #{1 2} (gpt/point 0.0 5.0)))] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 5.0}] + [:line-to {:x 20.0 :y 5.0}]] + (mapv (juxt :command :params) r))))) + +(t/deftest segment-distribute-content + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 9.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; the middle node is spaced evenly between the two extremes on x + (t/is (= [[0.0 0.0] [5.0 5.0] [10.0 9.0]] + (pts (path/distribute-content content #{0 1 2} :horizontal)))) + ;; fewer than three selected nodes is a no-op + (t/is (= (pts content) + (pts (path/distribute-content content #{0 1} :horizontal)))))) + +(t/deftest segment-distribute-content-keeps-coincident-nodes-together + ;; Coincident selected nodes move as one group. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0 :y 7.0}} + {:command :line-to :params {:x 3.0 :y 7.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; three distinct positions (0, 3, 10); the coincident pair is one group + ;; centred at x=5 and both nodes move there together, staying coincident + (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]] + (pts (path/distribute-content content #{0 1 2 3} :horizontal)))) + ;; only two distinct positions among the selection is a no-op + (t/is (= (pts content) + (pts (path/distribute-content content #{1 2 3} :horizontal)))))) + +(t/deftest segment-distribute-content-with-floating-point-coordinates + (t/testing "distribute-content groups nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0001 :y 7.0}} + {:command :line-to :params {:x 3.0002 :y 7.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; Nodes at ~3.0 should be grouped together + (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]] + (pts (path/distribute-content content #{0 1 2 3} :horizontal))))))) + +(t/deftest helpers-curve-arc-length-t + (let [arc-len (fn [curve a b] + (->> (range 1001) + (map #(path.helpers/curve-values + curve (+ a (* (/ (double %) 1000) (- b a))))) + (partition 2 1) + (map (fn [[p q]] (gpt/distance p q))) + (reduce +)))] + ;; a straight line (degenerate cubic) has its visual middle at t=0.5 + (let [line [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]] + (t/is (mth/close? 0.5 (path.helpers/curve-arc-length-t line) 0.01))) + ;; An uneven curve's arc midpoint differs from its parametric midpoint. + (let [curve [(gpt/point 0.0 0.0) (gpt/point 100.0 100.0) + (gpt/point 0.0 0.0) (gpt/point 0.0 100.0)] + t (path.helpers/curve-arc-length-t curve) + total (arc-len curve 0.0 1.0) + first-half (arc-len curve 0.0 t)] + (t/is (< 0.5 t 1.0)) + ;; the length up to t is within 1% of half the total + (t/is (< (mth/abs (- first-half (/ total 2.0))) (* 0.01 total)))))) + +(t/deftest helpers-fit-curve-single-curve + ;; samples of one gentle cubic are fitted back with a single curve + (let [curve [(gpt/point 0.0 0.0) (gpt/point 30.0 0.0) + (gpt/point 10.0 10.0) (gpt/point 20.0 10.0)] + samples (mapv #(path.helpers/curve-values curve (/ % 24.0)) (range 25)) + result (path.fit/fit-curve samples 0.5)] + (t/is (= 1 (count result))) + (let [[start end h1 h2] (first result)] + (t/is (= (gpt/point 0.0 0.0) start)) + (t/is (= (gpt/point 30.0 0.0) end)) + (t/is (mth/close? 10.0 (:x h1) 1.5)) + (t/is (mth/close? 10.0 (:y h1) 1.5)) + (t/is (mth/close? 20.0 (:x h2) 1.5)) + (t/is (mth/close? 10.0 (:y h2) 1.5))))) + +(t/deftest helpers-fit-curve-splits-and-chains + ;; Sharp corners split the fit into chained curves. + (let [pts (into [] + (concat + (map #(gpt/point (double %) (double %)) (range 0 11)) + (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11)))) + result (path.fit/fit-curve pts 0.1)] + (t/is (> (count result) 1)) + (t/is (every? (fn [[c1 c2]] (= (nth c1 1) (nth c2 0))) + (map vector result (rest result)))) + (t/is (= (gpt/point 0.0 0.0) (get-in result [0 0]))) + (t/is (= (gpt/point 20.0 0.0) (nth (peek result) 1))))) + +(t/deftest helpers-fit-curve-respects-tolerance + ;; every input point stays within tolerance of the fitted sequence + (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0)))) + (range 0 31)) + tol 0.5 + result (path.fit/fit-curve pts tol) + curve-pts (into [] + (mapcat (fn [c] + (map #(path.helpers/curve-values c (/ % 100.0)) + (range 101)))) + result) + max-dev (reduce max + (map (fn [p] + (reduce min (map #(gpt/distance p %) curve-pts))) + pts))] + (t/is (<= max-dev (+ tol 0.05))))) + +(t/deftest helpers-fit-curve-keeps-sharp-corners + ;; Sharp-corner handles follow their own legs. + (let [corner (gpt/point 10.0 10.0) + ;; two legs meeting at a 90 degree corner: (0,0)->(10,10)->(20,0) + pts (into [] + (concat + (map #(gpt/point (double %) (double %)) (range 0 11)) + (map #(gpt/point (+ 10.0 %) (- 10.0 %)) (range 1 11)))) + result (path.fit/fit-curve pts 0.1) + ;; the two curves meeting at the corner + left (first (filter #(= corner (nth % 1)) result)) + right (first (filter #(= corner (nth % 0)) result)) + v-in (gpt/to-vec corner (nth left 3)) ;; incoming handle (h2) direction + v-out (gpt/to-vec corner (nth right 2)) ;; outgoing handle (h1) direction + angle (gpt/angle-with-other v-in v-out)] + (t/is (some? left)) + (t/is (some? right)) + ;; The join keeps the corner's angle. + (t/is (< angle 135.0)) + (t/is (mth/close? 90.0 angle 15.0)))) + +(t/deftest segment-smooth-points->content + (t/testing "two points produce a straight segment" + (let [content (path.segment/smooth-points->content + [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)] 1.0)] + (t/is (= [:move-to :line-to] (mapv :command content))))) + (t/testing "freehand-like points produce fewer, fitted curve segments" + (let [pts (mapv #(gpt/point (double %) (* 5.0 (mth/sin (/ % 3.0)))) + (range 0 31)) + content (path.segment/smooth-points->content pts 1.0) + cmds (mapv :command content)] + (t/is (= :move-to (first cmds))) + (t/is (every? #(= :curve-to %) (rest cmds))) + (t/is (< (count cmds) (count pts))) + (t/is (= {:x 0.0 :y 0.0} (:params (first (vec content))))) + (let [last-params (:params (peek (vec content)))] + (t/is (mth/close? 30.0 (:x last-params))) + (t/is (mth/close? (* 5.0 (mth/sin 10.0)) (:y last-params))))))) + +(t/deftest segment-remove-nodes-collinear-keeps-line + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 0.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 20.0 :y 0.0} (:params (second result)))))) + +(t/deftest segment-remove-nodes-corner-fits-curve + ;; Removing a slanted corner keeps both endpoint tangents. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + (t/is (mth/close? 20.0 x)) + (t/is (mth/close? 0.0 y)) + ;; handlers stay on the removed segments' directions (45 degrees) + (t/is (mth/close? c1x c1y 0.01)) + (t/is (mth/close? (- 20.0 c2x) c2y 0.01)) + ;; the curve bulges towards the removed corner + (t/is (< 2.0 (:y mid) 10.0)) + (t/is (mth/close? 10.0 (:x mid) 0.5))))) + +(t/deftest segment-remove-nodes-between-curves-approximates + ;; Joined quarter arcs collapse into a fitted semicircle. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 0.0 :c1y 5.52 :c2x 4.48 :c2y 10.0 :x 10.0 :y 10.0}} + {:command :curve-to :params {:c1x 15.52 :c1y 10.0 :c2x 20.0 :c2y 5.52 :x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + ;; The fitted curve keeps the semicircle apex. + (t/is (mth/close? 10.0 (:x mid) 0.5)) + (t/is (mth/close? 10.0 (:y mid) 0.5))))) + +(t/deftest segment-remove-node-restores-a-split-curve + ;; Removing an untouched split node rejoins the cubic exactly. + (let [from (gpt/point 0.0 0.0) + original {:command :curve-to + :params {:c1x 0.0 :c1y 0.0 + :c2x 0.0 :c2y 100.0 + :x 100.0 :y 100.0}} + content (path/content [(path.helpers/make-move-to from) original]) + curve (path.helpers/command->bezier original from) + t-val (path.helpers/curve-arc-length-t curve) + split (-> (path.segment/split-segments content #{from (gpt/point 100.0 100.0)} t-val) + (path/content)) + inserted (path.helpers/segment->point (nth split 1)) + result (vec (path.segment/remove-nodes split #{inserted})) + healed (second result)] + ;; Split at the asymmetric curve's arc midpoint. + (t/is (not (mth/close? 0.5 t-val 0.01))) + (t/is (= [:move-to :curve-to] (mapv :command result))) + (doseq [coord [:c1x :c1y :c2x :c2y :x :y]] + (t/is (mth/close? (get-in original [:params coord]) + (get-in healed [:params coord])))))) + +(t/deftest segment-remove-nodes-multiple-consecutive + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 10.0 :y 7.0}} + {:command :line-to :params {:x 15.0 :y 5.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 5.0 5.0) + (gpt/point 10.0 7.0) + (gpt/point 15.0 5.0)}) + curve (second result)] + (t/is (= [:move-to :curve-to] (mapv :command result))) + (let [{:keys [c1x c1y c2x c2y x y]} (:params curve) + mid (path.helpers/curve-values (gpt/point 0.0 0.0) (gpt/point x y) + (gpt/point c1x c1y) (gpt/point c2x c2y) 0.5)] + (t/is (mth/close? 10.0 (:x mid) 1.0)) + (t/is (< 4.0 (:y mid) 8.5))))) + +(t/deftest segment-remove-nodes-endpoints-drop-segments + (let [content (path/content simple-open-content)] + (t/testing "removing the first node drops the leading segment" + (let [result (path.segment/remove-nodes content #{(gpt/point 0.0 0.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (first result)))))) + (t/testing "removing the last node drops the trailing segment" + (let [result (path.segment/remove-nodes content #{(gpt/point 10.0 10.0)})] + (t/is (= [:move-to :line-to] (mapv :command result))) + (t/is (= {:x 10.0 :y 0.0} (:params (second result)))))))) + +(t/deftest segment-remove-nodes-closed-path-keeps-closure + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)})] + (t/is (= [:move-to :curve-to :line-to :close-path] (mapv :command result))))) + +(t/deftest segment-remove-nodes-heals-a-closed-seam + (let [line-closed [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}] + command-closed (conj line-closed {:command :close-path :params {}}) + seam (gpt/point 0.0 0.0)] + (doseq [content [line-closed command-closed]] + (let [result (vec (path.segment/remove-nodes (path/content content) #{seam})) + commands (mapv :command result) + points (mapv path.helpers/segment->point + (remove #(= :close-path (:command %)) result))] + ;; Both non-seam sides survive and are joined through one fitted segment. + (t/is (= [:move-to :line-to :line-to :curve-to] + (cond-> commands + (= :close-path (peek commands)) pop))) + (t/is (= [(gpt/point 10.0 0.0) + (gpt/point 10.0 10.0) + (gpt/point 0.0 10.0) + (gpt/point 10.0 0.0)] + points)))))) + +(t/deftest segment-remove-nodes-heals-a-touching-subpath-seam + ;; During path edition, duplicated and merged halves can still be stored as + ;; two open subpaths whose endpoints touch. Finalizing the path joins them, + ;; but deleting their shared node must behave the same before finalization. + (let [content + (path/content + [{:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x -10.0 :y 7.0}} + {:command :line-to :params {:x -10.0 :y 3.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 3.0}} + {:command :line-to :params {:x 10.0 :y 7.0}} + {:command :line-to :params {:x 0.0 :y 10.0}}]) + result (vec (path.segment/remove-nodes content #{(gpt/point 0.0 10.0)}))] + (t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to] + (mapv :command result))) + (t/is (= (gpt/point -10.0 7.0) + (path.helpers/segment->point (first result)))) + (t/is (= (gpt/point -10.0 7.0) + (path.helpers/segment->point (peek result)))))) + +(t/deftest segment-remove-nodes-chain-ending-on-close-path + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 10.0 10.0)})] + ;; the geometry back to the start is approximated and the path stays closed + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))) + (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :x]))) + (t/is (mth/close? 0.0 (get-in (vec result) [2 :params :y]))))) + +(t/deftest segment-remove-nodes-heals-removed-close-target + ;; Removing the closed seam preserves both adjacent sides and fits their + ;; replacement across the former start point. + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}] + result (path.segment/remove-nodes (path/content content) + #{(gpt/point 0.0 0.0)})] + (t/is (= [:move-to :line-to :curve-to :close-path] (mapv :command result))) + (t/is (= (gpt/point 10.0 10.0) + (path.helpers/segment->point (first result)))) + (t/is (= (gpt/point 10.0 10.0) + (path.helpers/segment->point (nth result 2)))))) + (t/deftest segment-join-nodes (let [content (path/content simple-open-content) pt1 (gpt/point 0.0 0.0) @@ -1117,6 +1906,18 @@ ;; separate-nodes should return a collection (vector or seq) (t/is (coll? result)))) +(t/deftest segment-separate-nodes-with-floating-point-coordinates + (t/testing "separate-nodes finds nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + pt (gpt/point 10.0002 0.0) + result (path.segment/separate-nodes content #{pt})] + ;; Should still find and separate the node + (t/is (coll? result)) + (t/is (> (count result) (count content)))))) + (t/deftest segment-make-corner-point (let [content (path/content sample-content-2) ;; Take a curve point and make it a corner @@ -1290,6 +2091,118 @@ (let [result (path/merge-nodes nil #{(gpt/point 0 0)})] (t/is (some? result))))) +(t/deftest path-merge-disconnected-nodes + ;; Merging separate subpaths joins them at the shared midpoint. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)} + result (vec (path/merge-nodes content pts))] + (t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} + {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}] + (mapv :params result))))) + +(t/deftest path-duplicate-node-content + ;; Duplicating a node copies its incident segments as subpaths. + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 100.0 :y 0.0}} + {:command :curve-to :params {:x 200.0 :y 100.0 :c1x 100.0 :c1y 50.0 :c2x 150.0 :c2y 100.0}}]) + off (gpt/point 10 10)] + ;; Interior copies meet at the offset node. + (let [{ext :content selected :selected} (path/duplicate-node-content content 1 off)] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 110.0 :y 10.0}] + [:move-to {:x 200.0 :y 100.0}] + [:curve-to {:x 110.0 :y 10.0 :c1x 150.0 :c1y 100.0 :c2x 110.0 :c2y 60.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1 3} selected))) + ;; Endpoint copies keep only the incoming curve. + (let [{ext :content selected :selected} (path/duplicate-node-content content 2 off)] + (t/is (= [[:move-to {:x 100.0 :y 0.0}] + [:curve-to {:c1x 100.0 :c1y 50.0 :c2x 160.0 :c2y 110.0 :x 210.0 :y 110.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1} selected))) + ;; Subpath-start copies reverse the outgoing segment. + (let [{ext :content selected :selected} (path/duplicate-node-content content 0 off)] + (t/is (= [[:move-to {:x 100.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{1} selected))) + ;; a lone point (subpath with only a move-to) is copied as an offset point + (let [lone (path/content [{:command :move-to :params {:x 5.0 :y 5.0}}]) + {ext :content selected :selected} (path/duplicate-node-content lone 0 off)] + (t/is (= [[:move-to {:x 15.0 :y 15.0}]] + (mapv (juxt :command :params) ext))) + (t/is (= #{0} selected))))) + +(t/deftest segment-collapse-handler + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:x 100.0 :y 0.0 :c1x 20.0 :c1y 40.0 :c2x 80.0 :c2y 40.0}}])] + ;; Collapsing one handler keeps the other handle unchanged. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:curve-to {:x 100.0 :y 0.0 :c1x 0.0 :c1y 0.0 :c2x 80.0 :c2y 40.0}]] + (mapv (juxt :command :params) (path/collapse-handler content 1 :c1)))) + ;; collapsing the second handler too degenerates the curve into a line-to + (let [collapsed (-> content + (path/collapse-handler 1 :c1) + (path/collapse-handler 1 :c2))] + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 100.0 :y 0.0}]] + (mapv (juxt :command :params) collapsed)))))) + +(t/deftest segment-toggle-segment-curve + (let [line (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 90.0 :y 0.0}}]) + curve (path/toggle-segment-curve line 1)] + ;; Curved lines use perpendicular bowed handles. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:curve-to {:x 90.0 :y 0.0 :c1x 30.0 :c1y 22.5 :c2x 60.0 :c2y 22.5}]] + (mapv (juxt :command :params) curve))) + ;; curve -> line: drops the control points + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 90.0 :y 0.0}]] + (mapv (juxt :command :params) (path/toggle-segment-curve curve 1)))) + ;; move-to / close-path are untouched + (t/is (= (vec line) (vec (path/toggle-segment-curve line 0)))))) + +(t/deftest segment-remove-segments + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}])] + ;; Removing an interior segment keeps both endpoints. + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:move-to {:x 20.0 :y 0.0}] + [:line-to {:x 30.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments content #{2})))) + ;; Removing the first segment drops the dangling start node. + (t/is (= [[:move-to {:x 10.0 :y 0.0}] + [:line-to {:x 20.0 :y 0.0}] + [:line-to {:x 30.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments content #{1})))) + ;; a closed subpath broken elsewhere keeps its closing line geometry + (let [closed (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}])] + (t/is (= [[:move-to {:x 10.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}] + [:line-to {:x 0.0 :y 0.0}]] + (mapv (juxt :command :params) (path/remove-segments closed #{1})))) + ;; removing the close-path just leaves the subpath open + (t/is (= [[:move-to {:x 0.0 :y 0.0}] + [:line-to {:x 10.0 :y 0.0}] + [:line-to {:x 10.0 :y 10.0}]] + (mapv (juxt :command :params) (path/remove-segments closed #{3}))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; BOOL OPERATIONS — INTERSECTION / DIFFERENCE / EXCLUSION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1592,3 +2505,85 @@ [])] (t/is (= max-safe (:x move-res)) "reduce first x should be clamped") (t/is (= min-safe (:y move-res)) "reduce first y should be clamped"))))) + +(t/deftest segment-entries-identity + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}} + {:command :close-path :params {}} + {:command :move-to :params {:x 30.0 :y 30.0}} + {:command :line-to :params {:x 40.0 :y 30.0}}] + entries (path/segment-entries content)] + (t/is (= [1 2 3 5] (mapv :index entries))) + ;; The closing segment goes back to the subpath start node + (t/is (= 0 (:to-index (nth entries 2)))) + (t/is (= (gpt/point 0.0 0.0) (:to (nth entries 2)))) + ;; The second subpath starts from its own move-to + (t/is (= 4 (:from-index (nth entries 3)))))) + +(t/deftest single-line-predicate + ;; A move-to followed by exactly one line-to is a single line + (t/is (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]))) + ;; A curve, a polyline and a closed loop are not + (t/is (not (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :curve-to :params {:c1x 1.0 :c1y 0.0 :c2x 2.0 :c2y 0.0 :x 10.0 :y 0.0}}])))) + (t/is (not (path/single-line? + (path/content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}])))) + (t/is (not (path/single-line? nil)))) + +(t/deftest extract-content-chains-and-breaks + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}} + {:command :line-to :params {:x 40.0 :y 0.0}}]] + ;; Adjacent selected segments chain into one subpath + (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}] + (vec (path/extract-content content {:segments #{2 3}})))) + ;; A gap starts a new subpath + (t/is (= [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :move-to :params {:x 30.0 :y 0.0}} + {:command :line-to :params {:x 40.0 :y 0.0}}] + (vec (path/extract-content content {:segments #{1 4}})))))) + +(t/deftest extract-content-from-selected-nodes + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}} + {:command :line-to :params {:x 30.0 :y 0.0}}]] + ;; Segments whose two endpoint nodes are selected are included + (t/is (= [{:command :move-to :params {:x 10.0 :y 0.0}} + {:command :curve-to :params {:c1x 12.0 :c1y 0.0 :c2x 18.0 :c2y 0.0 :x 20.0 :y 0.0}}] + (vec (path/extract-content content {:nodes #{1 2}})))) + ;; A single selected node produces no content + (t/is (empty? (path/extract-content content {:nodes #{1}}))) + ;; Non-adjacent selected nodes produce no content + (t/is (empty? (path/extract-content content {:nodes #{0 2}}))))) + +(t/deftest extract-content-closes-full-loops + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :close-path :params {}}] + result (vec (path/extract-content content {:nodes #{0 1 2}}))] + (t/is (= :move-to (:command (nth result 0)))) + (t/is (= :line-to (:command (nth result 1)))) + (t/is (= :line-to (:command (nth result 2)))) + (t/is (= :close-path (:command (nth result 3)))))) + +(t/deftest splice-content-appends-subpaths + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}] + sub [{:command :move-to :params {:x 30.0 :y 30.0}} + {:command :line-to :params {:x 40.0 :y 30.0}}] + result (path/splice-content content sub)] + (t/is (path.impl/path-data? result)) + (t/is (= (into (vec content) sub) (vec result))))) diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index d7281244c7..702b3b3aef 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -171,10 +171,12 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = // Workaround: hover viewport first to avoid nil mouse position crash await workspacePage.viewport.hover(); - // Draw a path + // Draw a path with two segments; a single straight segment shows + // endpoint controls instead of the size badge await workspacePage.pathButton.click(); await workspacePage.clickAt(779, 163); await workspacePage.clickAt(951, 258); + await workspacePage.clickAt(1050, 163); // Finish drawing (commits path, path enters edition mode) await page.keyboard.press("Escape"); @@ -187,6 +189,36 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/); }); +test("Selection size badge is hidden for straight line paths", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); + + await workspacePage.goToWorkspace(); + + // Workaround: hover viewport first to avoid nil mouse position crash + await workspacePage.viewport.hover(); + + // Draw a path with a single straight segment + await workspacePage.pathButton.click(); + await workspacePage.clickAt(779, 163); + await workspacePage.clickAt(951, 258); + + // Finish drawing (commits path, path enters edition mode) + await page.keyboard.press("Escape"); + + // Exit edition mode (path stays selected) + await page.keyboard.press("Escape"); + + await expect(page.locator(".line-controls")).toBeVisible(); + await expect(page.locator(".selection-size-badge")).toHaveCount(0); +}); + test("User makes a group", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); diff --git a/frontend/resources/images/cursors/draw-add.svg b/frontend/resources/images/cursors/draw-add.svg new file mode 100644 index 0000000000..a2113432fb --- /dev/null +++ b/frontend/resources/images/cursors/draw-add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/draw-node.svg b/frontend/resources/images/cursors/draw-node.svg new file mode 100644 index 0000000000..5d6290c6bc --- /dev/null +++ b/frontend/resources/images/cursors/draw-node.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/draw-remove.svg b/frontend/resources/images/cursors/draw-remove.svg new file mode 100644 index 0000000000..1d430ca8e5 --- /dev/null +++ b/frontend/resources/images/cursors/draw-remove.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/draw.svg b/frontend/resources/images/cursors/draw.svg new file mode 100644 index 0000000000..97e139d4e4 --- /dev/null +++ b/frontend/resources/images/cursors/draw.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/move-add.svg b/frontend/resources/images/cursors/move-add.svg new file mode 100644 index 0000000000..3103c58d71 --- /dev/null +++ b/frontend/resources/images/cursors/move-add.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/images/cursors/move-copy.svg b/frontend/resources/images/cursors/move-copy.svg new file mode 100644 index 0000000000..4c6038f11b --- /dev/null +++ b/frontend/resources/images/cursors/move-copy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/cursors/move-curve.svg b/frontend/resources/images/cursors/move-curve.svg new file mode 100644 index 0000000000..f898b6cf33 --- /dev/null +++ b/frontend/resources/images/cursors/move-curve.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/images/cursors/move-handles.svg b/frontend/resources/images/cursors/move-handles.svg new file mode 100644 index 0000000000..852cfe59b9 --- /dev/null +++ b/frontend/resources/images/cursors/move-handles.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/images/cursors/move-move.svg b/frontend/resources/images/cursors/move-move.svg new file mode 100644 index 0000000000..beb6585cc0 --- /dev/null +++ b/frontend/resources/images/cursors/move-move.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/images/cursors/move-node.svg b/frontend/resources/images/cursors/move-node.svg new file mode 100644 index 0000000000..66f326aa90 --- /dev/null +++ b/frontend/resources/images/cursors/move-node.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/images/cursors/move-remove.svg b/frontend/resources/images/cursors/move-remove.svg new file mode 100644 index 0000000000..022d749c07 --- /dev/null +++ b/frontend/resources/images/cursors/move-remove.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/images/cursors/move.svg b/frontend/resources/images/cursors/move.svg new file mode 100644 index 0000000000..223bb839a6 --- /dev/null +++ b/frontend/resources/images/cursors/move.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/resources/images/icons/handlers-equal.svg b/frontend/resources/images/icons/handlers-equal.svg new file mode 100644 index 0000000000..645e773d8c --- /dev/null +++ b/frontend/resources/images/icons/handlers-equal.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/handlers-independent.svg b/frontend/resources/images/icons/handlers-independent.svg new file mode 100644 index 0000000000..36e1c06917 --- /dev/null +++ b/frontend/resources/images/icons/handlers-independent.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/handlers-mirror.svg b/frontend/resources/images/icons/handlers-mirror.svg new file mode 100644 index 0000000000..78f71c740e --- /dev/null +++ b/frontend/resources/images/icons/handlers-mirror.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/snap.svg b/frontend/resources/images/icons/snap.svg new file mode 100644 index 0000000000..0acdce1f20 --- /dev/null +++ b/frontend/resources/images/icons/snap.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/styles/common/refactor/color-defs.scss b/frontend/resources/styles/common/refactor/color-defs.scss index df8731a053..c78f5f0733 100644 --- a/frontend/resources/styles/common/refactor/color-defs.scss +++ b/frontend/resources/styles/common/refactor/color-defs.scss @@ -41,6 +41,7 @@ // APP COLORS --app-white: #fff; // Used in several places --app-black: #000; // Used on interactions, measurements and editor files + --app-pink: #f49ef7; // Used in path selection // SOCIAL LOGIN BUTTONS --google-login-background: #4285f4; diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index b246a6a737..af62c11962 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1616,6 +1616,7 @@ (dm/export dwt/update-dimensions-coalesced) (dm/export dwt/change-orientation) (dm/export dwt/start-rotate) +(dm/export dwt/start-move-line-point) (dm/export dwt/increase-rotation) (dm/export dwt/increase-rotation-coalesced) (dm/export dwt/start-move-selected) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index a8c1dc179e..0b234dadc1 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -40,6 +40,7 @@ [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.path.clipboard :as path-cp] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtxt] @@ -292,8 +293,9 @@ (rx/mapcat (fn [pdata] (case (:type pdata) - :copied-props (rx/of (paste-transit-props pdata)) - :copied-shapes (rx/of (paste-transit-shapes pdata)) + :copied-props (rx/of (paste-transit-props pdata)) + :copied-shapes (rx/of (paste-transit-shapes pdata)) + :copied-path-content (rx/of (path-cp/paste-nodes-as-shape (:content pdata))) (rx/empty))))) :else diff --git a/frontend/src/app/main/data/workspace/drawing/common.cljs b/frontend/src/app/main/data/workspace/drawing/common.cljs index 7a93af1289..930d91a280 100644 --- a/frontend/src/app/main/data/workspace/drawing/common.cljs +++ b/frontend/src/app/main/data/workspace/drawing/common.cljs @@ -14,6 +14,7 @@ [app.common.types.path :as path] [app.common.types.shape :as cts] [app.main.data.helpers :as dsh] + [app.main.data.workspace.path.state :as path.state] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.worker :as mw] @@ -51,9 +52,16 @@ (ptk/reify ::clear-drawing ptk/UpdateEvent (update [_ state] - (if preserve-tool? - (update state :workspace-drawing dissoc :object :lock) - (dissoc state :workspace-drawing)))))) + (let [path-editing? (path.state/editing? state)] + (cond + path-editing? + (update state :workspace-drawing select-keys [:object]) + + preserve-tool? + (update state :workspace-drawing dissoc :object :lock) + + :else + (dissoc state :workspace-drawing))))))) (defn handle-finish-drawing [] @@ -121,6 +129,6 @@ (rx/of (dwu/commit-undo-transaction (:id shape)))) (rx/empty))))) - ;; Delay so the mouse event can read the drawing state + ;; Let the current mouse event finish before clearing drawing state. (->> (rx/of (clear-drawing {:preserve-tool? (= tool :curve)})) (rx/delay 0))))))) diff --git a/frontend/src/app/main/data/workspace/drawing/curve.cljs b/frontend/src/app/main/data/workspace/drawing/curve.cljs index 8275c95050..efc670b492 100644 --- a/frontend/src/app/main/data/workspace/drawing/curve.cljs +++ b/frontend/src/app/main/data/workspace/drawing/curve.cljs @@ -26,6 +26,9 @@ (def ^:const simplify-tolerance 0.3) +;; Maximum curve-fit deviation in board units. +(def ^:const smooth-tolerance 1) + (defn- setup-frame [] (ptk/reify ::setup-frame @@ -82,7 +85,7 @@ (update-in state [:workspace-drawing :object] (fn [{:keys [::points] :as shape}] (let [points (ups/simplify points simplify-tolerance) - content (path/points->content points) + content (path/smooth-points->content points smooth-tolerance) selrect (path/calc-selrect content) points (grc/rect->points selrect)] @@ -117,4 +120,3 @@ (setup-frame) (finish-drawing) (common/handle-finish-drawing))))))) - diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs index 08ea46ca4a..185289276e 100644 --- a/frontend/src/app/main/data/workspace/edition.cljs +++ b/frontend/src/app/main/data/workspace/edition.cljs @@ -8,6 +8,7 @@ (:require [app.main.data.helpers :as dsh] [app.main.data.workspace.path.common :as dwpc] + [app.main.data.workspace.path.state :as path.state] [app.main.features :as features] [app.render-wasm.api :as wasm.api] [beicon.v2.core :as rx] @@ -48,26 +49,32 @@ (defn clear-edition-mode [] - (ptk/reify ::clear-edition-mode - ptk/UpdateEvent - (update [_ state] - (-> state - (update :workspace-local dissoc :edition :edit-path) - (update :workspace-drawing dissoc :object :lock) - (dissoc :workspace-grid-edition) - (dissoc :workspace-wasm-editor-styles))) + (let [path-id (volatile! nil)] + (ptk/reify ::clear-edition-mode + ptk/UpdateEvent + (update [_ state] + (let [edition-id (get-in state [:workspace-local :edition]) + path-editing? (path.state/editing? state)] + (vreset! path-id (when path-editing? edition-id)) + (-> state + (update :workspace-local dissoc :edition) + (cond-> (not path-editing?) + (update :workspace-local dissoc :edit-path) - ptk/WatchEvent - (watch [_ state _] - (let [id (get-in state [:workspace-local :edition])] - (rx/concat - (when (some? id) - (dwpc/finish-path))))) + (not path-editing?) + (update :workspace-drawing dissoc :object :lock)) + (dissoc :workspace-grid-edition) + (dissoc :workspace-wasm-editor-styles)))) - ptk/EffectEvent - (effect [_ state _] - (when (features/active-feature? state "text-editor-wasm/v1") - ;; NOTE: the WASM text editor is disposed by the v3 editor component on - ;; unmount, *after* it finalizes its content. - (wasm.api/request-render "clear-edition-mode"))))) + ptk/WatchEvent + (watch [_ _ _] + (if (some? @path-id) + (rx/of (dwpc/finish-path)) + (rx/empty))) + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor-wasm/v1") + ;; NOTE: the WASM text editor is disposed by the v3 editor component on + ;; unmount, *after* it finalizes its content. + (wasm.api/request-render "clear-edition-mode")))))) diff --git a/frontend/src/app/main/data/workspace/path.cljs b/frontend/src/app/main/data/workspace/path.cljs index c2e043f58a..de8decc1ba 100644 --- a/frontend/src/app/main/data/workspace/path.cljs +++ b/frontend/src/app/main/data/workspace/path.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.path (:require [app.common.data.macros :as dm] + [app.main.data.workspace.path.clipboard :as clipboard] [app.main.data.workspace.path.drawing :as drawing] [app.main.data.workspace.path.edition :as edition] [app.main.data.workspace.path.selection :as selection] @@ -19,31 +20,59 @@ (dm/export drawing/close-path-drag-start) (dm/export drawing/change-edit-mode) (dm/export drawing/reset-last-handler) +(dm/export drawing/on-draw-node-pointer-down) +(dm/export drawing/on-draw-segment-pointer-down) +(dm/export drawing/start-move-prev-handler) ;; Edition (dm/export edition/start-move-handler) (dm/export edition/start-move-path-point) +(dm/export edition/start-move-path-segment) (dm/export edition/start-path-edit) (dm/export edition/create-node-at-position) (dm/export edition/move-selected) +;; Clipboard +(dm/export clipboard/copy-selected-nodes) +(dm/export clipboard/cut-selected-nodes) +(dm/export clipboard/paste-nodes) +(dm/export clipboard/duplicate-selected) + ;; Selection (dm/export selection/handle-area-selection) (dm/export selection/select-node) +(dm/export selection/select-segment) +(dm/export selection/select-handler) (dm/export selection/path-handler-enter) (dm/export selection/path-handler-leave) +(dm/export selection/path-segment-enter) +(dm/export selection/path-segment-leave) (dm/export selection/path-pointer-enter) (dm/export selection/path-pointer-leave) +(dm/export selection/select-all-nodes) +(dm/export selection/deselect-all) ;; Path tools (dm/export tools/make-curve) (dm/export tools/make-corner) (dm/export tools/add-node) (dm/export tools/remove-node) +(dm/export tools/delete-selected) +(dm/export tools/delete-selected-with-segments) (dm/export tools/merge-nodes) (dm/export tools/join-nodes) (dm/export tools/separate-nodes) (dm/export tools/toggle-snap) +(dm/export tools/set-handler-type) +(dm/export tools/toggle-node-curve) +(dm/export tools/toggle-segment-curve) +(dm/export tools/remove-segment) +(dm/export tools/remove-node-with-segments) +(dm/export tools/remove-handler) +(dm/export tools/flip-nodes) +(dm/export tools/align-nodes) +(dm/export tools/distribute-nodes) +(dm/export tools/set-selection-coordinate) ;; Undo/redo (dm/export undo/undo-path) diff --git a/frontend/src/app/main/data/workspace/path/changes.cljs b/frontend/src/app/main/data/workspace/path/changes.cljs index 2ed96b3e7b..d47cf11230 100644 --- a/frontend/src/app/main/data/workspace/path/changes.cljs +++ b/frontend/src/app/main/data/workspace/path/changes.cljs @@ -6,86 +6,57 @@ (ns app.main.data.workspace.path.changes (:require - [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.types.path :as path] [app.main.data.changes :as dch] [app.main.data.helpers :as dsh] - [app.main.data.workspace.path.state :as st] + [app.main.data.workspace.shapes :as dwsh] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn generate-path-changes - "Generates changes to update the new content of the shape" - [it objects page-id shape old-content new-content] +(defn- normalize-content + "Normalizes path content for persistence." + [content preserve-move-to] + (-> (if (and (not preserve-move-to) + (= (-> content last :command) :move-to)) + (take (dec (count content)) content) + content) + (path/close-loops))) - (assert (path/content? old-content)) - (assert (path/content? new-content)) - - (let [shape-id (:id shape) - - ;; We set the old values so the update-shapes works - objects - (update objects shape-id - (fn [shape] - (-> shape - (assoc :content old-content) - (path/update-geometry)))) - - changes - (-> (pcb/empty-changes it page-id) - (pcb/with-objects objects)) - - new-content - (path/content new-content)] - - (cond - ;; https://tree.taiga.io/project/penpot/issue/2366 - (nil? shape-id) - changes - - (empty? new-content) - (-> changes - (pcb/remove-objects [shape-id]) - (pcb/resize-parents [shape-id])) - - :else - (-> changes - (pcb/update-shapes [shape-id] - (fn [shape] - (-> shape - (assoc :content new-content) - (path/update-geometry)))) - (pcb/resize-parents [shape-id]))))) - -(defn save-path-content - ([] - (save-path-content {})) - ([{:keys [preserve-move-to] :or {preserve-move-to false}}] - (ptk/reify ::save-path-content - ptk/UpdateEvent - (update [_ state] - (let [content (st/get-path state :content) - content (if (and (not preserve-move-to) - (= (-> content last :command) :move-to)) - (path/content (take (dec (count content)) content)) - (path/content content))] - (st/set-content state content))) - - ptk/WatchEvent - (watch [it state _] - (let [page-id (:current-page-id state) - local (get state :workspace-local) - id (get local :edition) - objects (dsh/lookup-page-objects state page-id)] - - ;; NOTE: we proceed only if the shape is present on the - ;; objects, if shape is a ephimeral drawing shape, we should - ;; do nothing - (when-let [shape (get objects id)] - (when-let [old-content (dm/get-in local [:edit-path id :old-content])] - (let [new-content (get shape :content) - changes (generate-path-changes it objects page-id shape old-content new-content)] - (rx/of (dch/commit-changes changes)))))))))) +(defn finalize-path-content + [id] + (ptk/reify ::finalize-path-content + ptk/WatchEvent + (watch [it state _] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shape (get objects id) + old-content (get-in state [:workspace-local :edit-path id :old-content]) + edit-content (get-in state [:workspace-drawing :object :content]) + new-content (some-> edit-content (normalize-content false))] + (cond + ;; Ignore differences introduced only by normalization. + (or (nil? shape) + (nil? old-content) + (nil? edit-content) + (= old-content edit-content) + (= (path/close-loops old-content) new-content)) + (rx/empty) + (empty? new-content) + (let [changes (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) + (pcb/remove-objects [id]) + (pcb/resize-parents [id]))] + (rx/of (dch/commit-changes changes))) + :else + (rx/of + (dwsh/update-shapes + [id] + (fn [shape] + (-> shape + (path/convert-to-path) + (assoc :content new-content) + (path/update-geometry))) + {:reg-objects? true}))))))) diff --git a/frontend/src/app/main/data/workspace/path/clipboard.cljs b/frontend/src/app/main/data/workspace/path/clipboard.cljs new file mode 100644 index 0000000000..003b555e6b --- /dev/null +++ b/frontend/src/app/main/data/workspace/path/clipboard.cljs @@ -0,0 +1,209 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.data.workspace.path.clipboard + (:require + [app.common.data.macros :as dm] + [app.common.geom.point :as gpt] + [app.common.math :as mth] + [app.common.transit :as t] + [app.common.types.path :as path] + [app.main.data.helpers :as dsh] + [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.drawing :as drawing] + [app.main.data.workspace.path.edition :as edition] + [app.main.data.workspace.path.helpers :as helpers] + [app.main.data.workspace.path.state :as st] + [app.main.data.workspace.path.tools :as tools] + [app.main.data.workspace.shapes :as dwsh] + [app.main.streams :as ms] + [app.util.clipboard :as clipboard] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(def ^:private clipboard-type :copied-path-content) + +(defn- on-clipboard-error + [cause] + (js/console.error "clipboard blocked:" cause) + (rx/empty)) + +(defn copy-selected-nodes + "Copies the selected path content to the clipboard." + [] + (ptk/reify ::copy-selected-nodes + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + fragment (some-> content (path/extract-content selection))] + (when (seq fragment) + (let [data (t/encode-str {:type clipboard-type + :content fragment} + {:type :json-verbose})] + (->> (rx/from (clipboard/to-clipboard data)) + (rx/catch on-clipboard-error) + (rx/ignore)))))))) + +(defn cut-selected-nodes + "Copies and removes the current path selection." + [] + (ptk/reify ::cut-selected-nodes + ptk/WatchEvent + (watch [_ _ _] + (rx/of (copy-selected-nodes) + (tools/delete-selected))))) + +(def ^:private paste-offset (gpt/point 10 10)) + +(defn collision-step + "Returns the non-negative paste-offset step that makes two nodes coincide." + [pasted existing] + (let [delta (gpt/subtract existing pasted) + x-step (/ (:x delta) (:x paste-offset)) + y-step (/ (:y delta) (:y paste-offset))] + (when (and (not (neg? x-step)) + (mth/close? x-step y-step) + (mth/close? x-step (mth/round x-step))) + (long (mth/round x-step))))) + +(defn available-offset-step + "Returns the first paste-offset step with no node collisions." + [existing pasted] + (let [blocked + (reduce + (fn [blocked pasted-point] + (reduce + (fn [blocked existing-point] + (if-let [step (collision-step pasted-point existing-point)] + (conj blocked step) + blocked)) + blocked + existing)) + #{} + pasted)] + ;; At most (count blocked) non-negative steps can be unavailable. + (some #(when-not (contains? blocked %) %) + (range (inc (count blocked)))))) + +(defn- center-content-at + "Centers `sub-content` on `target` using its node bounds." + [sub-content target] + (let [pts (path/get-points sub-content) + xs (map :x pts) + ys (map :y pts) + center (gpt/point (/ (+ (reduce min xs) (reduce max xs)) 2) + (/ (+ (reduce min ys) (reduce max ys)) 2))] + (path/move-content sub-content (gpt/subtract target center)))) + +(defn- offset-pasted-content + "Offsets pasted content until its nodes do not overlap existing nodes." + [content sub-content] + (let [existing (into #{} (path/get-points content)) + pasted (path/get-points sub-content) + step (available-offset-step existing pasted)] + (if (zero? step) + sub-content + (path/move-content sub-content (gpt/scale paste-offset step))))) + +(defn paste-content + "Pastes path content into the edited path at the pointer." + [sub-content] + (ptk/reify ::paste-content + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (if (and (some? id) + (some? (dm/get-in state [:workspace-local :edit-path id])) + (seq sub-content)) + (let [content (st/get-path state :content) + base (count content) + target (deref ms/mouse-position) + ;; Center the fragment at the pointer. + sub-content (cond-> sub-content + (some? target) (center-content-at target)) + sub-content (offset-pasted-content content sub-content) + new-content (path/splice-content content sub-content) + pasted (into #{} + (map #(+ base %)) + (helpers/node-indices sub-content))] + (-> state + (st/set-content new-content) + (update-in (st/get-path-location state) path/update-geometry) + (assoc-in [:workspace-local :edit-path id :selection] + (assoc helpers/empty-selection :nodes pasted)))) + state))) + + ptk/WatchEvent + (watch [_ state _] + ;; Enter move mode with the pasted nodes selected. + (when (some? (dm/get-in state [:workspace-local :edition])) + (rx/of (drawing/change-edit-mode :move)))))) + +(defn paste-nodes-as-shape + "Creates a path shape from copied content at the pointer." + [sub-content] + (ptk/reify ::paste-nodes-as-shape + ptk/WatchEvent + (watch [_ state _] + (let [content (path/content sub-content) + id (st/get-path-id state) + editing? (and (some? id) + (some? (dm/get-in state [:workspace-local :edit-path id])))] + (when (and (not editing?) (seq (path/get-points content))) + (let [target (or (deref ms/mouse-position) + (dsh/get-viewport-center state)) + moved (center-content-at content target) + mrect (path/calc-selrect moved)] + (rx/of + (dwsh/create-and-add-shape + :path (:x target) (:y target) + {:content moved + ;; Keep the shape at the content position. + :x (:x mrect) + :y (:y mrect) + :width (:width mrect) + :height (:height mrect) + :name "Path"})))))))) + +(defn paste-nodes + "Pastes copied path content into the edited path." + [] + (ptk/reify ::paste-nodes + ptk/WatchEvent + (watch [_ _ _] + (->> (clipboard/from-navigator) + (rx/filter #(= (.-type ^js %) "application/transit+json")) + (rx/mapcat #(rx/from (.text ^js %))) + (rx/map t/decode-str) + (rx/filter #(and (map? %) (= clipboard-type (:type %)))) + (rx/take 1) + (rx/mapcat (fn [{:keys [content]}] + ;; Drop a pending segment before splicing. + (rx/of (common/cancel-pending-segment) + (paste-content content)))) + (rx/catch on-clipboard-error))))) + +(defn duplicate-selected + "Duplicates the current node and segment selection." + [] + (ptk/reify ::duplicate-selected + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + result (helpers/duplicate-selection-content + content selection (edition/duplicate-offset zoom))] + (when (seq (:sub result)) + (rx/concat + ;; Drop a pending segment before splicing. + (rx/of (common/cancel-pending-segment) + (edition/splice-duplicated result)) + (when (some? (dm/get-in state [:workspace-local :edition])) + (rx/of (drawing/change-edit-mode :move))))))))) diff --git a/frontend/src/app/main/data/workspace/path/common.cljs b/frontend/src/app/main/data/workspace/path/common.cljs index ffdeeac51c..e2f3a06c61 100644 --- a/frontend/src/app/main/data/workspace/path/common.cljs +++ b/frontend/src/app/main/data/workspace/path/common.cljs @@ -17,6 +17,23 @@ [state] (dissoc state :last-point :prev-handler :drag-handler :preview)) +(defn- drop-trailing-move-to + "Drops a trailing subpath start without segments." + [content] + (if (= :move-to (-> content last :command)) + (path/content (take (dec (count content)) content)) + content)) + +(defn- update-object-content + [state f] + (let [location (st/get-path-location state) + object (get-in state location) + content (some-> (:content object) f)] + (cond-> state + (some? content) + (assoc-in location (cond-> (assoc object :content content) + (seq content) (path/update-geometry)))))) + (defn finish-path [] (ptk/reify ::finish-path @@ -25,4 +42,15 @@ (let [id (st/get-path-id state)] (-> state (update-in [:workspace-local :edit-path id] clean-edit-state) - (update-in (st/get-path-location state :content) path/close-subpaths)))))) + (update-object-content (comp path/close-subpaths drop-trailing-move-to))))))) + +(defn cancel-pending-segment + "Cancels the pending segment without leaving draw mode." + [] + (ptk/reify ::cancel-pending-segment + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (-> state + (update-in [:workspace-local :edit-path id] clean-edit-state) + (update-object-content drop-trailing-move-to)))))) diff --git a/frontend/src/app/main/data/workspace/path/drawing.cljs b/frontend/src/app/main/data/workspace/path/drawing.cljs index 6154aa8451..fa4db3cf7f 100644 --- a/frontend/src/app/main/data/workspace/path/drawing.cljs +++ b/frontend/src/app/main/data/workspace/path/drawing.cljs @@ -20,13 +20,14 @@ [app.main.data.workspace.drawing.common :as dwdc] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.pages :as-alias dwpg] - [app.main.data.workspace.path.changes :as changes] [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.edition :as edition] [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.data.workspace.path.streams :as streams] + [app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.undo :as undo] - [app.main.data.workspace.shapes :as dwsh] + [app.main.streams :as ms] [app.util.mouse :as mse] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -35,36 +36,75 @@ (declare check-changed-content) (declare change-edit-mode) -(defn- end-path-event? +(defn start-created-path-edition + [id] + (ptk/reify ::start-created-path-edition + ptk/WatchEvent + (watch [_ _ _] + (rx/of (dwe/start-edition-mode id) + (edition/start-path-edit id) + (change-edit-mode :draw))))) + +;; Draw-loop stop signals either restart the same path or exit drawing. + +(defn restart-draw-loop? + "True when drawing restarts on the same path." + [event] + (or (= (ptk/type event) ::common/finish-path) + (and ^boolean (mse/mouse-event? event) + ^boolean (mse/mouse-double-click-event? event)))) + +(defn- exit-draw-loop? + "True when the draw loop exits." [event] (let [type (ptk/type event)] - (or - (= type ::common/finish-path) - (= type :app.main.data.workspace.path.shortcuts/esc-pressed) - (= type :app.main.data.workspace.common/clear-edition-mode) - (= type :app.main.data.workspace.edition/clear-edition-mode) - (= type ::dwpg/finalize-page) - (= event :interrupt) ;; ESC - (and ^boolean (mse/mouse-event? event) - ^boolean (mse/mouse-double-click-event? event))))) + (or (= type ::dwe/clear-edition-mode) + (= type ::dwpg/finalize-page) + (dwe/interrupt? event)))) + +(defn- end-path-event? + "True when the draw loop should stop." + [event] + (or (restart-draw-loop? event) + (exit-draw-loop? event))) + +(def ^:private draw-insert-threshold + "Maximum screen distance for inserting a node on a segment." + 16) (defn preview-next-point [{:keys [x y shift?]}] (ptk/reify ::preview-next-point ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) - fix-angle? shift? - last-point (get-in state [:workspace-local :edit-path id :last-point]) - position (cond-> (gpt/point x y) - fix-angle? (path.helpers/position-fixed-angle last-point)) - content (st/get-path state :content) + (let [id (st/get-path-id state) + edit-path (get-in state [:workspace-local :edit-path id])] + ;; Freeze the next-point preview during modifier drags. + (if (seq (:content-modifiers edit-path)) + state + (let [fix-angle? shift? + {:keys [last-point prev-handler]} edit-path + content (st/get-path state :content) + zoom (dm/get-in state [:workspace-local :zoom] 1) + raw-pos @ms/mouse-position - {:keys [last-point prev-handler]} - (get-in state [:workspace-local :edit-path id]) + ;; Segment insertion uses the exact on-curve preview point. + insert-point (when (and (seq (:segments (:hover edit-path))) + (gpt/point? raw-pos)) + (helpers/insertion-point + content raw-pos (/ draw-insert-threshold zoom) true)) - segment (path/next-node content position last-point prev-handler)] - (assoc-in state [:workspace-local :edit-path id :preview] segment))))) + position (cond + (some? insert-point) + insert-point + + fix-angle? + (path.helpers/position-fixed-angle (gpt/point x y) last-point) + + :else + (gpt/point x y)) + segment (path/next-node content position last-point prev-handler)] + (assoc-in state [:workspace-local :edit-path id :preview] segment))))))) (defn add-node [{:keys [x y shift?]}] @@ -141,34 +181,84 @@ (rx/of (preview-next-point handler) (undo/merge-head)))))) -(defn close-path-drag-start - [position] - (ptk/reify ::close-path-drag-start +(defn drag-prev-handler + "Moves the current node's forward handle while drawing." + [{:keys [x y alt? shift?]}] + (ptk/reify ::drag-prev-handler + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + index (count content) + position (path.helpers/segment->point (nth content (dec index))) + + handler-position + (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle position)) + + dx (- (:x handler-position) (:x position)) + dy (- (:y handler-position) (:y position)) + + ;; Alt leaves the opposite handle unchanged. + rejoin? (not alt?) + + modifiers (helpers/move-handler-modifiers content index :c1 false false rejoin? dx dy)] + (-> state + (update-in [:workspace-local :edit-path id] dissoc :prev-handler) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers) + (assoc-in [:workspace-local :edit-path id :drag-handler] handler-position)))))) + +(defn start-move-prev-handler + "Starts dragging the current node's forward handle." + [] + (ptk/reify ::start-move-prev-handler ptk/WatchEvent (watch [_ state stream] - (let [content (st/get-path state :content) - handlers (-> (path/get-handlers content) - (get position)) + (let [stopper (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream)) - [idx prefix] (when (= (count handlers) 1) - (first handlers)) - - drag-events-stream + drag-events (->> (streams/position-stream state) - (rx/map #(drag-handler position idx prefix %)) - (rx/take-until - (rx/merge - (mse/drag-stopper stream) - (rx/filter end-path-event? stream))))] + (rx/map drag-prev-handler) + (rx/take-until stopper))] + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor "move-handles")) + drag-events + (rx/of (finish-drag)))))))) - (rx/concat - (rx/of (add-node position)) - (streams/drag-stream - (rx/concat - drag-events-stream - (rx/of (finish-drag)) - (rx/of (close-path-drag-end)))) - (rx/of (common/finish-path))))))) +(defn close-path-drag-start + ([position] + (close-path-drag-start position "draw-node")) + ([position cursor] + (ptk/reify ::close-path-drag-start + ptk/WatchEvent + (watch [_ state stream] + (let [content (st/get-path state :content) + handlers (-> (path/get-handlers content) + (get position)) + + [idx prefix] (when (= (count handlers) 1) + (first handlers)) + + drag-events-stream + (->> (streams/position-stream state) + (rx/map #(drag-handler position idx prefix %)) + (rx/take-until + (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream))))] + + (rx/concat + (rx/of (add-node position)) + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor cursor)) + drag-events-stream + (rx/of (finish-drag)) + (rx/of (close-path-drag-end)))) + (rx/of (common/finish-path)))))))) (defn close-path-drag-end [] (ptk/reify ::close-path-drag-end @@ -177,30 +267,27 @@ (let [id (st/get-path-id state)] (update-in state [:workspace-local :edit-path id] dissoc :prev-handler))))) -(defn start-path-from-point [position] - (ptk/reify ::start-path-from-point - ptk/WatchEvent - (watch [_ state stream] - (let [stopper (rx/merge - (mse/drag-stopper stream) - (rx/filter end-path-event? stream)) +(defn start-path-from-point + ([position] + (start-path-from-point position "draw-node")) + ([position cursor] + (ptk/reify ::start-path-from-point + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (rx/merge + (mse/drag-stopper stream) + (rx/filter end-path-event? stream)) - drag-events (->> (streams/position-stream state) - (rx/map #(drag-handler %)) - (rx/take-until stopper))] - (rx/concat - (rx/of (add-node position)) - (streams/drag-stream - (rx/concat - drag-events - (rx/of (finish-drag))))))))) - -(defn make-node-events-stream - [stream] - (->> stream - (rx/filter (ptk/type? ::close-path-drag-start)) - (rx/take 1) - (rx/merge-map #(rx/empty)))) + drag-events (->> (streams/position-stream state) + (rx/map #(drag-handler %)) + (rx/take-until stopper))] + (rx/concat + (rx/of (add-node position)) + (streams/drag-stream + (rx/concat + (rx/of (edition/set-drag-cursor cursor)) + drag-events + (rx/of (finish-drag)))))))))) (defn make-drag-stream [state stream down-event] @@ -229,7 +316,12 @@ ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (assoc-in state [:workspace-local :edit-path id :edit-mode] :draw))) + (update-in state [:workspace-local :edit-path id] + (fn [edit-state] + (-> edit-state + (assoc :edit-mode :draw) + ;; Keep explicit snap choices across draw restarts. + (update :snap-toggled (fnil identity true))))))) ptk/WatchEvent (watch [_ state stream] @@ -243,26 +335,29 @@ (rx/filter end-path-event?) (rx/share)) + stop-event + (volatile! nil) + stoper-stream (->> stream (rx/filter (ptk/type? ::start-edition)) - (rx/merge end-stream)) + (rx/merge end-stream) + (rx/tap #(vreset! stop-event %)) + (rx/share)) ;; Mouse move preview mousemove-events (->> (streams/position-stream state) (rx/map #(preview-next-point %))) - ;; From mouse down we can have: click, drag and double click + ;; Viewport clicks add nodes; node clicks handle closing separately. mousedown-events (->> mouse-down ;; We just ignore the mouse event and stream down the ;; last position event (rx/with-latest-from #(-> %2) (streams/position-stream state)) - ;; We change to the stream that emits the first event (rx/switch-map - #(rx/race (make-node-events-stream stream) - (make-drag-stream state stream %))) + #(make-drag-stream state stream %)) (rx/take-until end-stream))] (->> (rx/concat @@ -270,7 +365,11 @@ (->> (rx/merge mousemove-events mousedown-events) (rx/take-until stoper-stream)) - (rx/of (ptk/data-event ::end-edition)))))))) + (->> (rx/of nil) + (rx/map (fn [_] + (ptk/data-event + ::end-edition + {:restart? (restart-draw-loop? @stop-event)})))))))))) (defn setup-frame [] @@ -299,8 +398,20 @@ (cond-> (some? drop-index) (with-meta {:index drop-index}))))))))) +(defn- close-drawn-loops + "Adds explicit close commands to completed loops." + [] + (ptk/reify ::close-drawn-loops + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:workspace-drawing :object] + (fn [object] + (-> object + (update :content path/close-loops) + (path/update-geometry))))))) + (defn- handle-drawing-end - [shape-id] + [shape-id restart?] (ptk/reify ::handle-drawing-end ptk/UpdateEvent (update [_ state] @@ -313,15 +424,26 @@ ptk/WatchEvent (watch [_ state _] (when-let [content (dm/get-in state [:workspace-drawing :object :content])] - (if (> (count content) 1) - (rx/of (setup-frame) + (cond + (and (> (count content) 1) restart?) + (rx/of (common/finish-path) + (close-drawn-loops) + (setup-frame) (dwdc/handle-finish-drawing) - (dwe/start-edition-mode shape-id) - (change-edit-mode :draw)) - (rx/of (dwdc/handle-finish-drawing))))))) + (start-created-path-edition shape-id)) + + (> (count content) 1) + (rx/of (close-drawn-loops) + (setup-frame) + (dwdc/handle-finish-drawing) + (dwe/clear-edition-mode)) + + :else + (rx/of (dwdc/handle-finish-drawing) + (dwe/clear-edition-mode))))))) (defn handle-drawing - "Hanndle the start of drawing new path shape" + "Starts drawing a path." [] (ptk/reify ::handle-new-shape ptk/UpdateEvent @@ -337,8 +459,10 @@ (->> stream (rx/filter (ptk/type? ::end-edition)) (rx/take 1) + ;; Let the stop event settle before finishing the drawing. (rx/observe-on :async) - (rx/map (partial handle-drawing-end shape-id)))))))) + (rx/map (fn [event] + (handle-drawing-end shape-id (:restart? (deref event))))))))))) (declare start-draw-mode*) @@ -347,12 +471,22 @@ (ptk/reify ::start-draw-mode ptk/UpdateEvent (update [_ state] - (let [id (dm/get-in state [:workspace-local :edition]) - objects (dsh/lookup-page-objects state) - content (dm/get-in objects [id :content])] - (if content - (update-in state [:workspace-local :edit-path id] assoc :old-content content) - state))) + (let [id (dm/get-in state [:workspace-local :edition]) + objects (dsh/lookup-page-objects state) + shape (get objects id) + drawing (dm/get-in state [:workspace-drawing :object]) + old-content (dm/get-in state [:workspace-local :edit-path id :old-content]) + drawing (or drawing + (some-> shape + (path/convert-to-path objects) + (update :content path/close-subpaths) + (path/update-geometry)))] + (cond-> state + drawing + (assoc-in [:workspace-drawing :object] drawing) + + (and drawing (nil? old-content)) + (assoc-in [:workspace-local :edit-path id :old-content] (:content drawing))))) ptk/WatchEvent (watch [_ _ _] @@ -369,23 +503,52 @@ (if (= :draw mode) (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) (rx/of (start-edition id)) (->> stream (rx/filter (ptk/type? ::end-edition)) (rx/take 1) - (rx/mapcat (fn [_] - (rx/of (check-changed-content) - (start-draw-mode*)))))) + (rx/mapcat (fn [event] + (if (:restart? (deref event)) + (rx/of (common/finish-path) + (check-changed-content) + (start-draw-mode*)) + (rx/empty)))))) (rx/empty)))))) +(defn- enter-draw-from-selected-node + "Starts a new segment from the only selected node." + [state id] + (let [selection (get (st/get-selection state id) :nodes #{}) + last-point (dm/get-in state [:workspace-local :edit-path id :last-point]) + content (st/get-path state :content)] + (if (and (nil? last-point) + (= 1 (count selection)) + (some? content) + (helpers/node? content (first selection))) + (let [index (first selection) + pos (helpers/node-position content index) + last-idx (dec (count content)) + tip? (and (= index last-idx) + (not= :close-path (:command (nth content index nil)))) + state (assoc-in state [:workspace-local :edit-path id :last-point] pos)] + (if tip? + state + (update-in state (st/get-path-location state) + (fn [shape] + (-> shape + (update :content path/append-segment + {:command :move-to :params (select-keys pos [:x :y])}) + (path/update-geometry)))))) + state))) + (defn change-edit-mode [mode] (ptk/reify ::change-edit-mode ptk/UpdateEvent (update [_ state] (if-let [id (dm/get-in state [:workspace-local :edition])] - (d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode) + (cond-> (d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode) + (= mode :draw) (enter-draw-from-selected-node id)) state)) ptk/WatchEvent @@ -405,6 +568,98 @@ (let [id (st/get-path-id state)] (assoc-in state [:workspace-local :edit-path id :prev-handler] nil))))) +(defn on-draw-node-pointer-down + "Handles node clicks and drags in draw mode." + [index position alt? mod?] + (ptk/reify ::on-draw-node-pointer-down + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + node-pos (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index)) + last-point (dm/get-in state [:workspace-local :edit-path id :last-point]) + pending-origin? (and (some? node-pos) (= last-point node-pos))] + (cond + (and mod? alt?) + (rx/concat + (rx/of (tools/remove-node-with-segments index)) + (if pending-origin? + (rx/of (common/cancel-pending-segment)) + (rx/empty))) + + mod? + (streams/drag-stream + (rx/of (edition/set-drag-cursor "move-handles") + (edition/curve-config-node-drag index)) + (rx/of (tools/toggle-node-curve index))) + + alt? + (if (some? node-pos) + (rx/concat + (rx/of (tools/remove-node node-pos)) + (if pending-origin? + (rx/of (common/cancel-pending-segment)) + (rx/empty))) + (rx/empty)) + + (= last-point position) + (rx/of (reset-last-handler)) + + (nil? last-point) + (rx/of (start-path-from-point position)) + + :else + (rx/of (close-path-drag-start position))))))) + +(defn on-draw-segment-pointer-down + "Handles segment clicks and drags in draw mode." + [index alt? mod?] + (ptk/reify ::on-draw-segment-pointer-down + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + zoom (dm/get-in state [:workspace-local :zoom] 1) + content (st/get-path state :content) + position @ms/mouse-position + last-point (dm/get-in state [:workspace-local :edit-path id :last-point])] + (cond + alt? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + pending-here? (and (some? last-point) + (some? entry) + (or (= last-point (:from entry)) + (= last-point (:to entry))))] + (rx/concat + (rx/of (tools/remove-segment index)) + (if pending-here? + (rx/of (common/cancel-pending-segment)) + (rx/empty)))) + + mod? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + bendable? (and (some? entry) + (not= :close-path (:command (:segment entry))))] + (streams/drag-stream + (if bendable? + (rx/of (edition/set-drag-cursor "move-curve") + (edition/bend-selected-segment index position)) + (rx/empty)) + (rx/of (tools/toggle-segment-curve index)))) + + :else + (let [insert-point (helpers/insertion-point + content position (/ draw-insert-threshold zoom) true)] + (if (some? insert-point) + (rx/concat + (rx/of (edition/create-node-at-position (meta insert-point))) + (if (some? last-point) + (rx/of (close-path-drag-start insert-point "draw-add")) + (rx/of (start-path-from-point insert-point "draw-add")))) + (rx/empty)))))))) + (defn check-changed-content [] (ptk/reify ::check-changed-content @@ -418,10 +673,11 @@ (cond (and (not= content old-content) (not empty-content?)) - (rx/of (changes/save-path-content)) + (rx/empty) + ;; Exit through the path edition stop event. (= mode :draw) - (rx/of :interrupt) + (rx/of (dwe/clear-edition-mode)) :else (rx/of diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index 9648756d71..867938beed 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -8,10 +8,10 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers] - [app.main.data.changes :as dch] [app.main.data.helpers :as dsh] [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.path.changes :as changes] @@ -19,32 +19,178 @@ [app.main.data.workspace.path.selection :as selection] [app.main.data.workspace.path.state :as st] [app.main.data.workspace.path.streams :as streams] + [app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.undo :as undo] - [app.main.data.workspace.shapes :as dwsh] [app.main.streams :as ms] + [app.render-wasm.svg-fills :as svg-fills] [app.util.mouse :as mse] [beicon.v2.core :as rx] + [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) -(defn modify-handler [id index prefix dx dy match-opposite?] - (ptk/reify ::modify-handler +(defn- handler-modifier-delta + [modifiers index prefix] + (let [[cx cy] (path.helpers/prefix->coords prefix)] + (gpt/point (dm/get-in modifiers [index cx] 0) + (dm/get-in modifiers [index cy] 0)))) + +(defn- remove-handler-modifier + [modifiers [index prefix]] + (let [[cx cy] (path.helpers/prefix->coords prefix) + modifiers (update modifiers index dissoc cx cy)] + (cond-> modifiers + (empty? (get modifiers index)) (dissoc index)))) + +(defn- stored-handler-drag-mode + "Returns a handler's stored drag mode, ignoring stale mirror state." + [content handler-types index prefix] + (case (get handler-types (helpers/handler-node-index index prefix)) + :mirror (if (helpers/handlers-joined? content index prefix) + :mirror + :smart) + :aligned :aligned + :independent :independent + :smart)) + +(defn- active-selected-handlers + "Returns valid handlers for the current drag." + [content primary selected-handlers move-selection?] + (let [handlers (if move-selection? selected-handlers #{primary}) + handlers (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth content index nil))))) + handlers)] + (cond-> handlers + (empty? handlers) (conj primary)))) + +(defn- handler-drag-modifiers + "Returns modifiers for one dragged handler." + [content handler-types selected-handlers start-modifiers move-delta mode + move-selection? [index prefix]] + (let [start-delta (handler-modifier-delta start-modifiers index prefix) + delta (gpt/add start-delta move-delta) + opposite-id (path/opposite-index content index prefix) + opposite-selected? (and move-selection? + (contains? selected-handlers opposite-id)) + joined? (helpers/handlers-joined? content index prefix) + handler-mode (if move-selection? + (stored-handler-drag-mode + content handler-types index prefix) + mode) + modifiers (case handler-mode + :aligned + (helpers/align-handler-modifiers + content index prefix (:x delta) (:y delta)) + + :mirror + (helpers/move-handler-modifiers + content index prefix true true true (:x delta) (:y delta)) + + :independent + (helpers/move-handler-modifiers + content index prefix false false false (:x delta) (:y delta)) + + (helpers/move-handler-modifiers + content index prefix false + (and joined? (not opposite-selected?)) + false (:x delta) (:y delta)))] + (cond-> modifiers + opposite-selected? (remove-handler-modifier opposite-id)))) + +(defn- selected-handler-modifiers + "Combines modifiers for all dragged handlers." + [content handler-types selected-handlers start-modifiers move-delta mode move-selection?] + (reduce + (fn [modifiers handler-id] + (d/deep-merge + modifiers + (handler-drag-modifiers + content handler-types selected-handlers start-modifiers move-delta + mode move-selection? handler-id))) + {} + selected-handlers)) + +(defn- transient-prev-handler + "Returns the mirrored transient drawing handler." + [content [index prefix] handler-mode moving-handler edit-mode prev-handler] + (when (and (= edit-mode :draw) + (= prefix :c2) + (= index (dec (count content))) + (some? prev-handler) + (not= handler-mode :independent)) + (let [node (path/handler->node content index prefix) + mode (if (= handler-mode :mirror) :mirror :aligned)] + (helpers/opposite-handler-target node moving-handler prev-handler mode)))) + +(defn modify-selected-handlers + "Moves selected handlers using each node's handler mode." + [id primary start-modifiers dx dy mode move-selection?] + (ptk/reify ::modify-selected-handlers ptk/UpdateEvent (update [_ state] - - (let [content (st/get-path state :content) - modifiers (helpers/move-handler-modifiers content index prefix false match-opposite? dx dy) - [cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y]) - point (gpt/point (+ (dm/get-in content [index :params cx]) dx) - (+ (dm/get-in content [index :params cy]) dy))] - + (let [content (st/get-path state :content) + handler-types (dm/get-in state + [:workspace-local :edit-path id :handler-types] + {}) + selected-handlers (active-selected-handlers + content primary + (dm/get-in state + [:workspace-local :edit-path id :selection :handlers] + #{}) + move-selection?) + move-delta (gpt/point dx dy) + moved-modifiers (selected-handler-modifiers + content handler-types selected-handlers start-modifiers + move-delta mode move-selection?) + modifiers (d/deep-merge start-modifiers moved-modifiers) + [primary-index primary-prefix] primary + primary-mode (if move-selection? + (stored-handler-drag-mode + content handler-types primary-index primary-prefix) + mode) + primary-handler (path/get-handler-point content primary-index primary-prefix) + primary-delta (gpt/add + (handler-modifier-delta start-modifiers + primary-index + primary-prefix) + move-delta) + moving-handler (gpt/add primary-handler primary-delta) + edit-mode (dm/get-in state [:workspace-local :edit-path id :edit-mode]) + prev-handler (dm/get-in state [:workspace-local :edit-path id :prev-handler]) + new-prev-handler (transient-prev-handler + content primary primary-mode moving-handler + edit-mode prev-handler)] (-> state - (update-in [:workspace-local :edit-path id :content-modifiers] merge modifiers) - (assoc-in [:workspace-local :edit-path id :moving-handler] point)))))) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers) + (assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler) + (cond-> (some? new-prev-handler) + (assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler))))))) + +(defn- apply-content-modifiers* + [id new-content] + (ptk/reify ::apply-content-modifiers* + ptk/UpdateEvent + (update [_ state] + (cond-> (-> state + (st/set-content new-content) + (update-in [:workspace-local :edit-path id] + dissoc + :content-modifiers + :moving-nodes + :moving-handler)) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry))) + + ptk/WatchEvent + (watch [_ _ _] + ;; Moving modifiers keep node indices stable. + (when (empty? new-content) + (rx/of (dwe/clear-edition-mode)))))) (defn apply-content-modifiers [] (ptk/reify ::apply-content-modifiers ptk/WatchEvent - (watch [it state _] + (watch [_ state _] (let [id (st/get-path-id state) shape (st/get-path state) @@ -52,24 +198,47 @@ (dm/get-in state [:workspace-local :edit-path id :content-modifiers])] (if (or (nil? shape) (nil? content-modifiers)) (rx/of (dwe/clear-edition-mode)) - (let [page-id (get state :current-page-id state) - objects (dsh/lookup-page-objects state) + (let [content (get shape :content) + new-content (path/apply-content-modifiers content content-modifiers)] + (when (some? new-content) + (rx/of (apply-content-modifiers* id new-content))))))))) - content (get shape :content) - new-content (path/apply-content-modifiers content content-modifiers) +(def ^:private merge-drop-distance + "Maximum screen distance for merging dropped nodes." + 10) - old-points (path/get-points content) - new-points (path/get-points new-content) - point-change (->> (map hash-map old-points new-points) (reduce merge))] +(defn merge-dragged-on-drop + "Merges the closest moved and stationary nodes after a drag." + [] + (ptk/reify ::merge-dragged-on-drop + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) - (when (and (some? new-content) (some? shape)) - (let [changes (changes/generate-path-changes it objects page-id shape (:content shape) new-content)] - (if (empty? new-content) - (rx/of (dch/commit-changes changes) - (dwe/clear-edition-mode)) - (rx/of (dch/commit-changes changes) - (selection/update-selection point-change) - (fn [state] (update-in state [:workspace-local :edit-path id] dissoc :content-modifiers :moving-nodes :moving-handler)))))))))))) + ;; Include endpoints of selected segments. + moved-indices (into (get selection :nodes #{}) + (helpers/segment-node-indices content (get selection :segments #{}))) + moved (helpers/node-positions content moved-indices) + moved-set (set moved) + + zoom (dm/get-in state [:workspace-local :zoom] 1) + threshold (/ merge-drop-distance zoom) + others (remove moved-set (path/get-points content)) + + pairs (->> moved + (keep (fn [p] + (let [near (filter #(<= (gpt/distance % p) threshold) others)] + (when (seq near) + (let [t (apply min-key #(gpt/distance % p) near)] + [p t (gpt/distance t p)])))))) + best (when (seq pairs) + (apply min-key #(nth % 2) pairs))] + (if (some? best) + (let [[p t _] best] + (rx/of (tools/process-path-tool #{p t} path/merge-nodes))) + (rx/empty)))))) (defn modify-content-point [content {dx :x dy :y} modifiers point] @@ -93,59 +262,184 @@ (reduce modify-handler $ handler-indices)))) (defn set-move-modifier - [points move-modifier] + "Adds a move delta for selected nodes and handlers." + [points handler-ids move-modifier] (ptk/reify ::set-modifiers ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) + (let [id (st/get-path-id state) content (st/get-path state :content) - modifiers-reducer (partial modify-content-point content move-modifier) + {dx :x dy :y} move-modifier + content-modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) - content-modifiers (->> points - (reduce modifiers-reducer content-modifiers))] + + content-modifiers + (->> points + (reduce (partial modify-content-point content move-modifier) content-modifiers)) + + content-modifiers + (->> handler-ids + (reduce (fn [modifiers [index prefix]] + (let [cx (d/prefix-keyword prefix :x) + cy (d/prefix-keyword prefix :y)] + (update modifiers index assoc cx dx cy dy))) + content-modifiers))] (-> state (assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)))))) +(defn- move-node-indices + [state node-indices from-point to-point] + (let [id (st/get-path-id state) + content (st/get-path state :content) + to-point (cond-> to-point + (:shift? to-point) (path.helpers/position-fixed-angle from-point)) + delta (gpt/subtract to-point from-point) + points (helpers/node-positions content node-indices) + reducer (partial modify-content-point content delta) + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) + modifiers (reduce reducer modifiers points)] + (-> state + (assoc-in [:workspace-local :edit-path id :moving-nodes] true) + (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)))) + (defn move-selected-path-point [from-point to-point] (ptk/reify ::move-point ptk/UpdateEvent (update [_ state] - (let [id (st/get-path-id state) - content (st/get-path state :content) - to-point (cond-> to-point - (:shift? to-point) (path.helpers/position-fixed-angle from-point)) + (let [id (st/get-path-id state) + selected-nodes (dm/get-in state + [:workspace-local :edit-path id :selection :nodes] + #{})] + (move-node-indices state selected-nodes from-point to-point))))) - delta (gpt/subtract to-point from-point) +(defn move-selected-path-segment [from-point to-point] + (ptk/reify ::move-segment + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + node-indices (helpers/selected-node-indices content selection)] + (move-node-indices state node-indices from-point to-point))))) - modifiers-reducer (partial modify-content-point content delta) +(defn- clear-drag-cursor [] + (ptk/reify ::clear-drag-cursor + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (d/update-in-when state [:workspace-local :edit-path id] dissoc :drag-cursor))))) - points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) +(defn set-drag-cursor + "Shows `cursor` until the current drag stops." + [cursor] + (ptk/reify ::set-drag-cursor + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (d/update-in-when state [:workspace-local :edit-path id] assoc :drag-cursor cursor))) - modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) - modifiers (->> points - (reduce modifiers-reducer modifiers))] - - (-> state - (assoc-in [:workspace-local :edit-path id :moving-nodes] true) - (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)))))) + ptk/WatchEvent + (watch [_ _ stream] + (->> (rx/merge + (mse/drag-stopper stream) + (rx/filter streams/finish-edition? stream)) + (rx/take 1) + (rx/map #(clear-drag-cursor)))))) (declare drag-selected-points) +(def ^:private duplicate-screen-offset 10) + +(defn duplicate-offset + "Returns a duplicate offset that stays constant in screen pixels." + [zoom] + (let [step (/ duplicate-screen-offset zoom)] + (gpt/point step step))) + +(defn splice-duplicated + "Adds duplicate subpaths and selects their new nodes." + [{:keys [sub selected]}] + (ptk/reify ::splice-duplicated + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (if (and (some? id) (seq sub)) + (let [content (st/get-path state :content) + base (count content) + new-content (path/splice-content content sub) + pasted (into #{} (map #(+ base %)) selected)] + (-> state + (st/set-content new-content) + (update-in (st/get-path-location state) path/update-geometry) + (assoc-in [:workspace-local :edit-path id :selection] + (assoc helpers/empty-selection :nodes pasted)))) + state))))) + +(defn- duplicate-and-drag + "Duplicates the selection and drags the copy from `start-position`." + [start-position] + (ptk/reify ::duplicate-and-drag + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + result (helpers/duplicate-selection-content + content selection (duplicate-offset zoom))] + (if (seq (:sub result)) + (rx/of (splice-duplicated result) + (drag-selected-points start-position)) + (rx/of (drag-selected-points start-position))))))) + +(declare curve-config-node-drag) + (defn start-move-path-point - [position shift?] + "Handles node clicks and drags in move mode." + [index shift? alt? mod?] (ptk/reify ::start-move-path-point ptk/WatchEvent (watch [_ state _] - (let [id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected? (contains? selected-points position)] - (streams/drag-stream - (rx/of - (dwsh/update-shapes [id] path/convert-to-path) - (when-not selected? (selection/select-node position shift?)) - (drag-selected-points @ms/mouse-position)) - (rx/of (selection/select-node position shift?))))))) + (let [id (st/get-path-id state) + selected-nodes (get (st/get-selection state id) :nodes #{}) + selected? (contains? selected-nodes index) + content (st/get-path state :content) + position (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index))] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/empty) + (if (some? position) + (rx/of (tools/remove-node-with-segments index)) + (rx/empty))) + + mod? + (streams/drag-stream + (rx/of (set-drag-cursor "move-handles") + (curve-config-node-drag index)) + (rx/of (tools/toggle-node-curve index))) + + alt? + (streams/drag-stream + (rx/of + (set-drag-cursor "move-copy") + (when-not selected? (selection/select-node index false)) + (duplicate-and-drag @ms/mouse-position)) + (if (some? position) + (rx/of (tools/remove-node position)) + (rx/of (selection/select-node index false)))) + + :else + (streams/drag-stream + (rx/of + (set-drag-cursor "move-move") + (when-not selected? (selection/select-node index shift?)) + (drag-selected-points @ms/mouse-position)) + (rx/of (selection/select-node index shift?)))))))) (defn drag-selected-points [start-position] @@ -156,11 +450,13 @@ id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + content (st/get-path state :content) + + selected-nodes (get (st/get-selection state id) :nodes #{}) + selected-points (helpers/node-positions content selected-nodes) start-position (apply min-key #(gpt/distance start-position %) selected-points) - content (st/get-path state :content) points (path/get-points content)] (rx/concat @@ -169,8 +465,216 @@ (streams/move-points-stream start-position selected-points) (rx/map #(move-selected-path-point start-position %)) (rx/take-until stopper)) + (rx/of (apply-content-modifiers) + (merge-dragged-on-drop))))))) + +(declare drag-selected-segments) +(declare bend-selected-segment) +(declare create-node-at-position) + +(defn start-move-path-segment + "Handles segment clicks and drags in move mode." + [index shift? alt? mod?] + (ptk/reify ::start-move-path-segment + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + zoom (dm/get-in state [:workspace-local :zoom] 1) + content (st/get-path state :content) + selection (st/get-selection state id) + selected-segments (get selection :segments #{}) + ;; Both selected endpoints also select their segment for dragging. + segment-ends (helpers/segment-node-indices content #{index}) + selected? (or (contains? selected-segments index) + (and (seq segment-ends) + (every? (get selection :nodes #{}) segment-ends))) + position @ms/mouse-position + threshold (/ helpers/segment-insert-threshold zoom)] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/empty) + (rx/of (tools/remove-segment index))) + + mod? + (let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content)) + bend? (and (some? entry) + (not= :close-path (:command (:segment entry))))] + (streams/drag-stream + (rx/of (set-drag-cursor "move-curve") + (if bend? + (bend-selected-segment index position) + (drag-selected-segments position))) + (rx/of (tools/toggle-segment-curve index)))) + + alt? + (let [insert-point (helpers/insertion-point content position threshold true)] + (streams/drag-stream + (rx/of + (set-drag-cursor "move-copy") + (when-not selected? (selection/select-segment index false)) + (duplicate-and-drag position)) + (if (some? insert-point) + (rx/of (create-node-at-position (meta insert-point))) + (rx/of (selection/select-segment index false))))) + + :else + (let [insert-point (when-not shift? + (helpers/insertion-point content position threshold false)) + click-event (if (some? insert-point) + (create-node-at-position (meta insert-point)) + (selection/select-segment index shift?))] + (streams/drag-stream + (rx/of + (set-drag-cursor "move-move") + (when-not selected? (selection/select-segment index shift?)) + (drag-selected-segments position)) + (rx/of click-event)))))))) + +(defn- segment-entry + [content index] + (d/seek #(= index (:index %)) (helpers/segment-entries content))) + +(defn drag-selected-segments + [start-position] + (ptk/reify ::drag-selected-segments + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (mse/drag-stopper stream) + id (dm/get-in state [:workspace-local :edition]) + content (st/get-path state :content) + selection (st/get-selection state id) + node-indices (helpers/selected-node-indices content selection) + selected-points (helpers/node-positions content node-indices) + points (path/get-points content)] + (if (empty? selected-points) + (rx/empty) + (rx/concat + (->> points + (streams/move-points-stream start-position selected-points) + (rx/map #(move-selected-path-segment start-position %)) + (rx/take-until stopper)) + (rx/of (apply-content-modifiers) + (merge-dragged-on-drop)))))))) + +(defn bend-segment-modifier + "Bends segment `index` so its point at `t` reaches `target`." + [index base-curve t target] + (ptk/reify ::bend-segment-modifier + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + deltas (path.helpers/bend-curve-deltas base-curve t target) + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})] + (assoc-in state [:workspace-local :edit-path id :content-modifiers] + (assoc modifiers index deltas)))))) + +(defn bend-selected-segment + [index start-position] + (ptk/reify ::bend-selected-segment + ptk/WatchEvent + (watch [_ state stream] + (let [stopper (mse/drag-stopper stream) + content (st/get-path state :content) + entry (segment-entry content index) + base-curve (path.helpers/entry->bezier entry) + ;; Keep the grabbed curve parameter fixed during the drag. + t (path.helpers/curve-closest-t base-curve start-position 0.001)] + (rx/concat + (->> ms/mouse-position + (rx/filter gpt/point?) + (rx/map streams/to-pixel-snap) + (rx/map #(bend-segment-modifier index base-curve t %)) + (rx/take-until stopper)) (rx/of (apply-content-modifiers))))))) +(defn- curve-config-modifier + "Pulls out smooth node handles toward `position`." + [node in-index in-base in-neighbour out-index out-base out-neighbour position] + (ptk/reify ::curve-config-modifier + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + v (gpt/to-vec node position) + both? (and (some? in-index) (some? out-index)) + + ;; Pick which handle follows the pointer from the drag direction. + ref (when (and (some? in-neighbour) (some? out-neighbour)) + (gpt/subtract (gpt/unit (gpt/to-vec node out-neighbour)) + (gpt/unit (gpt/to-vec node in-neighbour)))) + s (if (and both? (some? ref) (neg? (gpt/dot v ref))) -1 1) + + out-handle (if both? (gpt/add node (gpt/scale v s)) (gpt/add node v)) + in-handle (if both? (gpt/subtract node (gpt/scale v s)) (gpt/add node v)) + + modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {}) + modifiers (cond-> modifiers + (some? in-index) + (assoc in-index + {:c2x (- (:x in-handle) (:x in-base)) + :c2y (- (:y in-handle) (:y in-base))}) + + (some? out-index) + (assoc out-index + {:c1x (- (:x out-handle) (:x out-base)) + :c1y (- (:y out-handle) (:y out-base))}))] + (assoc-in state [:workspace-local :edit-path id :content-modifiers] modifiers))))) + +(defn curve-config-node-drag + "Replaces a node's handles with a smooth mirrored pair during a drag." + [index] + (ptk/reify ::curve-config-node-drag + ptk/WatchEvent + (watch [_ state stream] + (let [content (st/get-path state :content) + node (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (helpers/node-position content index)) + in-cmd (nth content index nil) + out-cmd (nth content (inc index) nil) + in? (contains? #{:line-to :curve-to} (:command in-cmd)) + out? (contains? #{:line-to :curve-to} (:command out-cmd)) + ;; New curve handles start at the node. + in-base (when in? + (if (= :curve-to (:command in-cmd)) + (path/get-handler in-cmd :c2) + node)) + out-base (when out? + (if (= :curve-to (:command out-cmd)) + (path/get-handler out-cmd :c1) + node)) + ;; Neighbours keep handles on their matching leg. + in-neighbour (when in? (helpers/node-position content (dec index))) + out-neighbour (when out? (helpers/node-position content (inc index))) + stopper (rx/merge + (mse/drag-stopper stream) + (->> stream + (rx/filter streams/finish-edition?)))] + (if (and (some? node) (or in? out?)) + (rx/concat + (->> ms/mouse-position + (rx/filter gpt/point?) + ;; Apply Shift changes without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift) + (rx/map (fn [[position shift?]] + (assoc position :shift? shift?))) + (rx/map + (fn [{:keys [x y shift?]}] + (let [position (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle node))] + (curve-config-modifier node + (when in? index) + in-base + in-neighbour + (when out? (inc index)) + out-base + out-neighbour + position)))) + (rx/take-until stopper)) + (rx/of (apply-content-modifiers))) + (rx/empty)))))) + (defn- get-displacement "Retrieve the correct displacement delta point for the provided direction speed and distances thresholds." @@ -213,7 +717,20 @@ current-move (dm/get-in state [:workspace-local :edit-path id :current-move])] ;; id can be null if we just selected the tool but we didn't start drawing (if (and id (= same-event current-move)) - (let [points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + (let [content (st/get-path state :content) + selection (st/get-selection state id) + selected-nodes (get selection :nodes #{}) + selected-segments (get selection :segments #{}) + selected-handlers (get selection :handlers #{}) + + ;; Move nodes rigidly and handlers independently. + node-indices (into selected-nodes + (helpers/segment-node-indices content selected-segments)) + points (helpers/node-positions content node-indices) + handler-ids (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth content index nil))))) + selected-handlers) move-events (->> stream (rx/filter (ptk/type? ::move-selected)) @@ -226,12 +743,11 @@ mov-vec (gpt/multiply (get-displacement direction) scale)] (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) (rx/merge (->> move-events (rx/take-until stopper) (rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0)) - (rx/map #(set-move-modifier points %))) + (rx/map #(set-move-modifier points handler-ids %))) ;; First event is not read by the stream so we need to send it again (rx/of (move-selected direction shift?))) @@ -240,53 +756,141 @@ (finish-move-selected)))) (rx/empty))))))) +(declare drag-selected-handlers) + +(defn- handler-drag-mode + "Returns the live handler matching mode for a drag." + [plain-mode mod? alt?] + (cond + (and mod? alt?) :aligned + mod? :mirror + alt? :independent + :else plain-mode)) + +(defn- handler-drag-cursor + [mod? alt?] + (if (or mod? alt?) "move-handles" "move-move")) + (defn start-move-handler - [index prefix] + "Handles handler clicks and drags in both edit modes." + [index prefix shift? alt? mod?] (ptk/reify ::start-move-handler + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + handler-id [index prefix] + content (st/get-path state :content) + selected-handlers (dm/get-in state + [:workspace-local :edit-path id :selection :handlers] + #{}) + selected? (contains? selected-handlers handler-id) + + handler-types (dm/get-in state [:workspace-local :edit-path id :handler-types] {}) + plain-mode (stored-handler-drag-mode + content handler-types index prefix)] + (cond + (and mod? alt?) + (streams/drag-stream + (rx/of (set-drag-cursor (handler-drag-cursor mod? alt?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/empty)) + + (or mod? alt?) + (streams/drag-stream + (rx/of (set-drag-cursor (handler-drag-cursor mod? alt?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/of (tools/remove-handler index prefix))) + + :else + (streams/drag-stream + (rx/of + (set-drag-cursor (handler-drag-cursor mod? alt?)) + (when-not selected? + (selection/select-handler index prefix shift?)) + (drag-selected-handlers handler-id plain-mode)) + (rx/of (selection/select-handler index prefix shift?)))))))) + +(defn drag-selected-handlers + "Drags selected handlers using the live matching mode." + [[index prefix :as primary] plain-mode] + (ptk/reify ::drag-selected-handlers ptk/WatchEvent (watch [_ state stream] - (let [id (dm/get-in state [:workspace-local :edition]) - cx (d/prefix-keyword prefix :x) - cy (d/prefix-keyword prefix :y) - - modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers]) - start-delta-x (dm/get-in modifiers [index cx] 0) - start-delta-y (dm/get-in modifiers [index cy] 0) - - content (st/get-path state :content) - points (path/get-points content) - - point (-> content (nth (if (= prefix :c1) (dec index) index)) (path.helpers/segment->point)) - handler (-> content (nth index) (path/get-handler prefix)) - + (let [id (st/get-path-id state) + content (st/get-path state :content) + points (path/get-points content) + start-modifiers (dm/get-in state + [:workspace-local :edit-path id :content-modifiers] + {}) + start-delta (handler-modifier-delta start-modifiers index prefix) + point (path/handler->node content index prefix) + handler (-> (path/get-handler-point content index prefix) + (gpt/add start-delta)) [op-idx op-prefix] (path/opposite-index content index prefix) - opposite (path/get-handler-point content op-idx op-prefix)] + opposite (when op-idx + (-> (path/get-handler-point content op-idx op-prefix) + (gpt/add (handler-modifier-delta start-modifiers + op-idx + op-prefix)))) + stopper (rx/merge + (mse/drag-stopper stream) + (->> stream + (rx/filter streams/finish-edition?))) - (streams/drag-stream - (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path)) - (->> (streams/move-handler-stream handler point handler opposite points) + handler-events (rx/share + (streams/move-handler-stream handler point handler opposite points))] + (rx/concat + (rx/merge + (->> handler-events (rx/map - (fn [{:keys [x y alt? shift?]}] - (let [pos (cond-> (gpt/point x y) - shift? (path.helpers/position-fixed-angle point))] - (modify-handler - id - index - prefix - (+ start-delta-x (- (:x pos) (:x handler))) - (+ start-delta-y (- (:y pos) (:y handler))) - (not alt?))))) - (rx/take-until - (rx/merge - (mse/drag-stopper stream) - (->> stream - (rx/filter streams/finish-edition?))))) - - (rx/concat (rx/of (apply-content-modifiers))))))))) + (fn [{:keys [x y shift? alt? mod?]}] + (let [position (cond-> (gpt/point x y) + shift? (path.helpers/position-fixed-angle point)) + delta (gpt/subtract position handler) + mode (handler-drag-mode plain-mode mod? alt?) + move-selection? (not (or mod? alt?))] + (modify-selected-handlers id + primary + start-modifiers + (:x delta) + (:y delta) + mode + move-selection?)))) + (rx/take-until stopper)) + ;; Update the cursor only when the matching mode changes. + (->> handler-events + (rx/map (fn [{:keys [alt? mod?]}] (handler-drag-cursor mod? alt?))) + (rx/pipe (rxo/distinct-contiguous)) + (rx/map set-drag-cursor) + (rx/take-until stopper))) + (rx/of (apply-content-modifiers))))))) (declare stop-path-edit) +(defn resolve-edit-fills + "Resolves the fills inherited by the editing copy. + Frames stop group fill inheritance." + [shape objects] + (let [own (svg-fills/resolve-shape-fills shape)] + (if (seq own) + own + (loop [parent-id (:parent-id shape) + visited #{}] + (cond + (nil? parent-id) [] + (visited parent-id) [] + :else + (let [parent (get objects parent-id)] + (cond + (nil? parent) [] + (cfh/group-shape? parent) (let [fills (svg-fills/resolve-shape-fills parent)] + (if (seq fills) + fills + (recur (:parent-id parent) + (conj visited parent-id)))) + (cfh/frame-shape? parent) [] + :else (recur (:parent-id parent) + (conj visited parent-id))))))))) (defn start-path-edit [id] @@ -294,10 +898,15 @@ ptk/UpdateEvent (update [_ state] (let [objects (dsh/lookup-page-objects state) - shape (get objects id)] + shape (get objects id) + shape (-> shape + (path/convert-to-path objects) + (update :content path/close-subpaths) + (path/update-geometry)) + shape (assoc shape :fills (resolve-edit-fills shape objects))] (-> state - (st/set-content (path/close-subpaths (:content shape))) + (assoc-in [:workspace-drawing :object] shape) (update-in [:workspace-local :edit-path id] (fn [state] (let [state (if state @@ -305,20 +914,20 @@ (assoc state :edit-mode :draw) state) {:edit-mode :move - :selected #{} - :snap-toggled false})] + :selection helpers/empty-selection + :hover helpers/empty-selection + :handler-types {} + :snap-toggled true})] (assoc state :old-content (:content shape)))))))) ptk/WatchEvent (watch [_ _ stream] - (let [stopper (rx/filter #(let [type (ptk/type %)] - (= type ::dwe/clear-edition-mode) - (= type ::start-path-edit)) - stream)] + (let [stopper (rx/filter (ptk/type? ::start-path-edit) stream)] (rx/concat (rx/of (undo/start-path-undo)) + ;; Finalize once on the canonical edition stop event. (->> stream - (rx/filter #(= % :interrupt)) + (rx/filter (ptk/type? ::dwe/clear-edition-mode)) (rx/take 1) (rx/map #(stop-path-edit id)) (rx/take-until stopper))))))) @@ -326,29 +935,27 @@ (defn stop-path-edit [id] (ptk/reify ::stop-path-edit - ptk/UpdateEvent - (update [_ state] - (update state :workspace-local dissoc :edit-path id)) - ptk/WatchEvent (watch [_ _ _] - (rx/of (ptk/data-event :layout/update {:ids [id]}))))) + (rx/of + (changes/finalize-path-content id) + (fn [state] + (-> state + (update-in [:workspace-local :edit-path] dissoc id) + (update :workspace-drawing dissoc :object :lock))) + (ptk/data-event :layout/update {:ids [id]}))))) (defn- split-segments - [id {:keys [from-p to-p t]}] + [_id {:keys [from-p to-p t]}] (ptk/reify ::split-segments ptk/UpdateEvent (update [_ state] (let [content (st/get-path state :content)] (-> state - (assoc-in [:workspace-local :edit-path id :old-content] content) (st/set-content (-> content (path/split-segments #{from-p to-p} t) - (path/content)))))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (changes/save-path-content {:preserve-move-to true}))))) + (path/content))) + (update-in (st/get-path-location state) path/update-geometry)))))) (defn create-node-at-position [params] @@ -356,5 +963,4 @@ ptk/WatchEvent (watch [_ state _] (let [id (st/get-path-id state)] - (rx/of (dwsh/update-shapes [id] path/convert-to-path) - (split-segments id params)))))) + (rx/of (split-segments id params)))))) diff --git a/frontend/src/app/main/data/workspace/path/helpers.cljs b/frontend/src/app/main/data/workspace/path/helpers.cljs index 1800850bf4..2557cd0e5e 100644 --- a/frontend/src/app/main/data/workspace/path/helpers.cljs +++ b/frontend/src/app/main/data/workspace/path/helpers.cljs @@ -6,7 +6,11 @@ (ns app.main.data.workspace.path.helpers (:require + [app.common.data :as d] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.intersect :as gsi] [app.common.math :as mth] [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers])) @@ -25,8 +29,27 @@ (gpt/to-vec common p1) (gpt/to-vec common p2)))) +(defn opposite-handler-target + "Returns the opposite handler target for mirror or aligned modes." + [node handler opposite mode] + (if (and (some? node) (some? handler) (some? opposite)) + (case mode + :mirror + (gpt/subtract (gpt/scale node 2) handler) + + :aligned + (let [handler-vector (gpt/to-vec node handler)] + (if (mth/almost-zero? (gpt/length handler-vector)) + opposite + (gpt/subtract node + (gpt/scale (gpt/unit handler-vector) + (gpt/distance node opposite))))) + + opposite) + opposite)) + (defn- calculate-opposite-delta [node handler opposite match-angle? match-distance? dx dy] - (when (and (some? handler) (some? opposite)) + (if (and (some? handler) (some? opposite)) (let [;; To match the angle, the angle should be matching (angle between points 180deg) angle-handlers (angle-points node handler opposite) @@ -54,30 +77,466 @@ match-distance? (gpt/scale-from node distance-scale))] [(- (:x new-opposite) (:x opposite)) - (- (:y new-opposite) (:y opposite))]))) + (- (:y new-opposite) (:y opposite))]) + ;; Leave missing opposite handles unchanged. + [0 0])) + +(defn handlers-joined? + "True when a node's handlers are collinear and opposite." + [content index prefix] + (let [[op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (when op-idx (path/get-handler-point content op-idx op-prefix))] + (boolean + (and (some? op-idx) + (some? handler) + (some? opposite) + (not= handler node) + (not= opposite node) + (<= (mth/abs (- 180 (angle-points node handler opposite))) 0.1))))) (defn move-handler-modifiers - [content index prefix match-distance? match-angle? dx dy] + ([content index prefix match-distance? match-angle? dx dy] + (move-handler-modifiers content index prefix match-distance? match-angle? false dx dy)) + ([content index prefix match-distance? match-angle? rejoin? dx dy] - (let [[cx cy] (path.helpers/prefix->coords prefix) + (let [[cx cy] (path.helpers/prefix->coords prefix) + [op-idx op-prefix] (path/opposite-index content index prefix) + + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (path/get-handler-point content op-idx op-prefix) + + [ocx ocy] (path.helpers/prefix->coords op-prefix) + [odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy) + + hnv (if (some? handler) + (gpt/to-vec node (-> handler (update :x + dx) (update :y + dy))) + (gpt/point dx dy)) + mirrored-opposite (opposite-handler-target + node (gpt/add node hnv) opposite :mirror)] + + (-> {} + (update index assoc cx dx cy dy) + + (cond-> + ;; Force an exact mirror when rejoining handlers. + (and (some? op-idx) rejoin? (not= opposite node)) + (update op-idx assoc + ocx (- (:x mirrored-opposite) (:x opposite)) + ocy (- (:y mirrored-opposite) (:y opposite))) + + (and (some? op-idx) (not rejoin?) (not= opposite node)) + (update op-idx assoc ocx odx ocy ody) + + (and (some? op-idx) (= opposite node) match-distance? match-angle?) + (update op-idx assoc + ocx (- (:x mirrored-opposite) (:x opposite)) + ocy (- (:y mirrored-opposite) (:y opposite)))))))) + +(defn align-handler-modifiers + "Moves a handler and aligns its opposite without changing its length." + [content index prefix dx dy] + (let [[cx cy] (path.helpers/prefix->coords prefix) [op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + opposite (when (some? op-idx) + (path/get-handler-point content op-idx op-prefix)) + handler (path/get-handler-point content index prefix) + modifiers (-> {} (update index assoc cx dx cy dy))] + (if (and (some? handler) (some? opposite) (not= opposite node)) + (let [moved-handler (-> handler (update :x + dx) (update :y + dy)) + handler-vector (gpt/to-vec node moved-handler) + target (opposite-handler-target node moved-handler opposite :aligned)] + (if (mth/almost-zero? (gpt/length handler-vector)) + modifiers + (let [[ocx ocy] (path.helpers/prefix->coords op-prefix)] + (update modifiers op-idx assoc + ocx (- (:x target) (:x opposite)) + ocy (- (:y target) (:y opposite)))))) + modifiers))) - node (path/handler->node content index prefix) - handler (path/get-handler-point content index prefix) - opposite (path/get-handler-point content op-idx op-prefix) +;; --- Per-node handler type (mirror / aligned / independent) - [ocx ocy] (path.helpers/prefix->coords op-prefix) - [odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy) +(defn handler-node-index + "Returns the anchor command index for a handler." + [index prefix] + (if (= prefix :c1) (dec index) index)) - hnv (if (some? handler) - (gpt/to-vec node (-> handler (update :x + dx) (update :y + dy))) - (gpt/point dx dy))] +(defn node-primary-handler + "Returns a curve handler for a node, preferring its incoming handle." + [content node-index] + (let [n (count content) + out-idx (inc node-index)] + (cond + (and (>= node-index 0) (< node-index n) + (= :curve-to (:command (nth content node-index nil)))) + [node-index :c2] - (-> {} - (update index assoc cx dx cy dy) + (and (< out-idx n) + (= :curve-to (:command (nth content out-idx nil)))) + [out-idx :c1] - (cond-> (and (some? op-idx) (not= opposite node)) - (update op-idx assoc ocx odx ocy ody) + :else nil))) - (and (some? op-idx) (= opposite node) match-distance? match-angle?) - (update op-idx assoc ocx (- (:x hnv)) ocy (- (:y hnv))))))) +(defn handlers-equal-length? + "True when a node's two handlers are the same distance from the node." + [content index prefix] + (let [[op-idx op-prefix] (path/opposite-index content index prefix) + node (path/handler->node content index prefix) + handler (path/get-handler-point content index prefix) + opposite (when op-idx (path/get-handler-point content op-idx op-prefix))] + (boolean + (and (some? handler) (some? opposite) + (mth/almost-zero? (- (gpt/distance node handler) + (gpt/distance node opposite))))))) + +(defn derive-handler-type + "Infers a node's handler type from its geometry." + [content node-index] + (if-let [[idx prefix] (node-primary-handler content node-index)] + (cond + (not (handlers-joined? content idx prefix)) :independent + (handlers-equal-length? content idx prefix) :mirror + :else :aligned) + :independent)) + +(defn remap-handler-types + "Remaps handler types by node position after structural changes." + [handler-types old-content new-content] + (let [handler-types (or handler-types {})] + (if (= (count old-content) (count new-content)) + handler-types + (let [types-by-position + (reduce-kv + (fn [result index type] + (let [segment (nth old-content index nil)] + (if (or (nil? segment) (= :close-path (:command segment))) + result + (update result + (path.helpers/segment->point segment) + (fnil conj #{}) + type)))) + {} + handler-types)] + (into {} + (keep (fn [[index segment]] + (when-not (= :close-path (:command segment)) + (let [types (get types-by-position + (path.helpers/segment->point segment))] + (when (= 1 (count types)) + [index (first types)]))))) + (d/enumerate new-content)))))) + +;; Nodes and segments use command indices. Handlers use `[index prefix]`. +;; Selection and hover use grouped index sets: +;; {:nodes #{index} :segments #{index} :handlers #{[index prefix]}} + +(def empty-selection + {:nodes #{} :segments #{} :handlers #{}}) + +(defn node? + "True when the command at the given content index is a selectable node." + [content index] + (and (number? index) + (<= 0 index) + (< index (count content)) + (not= :close-path (:command (nth content index nil))))) + +(defn node-indices + "Indices of every selectable node in the content." + [content] + (into [] + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (map first)) + (d/enumerate content))) + +(defn node-position + "Position of the node at the given content command index." + [content index] + (path.helpers/segment->point (nth content index))) + +(defn curve-node? + "True when the node at `index` has a visible curve handler." + [content index] + (when (node? content index) + (let [node (node-position content index) + incoming (when (= :curve-to (:command (nth content index nil))) + (path/get-handler-point content index :c2)) + outgoing-index (inc index) + outgoing (when (= :curve-to (:command (nth content outgoing-index nil))) + (path/get-handler-point content outgoing-index :c1))] + (boolean (some #(and (some? %) (not= node %)) [incoming outgoing]))))) + +(defn node-positions + "Set of positions for the given node indices in the content." + [content indices] + (let [indices (set indices)] + (into #{} + (comp (filter (fn [[index _]] (contains? indices index))) + (map (fn [[_ seg]] (path.helpers/segment->point seg)))) + (d/enumerate content)))) + +(defn nodes-in-rect + "Indices of the nodes whose position falls inside the given rect." + [content rect] + (into #{} + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (filter (fn [[_ seg]] (gsh/has-point-rect? rect (path.helpers/segment->point seg)))) + (map first)) + (d/enumerate content))) + +(def segment-entries + "Returns selectable path segments." + path/segment-entries) + +(defn segment-node-indices + "Unique endpoint-node indices for the selected segment command indices." + [content segment-indices] + (let [segment-indices (set segment-indices)] + (into #{} + (comp (filter #(contains? segment-indices (:index %))) + (mapcat (juxt :from-index :to-index)) + (remove nil?)) + (segment-entries content)))) + +(defn check-enabled + "Returns path actions enabled for selected node indices." + [content selected-nodes] + (when content + (let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes) + selected-segments (filter (fn [{:keys [from-index to-index]}] + (and (contains? selected-nodes from-index) + (contains? selected-nodes to-index))) + (segment-entries content)) + num-segments (count selected-segments) + num-nodes (count selected-nodes) + nodes-selected? (seq selected-nodes) + segments-selected? (seq selected-segments) + max-segments (/ (* num-nodes (dec num-nodes)) 2) + curves-selected? (some #(curve-node? content %) selected-nodes) + corners-selected? (some #(not (curve-node? content %)) selected-nodes)] + {:make-corner (and nodes-selected? curves-selected?) + :make-curve (and nodes-selected? corners-selected?) + :merge-nodes (and nodes-selected? (>= num-nodes 2)) + :join-nodes (and nodes-selected? (>= num-nodes 2) (< num-segments max-segments)) + :separate-nodes (or segments-selected? (= num-nodes 1))}))) + +(defn selected-node-indices + "Returns selected nodes plus endpoints of selected segments." + [content selection] + (into (get selection :nodes #{}) + (segment-node-indices content (get selection :segments #{})))) + +(defn selection-coordinate-rect + "Returns the bounds of selected segments, nodes, and handlers." + [content selection] + (let [segments (get selection :segments #{}) + node-indices (selected-node-indices content selection) + handlers (get selection :handlers #{}) + segment-rect (when (seq segments) + (path/calc-selrect + (path/extract-content content {:segments segments}))) + point-rect (grc/points->rect + (into (node-positions content node-indices) + (keep (fn [[index prefix]] + (path/get-handler-point content index prefix))) + handlers))] + (grc/join-rects (keep identity [segment-rect point-rect])))) + +(defn handler-target-nodes + "Returns nodes targeted by the current node and handler selection." + [content selection] + (into (selected-node-indices content selection) + (map (fn [[idx prefix]] (handler-node-index idx prefix))) + (get selection :handlers #{}))) + +(defn handler-selection-state + "Returns targeted curve nodes and their shared handler mode." + [content handler-types target-nodes] + (let [curve-nodes (into #{} (filter #(curve-node? content %)) target-nodes) + modes (into #{} + (map (fn [index] + (or (get handler-types index) + (derive-handler-type content index)))) + curve-nodes)] + {:nodes curve-nodes + :active-type (cond + (empty? modes) nil + (= 1 (count modes)) (first modes) + :else :mixed)})) + +(defn handler-trigger-action + "Returns the handler menu action for the active mode." + [active-type] + (if (= active-type :mixed) :open :select)) + +(def segment-insert-threshold + "Maximum screen distance for midpoint insertion." + 12) + +(defn segment-mid-point + "Returns a segment's arc-length midpoint with split metadata." + [{:keys [from to segment] :as entry}] + (let [curve (path.helpers/entry->bezier entry) + t (if (= :line-to (:command segment)) + 0.5 + (path.helpers/curve-arc-length-t curve))] + (with-meta (path.helpers/curve-values curve t) + {:from-p from :to-p to :t t}))) + +(defn insertion-mid-points + "Precomputes segment midpoint insertion candidates." + [content] + (into [] + (comp (remove #(= :close-path (:command (:segment %)))) + (map segment-mid-point)) + (segment-entries content))) + +(defn- closest-insertion-mid-point + [mid-points position threshold] + (some->> mid-points + (reduce + (fn [closest mid-point] + (let [distance (gpt/distance position mid-point)] + (if (and (<= distance threshold) + (or (nil? closest) + (< distance (first closest)))) + [distance mid-point] + closest))) + nil) + second)) + +(defn insertion-point + "Returns the on-path point a nearby click would insert, with split metadata." + ([content position threshold anywhere?] + (insertion-point content position threshold anywhere? nil)) + ([content position threshold anywhere? mid-points] + (if anywhere? + (let [point (path/closest-point content position 0.01)] + (when (and (some? point) (<= (gpt/distance position point) threshold)) + point)) + (closest-insertion-mid-point + (or mid-points (insertion-mid-points content)) position threshold)))) + +(defn- segment-lines + [{:keys [from to segment]}] + (if (= :curve-to (:command segment)) + (path.helpers/curve->lines from + to + (path/get-handler segment :c1) + (path/get-handler segment :c2)) + [[from to]])) + +(defn segments-in-rect + "Returns segments that cross or fall inside `rect`." + [content rect] + (let [rect-lines (gsi/points->lines (grc/rect->points rect))] + (into #{} + (comp + (filter + (fn [entry] + (let [lines (segment-lines entry)] + (or (some (fn [[from to]] + (or (grc/contains-point? rect from) + (grc/contains-point? rect to))) + lines) + (gsi/intersects-lines? rect-lines lines))))) + (map :index)) + (segment-entries content)))) + +(defn handler-entries + "Visible path handlers as `{:identity [index prefix] :point p}` entries." + [content] + (into [] + (comp + (mapcat + (fn [[index segment]] + (when (= :curve-to (:command segment)) + (keep + (fn [prefix] + (let [handler (path/get-handler-point content index prefix) + node (path/handler->node content index prefix)] + (when (and handler (not= handler node)) + {:identity [index prefix] + :point handler}))) + [:c1 :c2]))))) + (d/enumerate content))) + +(defn handlers-in-rect + "Identities of visible path handlers whose control point is inside `rect`." + [content rect] + (into #{} + (comp (filter #(grc/contains-point? rect (:point %))) + (map :identity)) + (handler-entries content))) + +(defn remap-selected-nodes + "Remaps selected nodes by position after structural changes." + [selected-nodes old-content new-content] + (if (empty? selected-nodes) + selected-nodes + (let [positions (node-positions old-content selected-nodes)] + (into #{} + (comp (remove (fn [[_ seg]] (= :close-path (:command seg)))) + (filter (fn [[_ seg]] (contains? positions (path.helpers/segment->point seg)))) + (map first)) + (d/enumerate new-content))))) + +(defn- fragment-covered-nodes + "Returns nodes already included in a duplicated segment fragment." + [content {:keys [nodes segments]}] + (let [nodes (or nodes #{}) + segments (or segments #{})] + (into #{} + (comp (filter (fn [{:keys [index from-index to-index]}] + (or (contains? segments index) + (and (contains? nodes from-index) + (contains? nodes to-index))))) + (mapcat (juxt :from-index :to-index))) + (segment-entries content)))) + +(defn duplicate-selection-content + "Duplicates selected nodes and segments for splicing as new subpaths." + [content selection offset] + (let [fragment (path/extract-content content selection) + fragment (cond-> fragment + (and (seq fragment) (some? offset)) + (path/move-content offset)) + fragment (vec fragment) + covered (fragment-covered-nodes content selection) + free (sort (remove covered (get selection :nodes #{})))] + (reduce (fn [{:keys [sub selected]} node-index] + (if-let [{ext :content ext-selected :selected} + (path/duplicate-node-content content node-index offset)] + (let [start (count sub)] + {:sub (into sub ext) + :selected (into selected (map #(+ start %)) ext-selected)}) + {:sub sub :selected selected})) + {:sub fragment :selected (set (node-indices fragment))} + free))) + +(defn remap-selection + "Remaps a grouped selection after path content changes." + [selection old-content new-content] + (let [selection (or selection empty-selection)] + (if (= (count old-content) (count new-content)) + (-> selection + (update :handlers + (fn [handlers] + (into #{} + (filter (fn [[index _]] + (= :curve-to (:command (nth new-content index nil))))) + handlers))) + ;; Drop indices that became subpath breaks. + (update :segments + (fn [segments] + (into #{} + (remove (fn [index] + (= :move-to (:command (nth new-content index nil))))) + segments)))) + (assoc empty-selection + :nodes (remap-selected-nodes (get selection :nodes #{}) + old-content + new-content))))) diff --git a/frontend/src/app/main/data/workspace/path/selection.cljs b/frontend/src/app/main/data/workspace/path/selection.cljs index 0de46d1049..998c3d1783 100644 --- a/frontend/src/app/main/data/workspace/path/selection.cljs +++ b/frontend/src/app/main/data/workspace/path/selection.cljs @@ -7,9 +7,8 @@ (ns app.main.data.workspace.path.selection (:require [app.common.data.macros :as dm] - [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] - [app.common.geom.shapes :as gsh] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.streams :as ms] [app.util.mouse :as mse] @@ -17,84 +16,140 @@ [beicon.v2.operators :as rxo] [potok.v2.core :as ptk])) -(defn path-pointer-enter [position] +(defn path-pointer-enter [index] (ptk/reify ::path-pointer-enter ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-points] (fnil conj #{}) position))))) + (update-in state [:workspace-local :edit-path id :hover :nodes] (fnil conj #{}) index))))) -(defn path-pointer-leave [position] +(defn path-pointer-leave [index] (ptk/reify ::path-pointer-leave ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-points] disj position))))) + (update-in state [:workspace-local :edit-path id :hover :nodes] disj index))))) (defn path-handler-enter [index prefix] (ptk/reify ::path-handler-enter ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-handlers] (fnil conj #{}) [index prefix]))))) + (update-in state [:workspace-local :edit-path id :hover :handlers] (fnil conj #{}) [index prefix]))))) (defn path-handler-leave [index prefix] (ptk/reify ::path-handler-leave ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (update-in state [:workspace-local :edit-path id :hover-handlers] disj [index prefix]))))) + (update-in state [:workspace-local :edit-path id :hover :handlers] disj [index prefix]))))) -(defn select-node-area - [initial-set remove?] - (ptk/reify ::select-node-area +(defn path-segment-enter [index] + (ptk/reify ::path-segment-enter ptk/UpdateEvent (update [_ state] - (let [selrect (dm/get-in state [:workspace-local :selrect]) - id (dm/get-in state [:workspace-local :edition]) - content (st/get-path state :content) + (let [id (st/get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover :segments] (fnil conj #{}) index))))) - selected-point? (if (some? selrect) - (partial gsh/has-point-rect? selrect) - (constantly false)) +(defn path-segment-leave [index] + (ptk/reify ::path-segment-leave + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state)] + (update-in state [:workspace-local :edit-path id :hover :segments] disj index))))) - xform (comp (filter #(not (= (:command %) :close-path))) - (map (comp gpt/point :params)) - (filter selected-point?)) - positions (if remove? - (apply disj initial-set (into #{} xform content)) - (into initial-set xform content))] +(defn- select-element + [state type identity shift?] + (let [id (dm/get-in state [:workspace-local :edition]) + selection (or (st/get-selection state id) helpers/empty-selection) + selected (get selection type #{}) + selection (cond + (and shift? (contains? selected identity)) + (update selection type disj identity) - (cond-> state - (some? id) - (assoc-in [:workspace-local :edit-path id :selected-points] positions)))))) + shift? + (update selection type (fnil conj #{}) identity) -(defn select-node [position shift?] + :else + (assoc helpers/empty-selection type #{identity}))] + (cond-> state + (some? id) + (assoc-in [:workspace-local :edit-path id :selection] selection)))) + +(defn select-node [index shift?] (ptk/reify ::select-node ptk/UpdateEvent (update [_ state] - (let [id (dm/get-in state [:workspace-local :edition]) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected-points (cond - (and shift? (contains? selected-points position)) - (disj selected-points position) + (select-element state :nodes index shift?)))) - shift? - (conj selected-points position) +(defn select-segment [index shift?] + (ptk/reify ::select-segment + ptk/UpdateEvent + (update [_ state] + (select-element state :segments index shift?)))) - :else - #{position})] - (cond-> state - (some? id) - (assoc-in [:workspace-local :edit-path id :selected-points] selected-points)))))) +(defn select-handler [index prefix shift?] + (ptk/reify ::select-handler + ptk/UpdateEvent + (update [_ state] + (select-element state :handlers [index prefix] shift?)))) + +(defn- update-area-set + [initial-set in-rect remove?] + (if remove? + (apply disj initial-set in-rect) + (into initial-set in-rect))) + +(defn select-path-area + [rect initial-selection remove?] + (ptk/reify ::select-path-area + ptk/UpdateEvent + (update [_ state] + (if-not (grc/rect? rect) + state + (let [id (dm/get-in state [:workspace-local :edition]) + content (st/get-path state :content) + + ;; Marquee priority is nodes, segments, then handlers. + nodes (helpers/nodes-in-rect content rect) + segments (if (empty? nodes) + (helpers/segments-in-rect content rect) + #{}) + handlers (if (and (empty? nodes) (empty? segments)) + (helpers/handlers-in-rect content rect) + #{}) + in-rect {:nodes nodes + :segments segments + :handlers handlers} + selection + (reduce-kv + (fn [selection type identities] + (assoc selection type + (update-area-set (get initial-selection type #{}) + identities + remove?))) + helpers/empty-selection + in-rect)] + (cond-> state + (some? id) + (assoc-in [:workspace-local :edit-path id :selection] selection))))))) (defn deselect-all [] (ptk/reify ::deselect-all ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state)] - (-> state - (assoc-in [:workspace-local :edit-path id :selected-points] #{})))))) + (assoc-in state [:workspace-local :edit-path id :selection] helpers/empty-selection))))) + +(defn select-all-nodes [] + (ptk/reify ::select-all-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (assoc helpers/empty-selection + :nodes (into #{} (helpers/node-indices content)))] + (assoc-in state [:workspace-local :edit-path id :selection] selection))))) (defn update-area-selection [rect] @@ -123,10 +178,10 @@ stopper (mse/drag-stopper stream) from-p @ms/mouse-position - initial-set + initial-selection (if (or append? remove?) - (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - #{}) + (or (st/get-selection state id) helpers/empty-selection) + helpers/empty-selection) selrect-stream (->> ms/mouse-position @@ -141,20 +196,11 @@ (rx/merge (->> selrect-stream (rx/map update-area-selection)) + ;; Limit path hit-testing to once per animation frame. (->> selrect-stream - (rx/buffer-time 100) + (rx/buffer-time 16) (rx/map last) + (rx/filter some?) (rx/pipe (rxo/distinct-contiguous)) - (rx/map #(select-node-area initial-set remove?)))) + (rx/map #(select-path-area % initial-selection remove?)))) (rx/of (clear-area-selection)))))))) - -(defn update-selection - [point-change] - (ptk/reify ::update-selection - ptk/UpdateEvent - (update [_ state] - (let [id (st/get-path-id state) - selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) - selected-points (into #{} (map point-change) selected-points)] - (-> state - (assoc-in [:workspace-local :edit-path id :selected-points] selected-points)))))) diff --git a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs index 7dfb6f4c7e..7ea9afe315 100644 --- a/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs +++ b/frontend/src/app/main/data/workspace/path/shapes_to_path.cljs @@ -9,6 +9,7 @@ [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cph] + [app.common.geom.matrix :as gmt] [app.common.geom.shapes :as gsh] [app.common.types.container :as ctn] [app.common.types.path :as path] @@ -28,6 +29,18 @@ :rx :ry :r1 :r2 :r3 :r4 :metadata]) +(defn- flatten-path + "Resets a path to axis-aligned geometry." + [shape] + (-> shape + (assoc :rotation 0 + :flip-x false + :flip-y false + :transform (gmt/matrix) + :transform-inverse (gmt/matrix)) + (dissoc :selrect :points) + (path/update-geometry))) + (defn convert-selected-to-path ([] (convert-selected-to-path nil)) @@ -53,18 +66,21 @@ (pcb/update-shapes selected (fn [shape] - (let [content (wasm.api/shape-to-path (:id shape))] - (-> shape - (assoc :type :path) - (cond-> (cph/text-shape? shape) - (assoc :fills - (->> (txt/node-seq txt/is-text-node? (:content shape)) - (map :fills) - (first)))) - (cond-> (cph/image-shape? shape) - (assoc :fill-image (get shape :metadata))) - (d/without-keys dissoc-attrs) - (path/update-geometry content))))) + ;; Keep path content in world coordinates. + (if (cph/path-shape? shape) + (flatten-path shape) + (let [content (wasm.api/shape-to-path (:id shape))] + (-> shape + (assoc :type :path) + (cond-> (cph/text-shape? shape) + (assoc :fills + (->> (txt/node-seq txt/is-text-node? (:content shape)) + (map :fills) + (first)))) + (cond-> (cph/image-shape? shape) + (assoc :fill-image (get shape :metadata))) + (d/without-keys dissoc-attrs) + (path/update-geometry content)))))) (pcb/remove-objects children-ids))] (rx/of (dch/commit-changes changes))) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index af910e3cd8..45bc319e27 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -9,6 +9,8 @@ [app.main.data.shortcuts :as ds] [app.main.data.workspace :as dw] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.common :as drp.common] + [app.main.data.workspace.path.state :as drp.state] [app.main.store :as st] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -19,12 +21,24 @@ ;; Shortcuts format https://github.com/ccampbell/mousetrap -(defn esc-pressed [] +(defn esc-pressed + "Maps Escape to finish, cancel, or exit for the current draw state." + [] (ptk/reify ::esc-pressed ptk/WatchEvent - (watch [_ _ _] - ;; Not interrupt when we're editing a path - (rx/of :interrupt)))) + (watch [_ state _] + (let [id (drp.state/get-path-id state) + pending? (some? (get-in state [:workspace-local :edit-path id :last-point])) + edition (get-in state [:workspace-local :edition])] + (cond + (and pending? (nil? edition)) + (rx/of (drp.common/finish-path)) + + pending? + (rx/of (drp.common/cancel-pending-segment)) + + :else + (rx/of :interrupt)))))) (def shortcuts {:move-nodes {:tooltip "M" @@ -51,8 +65,12 @@ :subsections [:path-editor] :section [:workspace] :overwrite true - :fn #(st/emit! - (drp/remove-node))} + :fn #(st/emit! (drp/delete-selected))} + + :delete-node-and-segments {:tooltip (ds/shift (ds/supr)) + :command ["shift+del" "shift+backspace"] + :subsections [:path-editor] + :fn #(st/emit! (drp/delete-selected-with-segments))} :merge-nodes {:tooltip (ds/meta "J") :command (ds/c-mod "j") @@ -93,6 +111,46 @@ :section [:workspace] :fn #(st/emit! (drp/toggle-snap))} + :copy {:tooltip (ds/meta "C") + :command (ds/c-mod "c") + :subsections [:path-editor] + :fn #(st/emit! (drp/copy-selected-nodes))} + + :cut {:tooltip (ds/meta "X") + :command (ds/c-mod "x") + :subsections [:path-editor] + :fn #(st/emit! (drp/cut-selected-nodes))} + + :paste {:tooltip (ds/meta "V") + :command (ds/c-mod "v") + :subsections [:path-editor] + :fn #(st/emit! (drp/paste-nodes))} + + :duplicate {:tooltip (ds/meta "D") + :command (ds/c-mod "d") + :subsections [:path-editor] + :fn #(st/emit! (drp/duplicate-selected))} + + :select-all {:tooltip (ds/meta "A") + :command (ds/c-mod "a") + :subsections [:path-editor] + :fn #(st/emit! (drp/select-all-nodes))} + + :deselect-all {:tooltip (ds/meta (ds/shift "A")) + :command (ds/c-mod "shift+a") + :subsections [:path-editor] + :fn #(st/emit! (drp/deselect-all))} + + :flip-horizontal {:tooltip (ds/shift "H") + :command "shift+h" + :subsections [:path-editor] + :fn #(st/emit! (drp/flip-nodes :horizontal))} + + :flip-vertical {:tooltip (ds/shift "V") + :command "shift+v" + :subsections [:path-editor] + :fn #(st/emit! (drp/flip-nodes :vertical))} + :escape {:tooltip (ds/esc) :command ["escape" "v"] :section [:workspace] diff --git a/frontend/src/app/main/data/workspace/path/state.cljs b/frontend/src/app/main/data/workspace/path/state.cljs index af1878f790..7b1dcbda75 100644 --- a/frontend/src/app/main/data/workspace/path/state.cljs +++ b/frontend/src/app/main/data/workspace/path/state.cljs @@ -10,18 +10,50 @@ [app.common.types.path.shape-to-path :as stp])) (defn get-path-id - "Retrieves the currently editing path id" + "Returns the active path id. + + The drawing copy is preferred because it also exists during initial path + creation, before workspace edition has an id. The edition id is the fallback + while an existing path's drawing copy is being established." [state] - (or (dm/get-in state [:workspace-local :edition]) - (dm/get-in state [:workspace-drawing :object :id]))) + (or (dm/get-in state [:workspace-drawing :object :id]) + (dm/get-in state [:workspace-local :edition]))) + +(defn get-selection + "Returns the grouped selection for the active path or the supplied path id." + ([state] + (get-selection state (get-path-id state))) + ([state id] + (dm/get-in state [:workspace-local :edit-path id :selection]))) + +(defn current-edit-state + ([state] + (current-edit-state (dm/get-in state [:workspace-local :edit-path]) + (dm/get-in state [:workspace-local :edition]))) + ([edit-path id] + (get edit-path id))) + +(defn editing? + ([state] + (some? (current-edit-state state))) + ([edit-path id] + (some? (current-edit-state edit-path id)))) + +(defn drawing? + ([state] + (let [edition (dm/get-in state [:workspace-local :edition]) + edit-path (dm/get-in state [:workspace-local :edit-path])] + (and (nil? edition) + (some? (get edit-path (get-path-id state)))))) + ([edit-state edition drawing-tool drawing-object] + (or (= :draw (:edit-mode edit-state)) + (and (nil? edition) + (= :path (:type drawing-object)) + (not= :curve drawing-tool))))) (defn get-path-location - [state & ks] - (if-let [edit-id (dm/get-in state [:workspace-local :edition])] - (let [page-id (:current-page-id state) - file-id (:current-file-id state)] - (into [:files file-id :data :pages-index page-id :objects edit-id] ks)) - (into [:workspace-drawing :object] ks))) + [_state & ks] + (into [:workspace-drawing :object] ks)) (defn get-path "Retrieves the location of the path object and additionally can pass diff --git a/frontend/src/app/main/data/workspace/path/streams.cljs b/frontend/src/app/main/data/workspace/path/streams.cljs index e567763d45..94b5ef4a41 100644 --- a/frontend/src/app/main/data/workspace/path/streams.cljs +++ b/frontend/src/app/main/data/workspace/path/streams.cljs @@ -9,6 +9,7 @@ [app.common.data.macros :as dm] [app.common.geom.point :as gpt] [app.common.types.path :as path] + [app.main.data.workspace.edition :as-alias dwe] [app.main.data.workspace.path.state :as pst] [app.main.snap :as snap] [app.main.store :as st] @@ -20,24 +21,30 @@ (defonce drag-threshold 5) +(def ^:private half-pixel-snap-zoom + "Zoom threshold for half-pixel snapping." + 3) + (defn dragging? [start zoom] (fn [current] (>= (gpt/distance start current) (/ drag-threshold zoom)))) -(defn finish-edition? [event] - (= (ptk/type event) :app.main.data.workspace.common/clear-edition-mode)) +(defn finish-edition? + "True for the path edition stop event." + [event] + (= (ptk/type event) ::dwe/clear-edition-mode)) (defn to-pixel-snap [position] (let [layout (get @st/state :workspace-layout) - snap-pixel? (contains? layout :snap-pixel-grid)] + snap-pixel? (contains? layout :snap-pixel-grid) + zoom (get-in @st/state [:workspace-local :zoom] 1)] (cond (or (not snap-pixel?) (not (gpt/point? position))) position - :else - (gpt/round position)))) + (gpt/round-step position (if (> zoom half-pixel-snap-zoom) 0.5 1))))) (defn drag-stream ([to-stream] @@ -79,13 +86,71 @@ (-> (l/derived get-snap st/state) (rx/from-atom {:emit-current-value? true})))) +(def ^:private node-merge-snap-distance + "Maximum screen distance for node merge snapping." + 10) + +(def ^:private neighboring-cell-offsets + [[-1 -1] [-1 0] [-1 1] + [0 -1] [0 0] [0 1] + [1 -1] [1 0] [1 1]]) + +(defn- point-cell + [point cell-size] + [(js/Math.floor (/ (:x point) cell-size)) + (js/Math.floor (/ (:y point) cell-size))]) + +(defn make-node-merge-snap + "Builds a stationary-node index and returns its merge snap function." + [start-point selected-points points max-distance] + (let [selected-points (set selected-points) + point-index (reduce + (fn [index point] + (if (contains? selected-points point) + index + (update index (point-cell point max-distance) (fnil conj []) point))) + {} + points) + closest-target (fn [closest moved-point] + (let [[cell-x cell-y] (point-cell moved-point max-distance)] + (reduce + (fn [closest [offset-x offset-y]] + (reduce + (fn [closest target] + (let [distance (gpt/distance moved-point target)] + (if (and (<= distance max-distance) + (or (nil? closest) + (< distance (first closest)))) + [distance (gpt/subtract target moved-point)] + closest))) + closest + (get point-index [(+ cell-x offset-x) (+ cell-y offset-y)] []))) + closest + neighboring-cell-offsets)))] + (fn [position] + (let [delta (gpt/subtract position start-point) + closest (reduce + (fn [closest selected-point] + (closest-target closest (gpt/add selected-point delta))) + nil + selected-points)] + (when (some? closest) + (gpt/add position (second closest))))))) + (defn move-points-stream [start-point selected-points points] (let [zoom (get-in @st/state [:workspace-local :zoom] 1) + snap-pixel? (contains? (get @st/state :workspace-layout) :snap-pixel-grid) ranges (snap/create-ranges points selected-points) d-pos (/ snap/snap-path-accuracy zoom) + ;; Build the merge index once per pixel-snapped gesture. + merge-distance (/ node-merge-snap-distance zoom) + node-merge-snap (when snap-pixel? + (make-node-merge-snap + start-point selected-points points merge-distance)) + check-path-snap (fn [[position snap-toggled]] (if snap-toggled @@ -93,16 +158,23 @@ moved-points (->> selected-points (mapv #(gpt/add % delta))) snap (snap/get-snap-delta moved-points ranges d-pos)] (gpt/add position snap)) + position)) + + ;; Node merge snapping takes priority over the pixel grid. + snap-position + (fn [[position snap-toggled]] + (if (gpt/point? position) + (or (when node-merge-snap + (node-merge-snap position)) + (check-path-snap [(to-pixel-snap position) snap-toggled])) position))] (->> ms/mouse-position - (rx/map to-pixel-snap) (rx/with-latest-from (snap-toggled-stream)) - (rx/map check-path-snap) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt)))) + (rx/map snap-position) + ;; Apply keyboard modifiers without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt) + (rx/map (fn [[position shift? alt?]] + (assoc position :shift? shift? :alt? alt?)))))) (defn get-angle [node handler opposite] (when (and (some? node) (some? handler) (some? opposite)) @@ -144,13 +216,13 @@ (merge position (gpt/add position snap))))) position))] + ;; Keep handler movement off the pixel grid. (->> ms/mouse-position - (rx/map to-pixel-snap) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt) + (rx/filter gpt/point?) + ;; Apply keyboard modifiers without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt ms/keyboard-mod) + (rx/map (fn [[position shift? alt? mod?]] + (assoc position :shift? shift? :alt? alt? :mod? mod?))) (rx/with-latest-from (snap-toggled-stream)) (rx/map check-path-snap)))) @@ -171,6 +243,8 @@ (rx/map snap/create-ranges))] (->> ms/mouse-position + ;; The subject can hold nil until the pointer enters the viewport + (rx/filter gpt/point?) (rx/map to-pixel-snap) (rx/with-latest-from ranges-stream (snap-toggled-stream)) (rx/map (fn [[position ranges snap-toggled]] @@ -178,8 +252,7 @@ (let [snap (snap/get-snap-delta [position] ranges d-pos)] (gpt/add position snap)) position))) - (rx/with-latest-from - (fn [position shift? alt?] - (assoc position :shift? shift? :alt? alt?)) - ms/mouse-position-shift - ms/mouse-position-alt)))) + ;; Apply Shift without waiting for pointer movement. + (rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt) + (rx/map (fn [[position shift? alt?]] + (assoc position :shift? shift? :alt? alt?)))))) diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index 62ea5eed39..271634bc7a 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -6,50 +6,56 @@ (ns app.main.data.workspace.path.tools (:require + [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.point :as gpt] [app.common.types.path :as path] - [app.main.data.changes :as dch] - [app.main.data.helpers :as dsh] [app.main.data.workspace.edition :as dwe] - [app.main.data.workspace.path.changes :as changes] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] - [app.main.data.workspace.shapes :as dwsh] + [app.main.store :as store] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (defn process-path-tool - "Generic function that executes path transformations with the content and selected nodes" + "Runs a position-based path tool and remaps the selection." ([tool-fn] (process-path-tool nil tool-fn)) ([points tool-fn] (ptk/reify ::process-path-tool - ptk/WatchEvent - (watch [it state _] - (let [page-id (get state :current-page-id) - objects (dsh/lookup-page-objects state page-id) + ptk/UpdateEvent + (update [_ state] + (let [shape (st/get-path state) + id (st/get-path-id state) - shape (st/get-path state) - id (st/get-path-id state) + old-content (:content shape) - selected-points - (dm/get-in state [:workspace-local :edit-path id :selected-points] #{}) + ;; Segment selections include their endpoint nodes. + selected-nodes + (helpers/selected-node-indices + old-content + (st/get-selection state id)) points - (or points selected-points)] + (or points (helpers/node-positions old-content selected-nodes))] - (when (and (seq points) (some? shape)) + (if (and (seq points) (some? shape)) (let [new-content - (-> (tool-fn (:content shape) points) - (path/close-subpaths)) + (-> (tool-fn old-content points) + (path/close-subpaths))] + (-> (cond-> (st/set-content state new-content) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry)) + (update-in [:workspace-local :edit-path id :selection] + #(helpers/remap-selection % old-content new-content)) + (update-in [:workspace-local :edit-path id :handler-types] + #(helpers/remap-handler-types % old-content new-content)))) + state))) - changes - (changes/generate-path-changes it objects page-id shape (:content shape) new-content)] - - (rx/concat - (rx/of (dwsh/update-shapes [id] path/convert-to-path) - (dch/commit-changes changes)) - (when (empty? new-content) - (rx/of (dwe/clear-edition-mode))))))))))) + ptk/WatchEvent + (watch [_ state _] + (when (empty? (st/get-path state :content)) + (rx/of (dwe/clear-edition-mode))))))) (defn make-corner ([] @@ -58,7 +64,9 @@ (process-path-tool (when point #{point}) (fn [content points] - (reduce path/make-corner-point content points))))) + (->> points + (filter #(path/is-curve-point? content %)) + (reduce path/make-corner-point content)))))) (defn make-curve ([] @@ -67,13 +75,154 @@ (process-path-tool (when point #{point}) (fn [content points] - (reduce path/make-curve-point content points))))) + (->> points + (remove #(path/is-curve-point? content %)) + (reduce path/make-curve-point content)))))) + +(defn- apply-handler-type-modifiers + "Returns modifiers that reshape a node's handlers to `type`." + [content node-index type] + (if-let [[idx prefix] (helpers/node-primary-handler content node-index)] + (case type + :mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0) + :aligned (helpers/align-handler-modifiers content idx prefix 0 0) + {}) + {})) + +(defn set-handler-type + "Sets and stores the handler behavior of selected nodes." + [type] + (ptk/reify ::set-handler-type + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + nodes (helpers/handler-target-nodes content selection)] + (if (and (some? content) (seq nodes)) + (let [modifiers (reduce (fn [acc node-index] + (d/deep-merge acc (apply-handler-type-modifiers content node-index type))) + {} nodes) + new-content (path/apply-content-modifiers content modifiers)] + (-> (st/set-content state new-content) + (update-in (st/get-path-location state) path/update-geometry) + (update-in [:workspace-local :edit-path id :handler-types] + (fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes))))) + state))))) (defn add-node [] (process-path-tool (fn [content points] (path/split-segments content points 0.5)))) -(defn remove-node [] - (process-path-tool path/remove-nodes)) +(defn remove-node + "Removes nodes and heals the gap with a fitted curve." + ([] + (process-path-tool path/remove-nodes)) + ([point] + (process-path-tool #{point} path/remove-nodes))) + +(defn toggle-node-curve + "Toggles a node between a corner and a curve." + [index] + (ptk/reify ::toggle-node-curve + ptk/WatchEvent + (watch [_ state _] + (let [content (st/get-path state :content)] + (when (and (some? content) + (< index (count content)) + (helpers/node? content index)) + (let [point (helpers/node-position content index)] + (rx/of (if (path/is-curve-point? content point) + (make-corner point) + (make-curve point))))))))) + +(defn- update-path-content + "Updates path content, geometry, selection, and handler types." + [state new-content] + (let [id (st/get-path-id state) + old-content (st/get-path state :content)] + (-> (cond-> (st/set-content state new-content) + (seq new-content) + (update-in (st/get-path-location state) path/update-geometry)) + (update-in [:workspace-local :edit-path id :selection] + #(helpers/remap-selection % old-content new-content)) + (update-in [:workspace-local :edit-path id :handler-types] + #(helpers/remap-handler-types % old-content new-content))))) + +(defn remove-segments + "Removes segments and opens the path at their endpoints." + [indices] + (ptk/reify ::remove-segments + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (and (some? content) (seq indices)) + (update-path-content state (path/remove-segments content indices)) + state))) + + ptk/WatchEvent + (watch [_ state _] + (when (empty? (st/get-path state :content)) + (rx/of (dwe/clear-edition-mode)))))) + +(defn remove-segment + [index] + (remove-segments #{index})) + +(defn remove-node-with-segments + "Removes a node and its incident segments without healing the gap." + [index] + (ptk/reify ::remove-node-with-segments + ptk/WatchEvent + (watch [_ state _] + (let [content (st/get-path state :content) + incident (into #{} + (comp (filter #(or (= index (:to-index %)) + (= index (:from-index %)))) + (map :index)) + (helpers/segment-entries content))] + (when (seq incident) + (rx/of (remove-segments incident))))))) + +(defn delete-selected-with-segments + "Removes selected nodes and their incident segments without healing." + [] + (ptk/reify ::delete-selected-with-segments + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (helpers/selected-node-indices + content + (st/get-selection state id)) + incident (into #{} + (comp (filter #(or (contains? selected (:to-index %)) + (contains? selected (:from-index %)))) + (map :index)) + (helpers/segment-entries content))] + (when (seq incident) + (rx/of (remove-segments incident))))))) + +(defn toggle-segment-curve + "Toggles a segment between a line and a curve." + [index] + (ptk/reify ::toggle-segment-curve + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (some? content) + (update-path-content state (path/toggle-segment-curve content index)) + state))))) + +(defn remove-handler + "Collapses one handler onto its node." + [index prefix] + (ptk/reify ::remove-handler + ptk/UpdateEvent + (update [_ state] + (let [content (st/get-path state :content)] + (if (some? content) + (update-path-content state (path/collapse-handler content index prefix)) + state))))) (defn merge-nodes [] (process-path-tool path/merge-nodes)) @@ -81,8 +230,157 @@ (defn join-nodes [] (process-path-tool path/join-nodes)) +(def ^:private separate-node-screen-offset + "Screen offset between separated node ends." + 8) + (defn separate-nodes [] - (process-path-tool path/separate-nodes)) + ;; Keep the visible gap stable across zoom levels. + (let [zoom (get-in @store/state [:workspace-local :zoom] 1) + step (/ separate-node-screen-offset zoom) + offset (gpt/point step step)] + (process-path-tool + (fn [content points] + (path/separate-nodes content points offset))))) + +(defn delete-selected + "Heals selected nodes or opens selected segments." + [] + (ptk/reify ::delete-selected + ptk/WatchEvent + (watch [_ state _] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + nodes (get selection :nodes #{}) + segments (get selection :segments #{})] + (rx/of + (cond + ;; Node selection takes priority in mixed selections. + (seq nodes) + (process-path-tool (helpers/node-positions content nodes) path/remove-nodes) + + ;; Segment-only selection opens the path. + (seq segments) + (separate-nodes) + + :else + (remove-node))))))) + +(defn flip-nodes + "Flips selected nodes, or the whole path when none are selected." + [axis] + (ptk/reify ::flip-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (helpers/selected-node-indices + content + (st/get-selection state id)) + indices (if (seq selected) + selected + (helpers/node-indices content)) + content (path/flip-content content indices axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn align-nodes + "Aligns selected nodes and their handles within their bounds." + [axis] + (ptk/reify ::align-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (get (st/get-selection state id) :nodes #{}) + content (path/align-content content selected axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn distribute-nodes + "Distributes selected nodes evenly along `axis`." + [axis] + (ptk/reify ::distribute-nodes + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selected (get (st/get-selection state id) :nodes #{}) + content (path/distribute-content content selected axis)] + (-> (st/set-content state content) + (update-in (st/get-path-location state) path/update-geometry)))))) + +(defn- axis-point + "Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`." + [p axis value] + (if (= axis :x) (gpt/point value (:y p)) (gpt/point (:x p) value))) + +(defn- handler-target-points + "Returns handler targets for an absolute coordinate edit." + [content handlers handler-types axis value] + (reduce + (fn [pts [index prefix]] + (let [hp (path/get-handler-point content index prefix) + hp' (axis-point hp axis value) + node-index (helpers/handler-node-index index prefix) + mode (or (get handler-types node-index) + (helpers/derive-handler-type content node-index)) + [op-idx op-prefix] (path/opposite-index content index prefix) + pts (assoc pts [index prefix] hp')] + (if (and (contains? #{:mirror :aligned} mode) (some? op-idx)) + (let [node (path/handler->node content index prefix) + opp (path/get-handler-point content op-idx op-prefix) + opp' (helpers/opposite-handler-target node hp' opp mode)] + (assoc pts [op-idx op-prefix] opp')) + pts))) + {} + handlers)) + +(defn- translated-handler-target-points + "Returns standalone handler targets for a group translation." + [content handlers node-indices delta] + (into {} + (comp + (remove (fn [[index prefix]] + (contains? node-indices + (helpers/handler-node-index index prefix)))) + (keep (fn [[index prefix :as identity]] + (when-let [point (path/get-handler-point content index prefix)] + [identity (gpt/add point delta)])))) + handlers)) + +(defn set-selection-coordinate + "Sets one coordinate of the current path selection." + [axis value] + (ptk/reify ::set-selection-coordinate + ptk/UpdateEvent + (update [_ state] + (let [id (st/get-path-id state) + content (st/get-path state :content) + selection (st/get-selection state id) + htypes (dm/get-in state [:workspace-local :edit-path id :handler-types]) + segments (get selection :segments #{}) + handlers (get selection :handlers #{}) + node-idx (helpers/selected-node-indices content selection) + + new-content + (if (seq segments) + ;; Translate segment selections as one group. + (let [rect (helpers/selection-coordinate-rect content selection) + cur (if (= axis :x) (dm/get-prop rect :x) (dm/get-prop rect :y)) + delta (axis-point (gpt/point 0 0) axis (- value cur)) + htargets (translated-handler-target-points + content handlers node-idx delta)] + (cond-> (path/translate-selected-nodes content node-idx delta) + (seq htargets) (path/set-handler-points htargets))) + ;; Set node and handler coordinates directly. + (let [pts (handler-target-points content handlers htypes axis value)] + (cond-> content + (seq node-idx) (path/set-nodes-coordinate node-idx axis value) + (seq pts) (path/set-handler-points pts))))] + (-> (st/set-content state new-content) + (update-in (st/get-path-location state) path/update-geometry)))))) (defn toggle-snap [] (ptk/reify ::toggle-snap diff --git a/frontend/src/app/main/data/workspace/path/undo.cljs b/frontend/src/app/main/data/workspace/path/undo.cljs index ad5ef82d43..cec76924c6 100644 --- a/frontend/src/app/main/data/workspace/path/undo.cljs +++ b/frontend/src/app/main/data/workspace/path/undo.cljs @@ -12,8 +12,8 @@ [app.main.data.workspace.common :as dwc] [app.main.data.workspace.edition :as-alias dwe] [app.main.data.workspace.pages :as-alias dwpg] - [app.main.data.workspace.path.changes :as changes] [app.main.data.workspace.path.common :as common] + [app.main.data.workspace.path.helpers :as helpers] [app.main.data.workspace.path.state :as st] [app.main.store :as store] [beicon.v2.core :as rx] @@ -28,17 +28,17 @@ [event] (= :app.main.data.workspace.common/redo (ptk/type event))) +;; Undo entries skip the render-only preview. (defn- make-entry [state] (let [id (st/get-path-id state) shape (st/get-path state)] {:content (:content shape) :selrect (:selrect shape) :points (:points shape) - :preview (get-in state [:workspace-local :edit-path id :preview]) :last-point (get-in state [:workspace-local :edit-path id :last-point]) :prev-handler (get-in state [:workspace-local :edit-path id :prev-handler])})) -(defn- load-entry [state {:keys [content selrect points preview last-point prev-handler]}] +(defn- load-entry [state {:keys [content selrect points last-point prev-handler]}] (let [id (st/get-path-id state) old-content (st/get-path state :content)] (-> state @@ -47,11 +47,14 @@ (d/assoc-in-when (st/get-path-location state :points) points) (d/update-in-when [:workspace-local :edit-path id] - assoc - :preview preview - :last-point last-point - :prev-handler prev-handler - :old-content old-content)))) + (fn [edit-state] + ;; Remap the selection to the restored content. + (cond-> (assoc edit-state + :preview nil + :last-point last-point + :prev-handler prev-handler) + (some? content) + (update :selection helpers/remap-selection old-content content))))))) (defn undo-path [] (ptk/reify ::undo-path @@ -72,10 +75,8 @@ (watch [_ state _] (let [id (st/get-path-id state) undo-stack (get-in state [:workspace-local :edit-path id :undo-stack])] - (if (> (:index undo-stack) 0) - (rx/of (changes/save-path-content {:preserve-move-to true})) - (rx/of (changes/save-path-content {:preserve-move-to true}) - (common/finish-path) + (when (zero? (:index undo-stack)) + (rx/of (common/finish-path) (dwc/show-toolbar))))))) (defn redo-path [] @@ -90,11 +91,7 @@ (load-entry entry) (d/assoc-in-when [:workspace-local :edit-path id :undo-stack] - undo-stack)))) - - ptk/WatchEvent - (watch [_ _ _] - (rx/of (changes/save-path-content))))) + undo-stack)))))) (defn merge-head "Joins the head with the previous undo in one. This is done so when the user changes a @@ -171,4 +168,3 @@ (rx/map #(add-undo-entry))) (rx/of (end-path-undo)))))))))) - diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index f74dacdda8..aa43f69c1d 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -23,6 +23,8 @@ [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] + [app.common.types.path :as path] + [app.common.types.path.helpers :as path.helpers] [app.common.types.shape-tree :as ctst] [app.common.types.shape.attrs :refer [editable-attrs]] [app.common.types.shape.layout :as ctl] @@ -363,6 +365,71 @@ (dwm/apply-modifiers) (finish-transform)))))))))))) +(defn start-move-line-point + "Drags one endpoint of a straight path while keeping the other fixed." + [shape index] + (ptk/reify ::start-move-line-point + ptk/WatchEvent + (watch [_ state stream] + (let [id (dm/get-prop shape :id) + page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + zoom (dm/get-in state [:workspace-local :zoom] 1) + layout (:workspace-layout state) + focus (:workspace-focus-selected state) + + content (dm/get-prop shape :content) + start-point (path.helpers/segment->point (nth content index)) + other-point (path.helpers/segment->point (nth content (if (zero? index) 1 0))) + + stopper (mse/drag-stopper stream) + + ;; Shift constrains the endpoint around the fixed point. + position-stream + (->> ms/mouse-position + (rx/filter some?) + (rx/with-latest-from ms/mouse-position-shift) + (rx/switch-map + (fn [[pos shift?]] + (if ^boolean shift? + (rx/of (path.helpers/position-fixed-angle pos other-point)) + (snap/closest-snap-point page-id [shape] objects layout zoom focus pos)))) + (rx/share)) + + move-endpoint + (fn [pos save-undo?] + (let [delta (gpt/to-vec start-point pos)] + (dwsh/update-shapes + [id] + (fn [_] + (-> shape + (assoc :content (path/apply-content-modifiers + content + {index {:x (dm/get-prop delta :x) + :y (dm/get-prop delta :y)}})) + (path/update-geometry))) + {:reg-objects? true :save-undo? save-undo?})))] + + ;; Hide selection controls during the drag. + (rx/concat + (rx/of #(assoc-in % [:workspace-local :transform] :move)) + ;; Subscribe the preview and commit branches together. + (rx/merge + ;; Preview without creating undo entries. + (->> position-stream + (rx/sample mconst/move-sample-time) + (rx/map #(move-endpoint % false)) + (rx/take-until stopper)) + ;; Commit the final position as one undo step. + (->> position-stream + (rx/take-until stopper) + (rx/last) + (rx/mapcat + (fn [pos] + (rx/of (move-endpoint start-point false) + (move-endpoint pos true)))))) + (rx/of #(assoc-in % [:workspace-local :transform] nil))))))) + (defn trigger-bounding-box-cloaking "Trigger the bounding box cloaking (with default timer of 1sec) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c75ac18697..064e7cd4e8 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -222,6 +222,9 @@ (def selected-edition (l/derived :edition workspace-local)) +(def workspace-edit-path + (l/derived :edit-path workspace-local)) + (def current-transform (l/derived :transform workspace-local)) diff --git a/frontend/src/app/main/ui/css_cursors.cljs b/frontend/src/app/main/ui/css_cursors.cljs index 9c0a97276e..8541d7bedf 100644 --- a/frontend/src/app/main/ui/css_cursors.cljs +++ b/frontend/src/app/main/ui/css_cursors.cljs @@ -40,9 +40,21 @@ (init-static-cursor-style style "create-polygon" cur/create-polygon) (init-static-cursor-style style "create-rectangle" cur/create-rectangle) (init-static-cursor-style style "create-shape" cur/create-shape) + (init-static-cursor-style style "draw" cur/draw) + (init-static-cursor-style style "draw-add" cur/draw-add) + (init-static-cursor-style style "draw-node" cur/draw-node) + (init-static-cursor-style style "draw-remove" cur/draw-remove) (init-static-cursor-style style "duplicate" cur/duplicate) (init-static-cursor-style style "hand" cur/hand) + (init-static-cursor-style style "move" cur/move) + (init-static-cursor-style style "move-add" cur/move-add) + (init-static-cursor-style style "move-copy" cur/move-copy) + (init-static-cursor-style style "move-curve" cur/move-curve) + (init-static-cursor-style style "move-handles" cur/move-handles) + (init-static-cursor-style style "move-move" cur/move-move) + (init-static-cursor-style style "move-node" cur/move-node) (init-static-cursor-style style "move-pointer" cur/move-pointer) + (init-static-cursor-style style "move-remove" cur/move-remove) (init-static-cursor-style style "pen" cur/pen) (init-static-cursor-style style "pen-node" cur/pen-node) (init-static-cursor-style style "pencil" cur/pencil) diff --git a/frontend/src/app/main/ui/cursors.clj b/frontend/src/app/main/ui/cursors.clj index c99514f515..eef490b7f9 100644 --- a/frontend/src/app/main/ui/cursors.clj +++ b/frontend/src/app/main/ui/cursors.clj @@ -17,6 +17,7 @@ (def default-hotspot-y 12) (def default-rotation 0) (def default-height 20) +(def default-width 20) (defn parse-svg [svg-data] (-> svg-data @@ -51,7 +52,7 @@ (str/replace #"\s+$" ""))) (defn encode-svg-cursor - [id rotation x y height] + [id rotation x y height width] (let [svg-path (str cursor-folder "/" (name id) ".svg") data (-> svg-path io/resource slurp parse-svg) data (u/percent-encode data) @@ -59,15 +60,16 @@ data (if rotation (str/fmt "%3Cg transform='rotate(%s 8,8)'%3E%s%3C/g%3E" rotation data) data)] - (str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20px' " + (str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='" width "px' " "height='" height "px' %3E" data "%3C/svg%3E\") " x " " y ", auto"))) (defmacro cursor-ref - "Creates a static cursor given its name, rotation and x/y hotspot" - ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height)) - ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height)) - ([id rotation x y] (encode-svg-cursor id rotation x y default-height)) - ([id rotation x y height] (encode-svg-cursor id rotation x y height))) + "Creates a static SVG cursor." + ([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height default-width)) + ([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height default-width)) + ([id rotation x y] (encode-svg-cursor id rotation x y default-height default-width)) + ([id rotation x y height] (encode-svg-cursor id rotation x y height default-width)) + ([id rotation x y height width] (encode-svg-cursor id rotation x y height width))) (defmacro cursor-fn "Creates a dynamic cursor that can be rotated in runtime" @@ -75,7 +77,8 @@ (let [[cp1 cp2] (-> (encode-svg-cursor id "$$$" default-hotspot-x default-hotspot-y - default-height) + default-height + default-width) (str/split #"\$\$\$"))] `(fn [rot#] (str/concat ~cp1 (+ ~initial rot#) ~cp2)))) diff --git a/frontend/src/app/main/ui/cursors.cljs b/frontend/src/app/main/ui/cursors.cljs index e8c3589914..9453f285d5 100644 --- a/frontend/src/app/main/ui/cursors.cljs +++ b/frontend/src/app/main/ui/cursors.cljs @@ -14,9 +14,21 @@ (def ^:cursor create-polygon (cursor-ref :create-polygon)) (def ^:cursor create-rectangle (cursor-ref :create-rectangle)) (def ^:cursor create-shape (cursor-ref :create-shape)) +(def ^:cursor draw (cursor-ref :draw 0 0 0)) +(def ^:cursor draw-add (cursor-ref :draw-add 0 0 0 25 25)) +(def ^:cursor draw-node (cursor-ref :draw-node 0 0 0 25 25)) +(def ^:cursor draw-remove (cursor-ref :draw-remove 0 0 0 25 25)) (def ^:cursor duplicate (cursor-ref :duplicate 0 0 0)) (def ^:cursor hand (cursor-ref :hand)) +(def ^:cursor move (cursor-ref :move 0 0 0 25 25)) +(def ^:cursor move-add (cursor-ref :move-add 0 0 0 25 25)) +(def ^:cursor move-copy (cursor-ref :move-copy 0 0 0 25 25)) +(def ^:cursor move-curve (cursor-ref :move-curve 0 0 0 25 25)) +(def ^:cursor move-handles (cursor-ref :move-handles 0 0 0 25 25)) +(def ^:cursor move-move (cursor-ref :move-move 0 0 0 25 25)) +(def ^:cursor move-node (cursor-ref :move-node 0 0 0 25 25)) (def ^:cursor move-pointer (cursor-ref :move-pointer)) +(def ^:cursor move-remove (cursor-ref :move-remove 0 0 0 25 25)) (def ^:cursor pen (cursor-ref :pen 0 0 0)) (def ^:cursor pen-node (cursor-ref :pen-node 0 0 10 36)) (def ^:cursor pencil (cursor-ref :pencil 0 0 24)) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index 3ec978b0d6..9e62720d02 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -144,6 +144,9 @@ (def ^:icon group (icon-xref :group)) (def ^:icon gutter-horizontal (icon-xref :gutter-horizontal)) (def ^:icon gutter-vertical (icon-xref :gutter-vertical)) +(def ^:icon handlers-equal (icon-xref :handlers-equal)) +(def ^:icon handlers-independent (icon-xref :handlers-independent)) +(def ^:icon handlers-mirror (icon-xref :handlers-mirror)) (def ^:icon help (icon-xref :help)) (def ^:icon hide (icon-xref :hide)) (def ^:icon history (icon-xref :history)) @@ -219,6 +222,7 @@ (def ^:icon shown (icon-xref :shown)) (def ^:icon size-horizontal (icon-xref :size-horizontal)) (def ^:icon size-vertical (icon-xref :size-vertical)) +(def ^:icon snap (icon-xref :snap)) (def ^:icon snap-nodes (icon-xref :snap-nodes)) (def ^:icon status-alert (icon-xref :status-alert)) (def ^:icon status-tick (icon-xref :status-tick)) diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index f2fdb2f639..9ba9226c81 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -9,11 +9,13 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] [app.common.math :as mth] [app.common.types.component :as ctk] + [app.common.types.path :as path] [app.common.uuid :as uuid] [app.main.constants :as mconst] [app.main.ui.formats :as fmt] @@ -214,6 +216,11 @@ single-shape (and (= (count shapes) 1) (first shapes)) + ;; Straight paths use endpoint controls instead of a size badge. + single-line? (and single-shape + (cfh/path-shape? single-shape) + (path/single-line? (dm/get-prop single-shape :content))) + component-color? (if single-shape (ctk/instance-head? single-shape) (every? ctk/instance-head? shapes)) @@ -245,64 +252,65 @@ text-width (* (count text) badge-char-width) badge-width (+ text-width (* 2 badge-padding-x))] - (if has-rotation? - (let [edge (get-edge-for-badge rotation) - points (dm/get-prop single-shape :points) + (when-not ^boolean single-line? + (if has-rotation? + (let [edge (get-edge-for-badge rotation) + points (dm/get-prop single-shape :points) - [ep1 ep2] (get-edge-points points edge) + [ep1 ep2] (get-edge-points points edge) - mid-point (gpt/lerp ep1 ep2 0.5) - normal (gpt/normal-right (gpt/subtract ep2 ep1)) + mid-point (gpt/lerp ep1 ep2 0.5) + normal (gpt/normal-right (gpt/subtract ep2 ep1)) - rot-offset (case edge - :bottom 0 - :right 270 - :top 180 - :left 90) - badge-rot (+ rotation rot-offset) - offset (+ badge-gap (/ badge-height 2)) + rot-offset (case edge + :bottom 0 + :right 270 + :top 180 + :left 90) + badge-rot (+ rotation rot-offset) + offset (+ badge-gap (/ badge-height 2)) - badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (+ (:x mid-point) (* (:x normal) offset)) - badge-cy (+ (:y mid-point) (* (:y normal) offset))] + badge-x (- (/ badge-width 2)) + badge-y (- (/ badge-height 2)) + badge-cx (+ (:x mid-point) (* (:x normal) offset)) + badge-cy (+ (:y mid-point) (* (:y normal) offset))] - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]]) + [:g.selection-size-badge {:pointer-events "none" + :transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")} + [:rect {:x badge-x + :y badge-y + :width badge-width + :height badge-height + :rx badge-radius + :ry badge-radius + :style {:fill badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x 0 + :y 0 + :text-anchor "middle" + :dominant-baseline "middle"} + text]]) - (let [badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (+ (:x selrect) (/ (:width selrect) 2)) - badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))] + (let [badge-x (- (/ badge-width 2)) + badge-y (- (/ badge-height 2)) + badge-cx (+ (:x selrect) (/ (:width selrect) 2)) + badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))] - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]])))) + [:g.selection-size-badge {:pointer-events "none" + :transform (dm/str "translate(" badge-cx "," badge-cy ")")} + [:rect {:x badge-x + :y badge-y + :width badge-width + :height badge-height + :rx badge-radius + :ry badge-radius + :style {:fill badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x 0 + :y 0 + :text-anchor "middle" + :dominant-baseline "middle"} + text]]))))) (mf/defc distance-display* [{:keys [from to zoom bounds]}] (let [fixed-x (if (gsh/fully-contained? from to) @@ -384,4 +392,3 @@ [:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}] [:> size-display* {:selrect hover-selrect :zoom zoom}] [:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])]))) - diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 4f469b04ae..4e910f3624 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -510,7 +510,10 @@ :shortcut-key :start-editing :on-click do-start-editing}]) - (when-not (or disable-flatten has-frame? has-path?) + ;; Flattening a single path bakes its transform. + (when (and (not disable-flatten) + (not has-frame?) + (or (not has-path?) (and single? has-path?))) [:> menu-entry* {:title (tr "workspace.shape.menu.flatten") :on-click do-transform-to-path}]) diff --git a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs index dbf2bfee11..ef886cf5dc 100644 --- a/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/path/editor.cljs @@ -12,14 +12,16 @@ [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as dwp.helpers] [app.main.snap :as snap] [app.main.store :as st] [app.main.streams :as ms] [app.main.ui.css-cursors :as cur] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.util.dom :as dom] [app.util.keyboard :as kbd] - [clojure.set :refer [map-invert]] + [beicon.v2.core :as rx] [goog.events :as events] [rumext.v2 :as mf])) @@ -39,10 +41,55 @@ (def black-color "var(--app-black)") (def white-color "var(--app-white)") (def gray-color "var(--df-secondary)") +(def selected-color "var(--app-pink)") + +;; Hover cursors for each edit mode and modifier combination. + +(defn- node-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected any-node-selected?] + (if (= edit-mode :draw) + (cond + ^boolean alt? "draw-remove" + ^boolean mod? "move-handles" + :else "draw-node") + (cond + (and ^boolean mod? ^boolean alt?) "draw-remove" + ^boolean mod? "move-handles" + ^boolean alt? "draw-remove" + (and ^boolean shift? + ^boolean any-node-selected?) "move-add" + ^boolean is-selected "move-move" + :else "move-node"))) + +(defn- segment-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected insert-preview?] + (if (= edit-mode :draw) + (cond + ^boolean alt? "draw-remove" + ^boolean mod? "move-curve" + :else "draw-add") + (cond + (and ^boolean mod? ^boolean alt?) "draw-remove" + ^boolean mod? "move-curve" + ^boolean alt? "draw-add" + ^boolean shift? "move-add" + ^boolean insert-preview? "draw-add" + ^boolean is-selected "move-move" + :else nil))) + +(defn- handler-cursor + [edit-mode {:keys [shift? mod? alt?]} is-selected] + (cond + (and ^boolean mod? ^boolean alt?) "move" + (or ^boolean mod? ^boolean alt?) "move-remove" + (and (= edit-mode :move) + ^boolean shift? + (not ^boolean is-selected)) "move-add" + :else "move")) (mf/defc path-point* {::mf/private true} - [{:keys [position zoom edit-mode is-hover is-selected is-preview is-start-path is-last is-new is-curve]}] + [{:keys [index position zoom edit-mode is-hover is-selected is-preview is-new cursor]}] (let [{:keys [x y]} position is-draw (= edit-mode :draw) @@ -54,48 +101,33 @@ on-enter (mf/use-fn + (mf/deps index) (fn [_] - (st/emit! (drp/path-pointer-enter position)))) + (when (some? index) + (st/emit! (drp/path-pointer-enter index))))) on-leave (mf/use-fn + (mf/deps index) (fn [_] - (st/emit! (drp/path-pointer-leave position)))) + (when (some? index) + (st/emit! (drp/path-pointer-leave index))))) on-pointer-down (fn [event] (when (dom/left-mouse? event) + (uwvv/capture-pointer event) (dom/stop-propagation event) (dom/prevent-default event) + (let [is-shift (kbd/shift? event) + is-alt (kbd/alt? event) + is-mod (kbd/mod? event)] + (cond + is-move + (st/emit! (drp/start-move-path-point index is-shift is-alt is-mod)) - ;; When clicking on a hover point that lies on a segment (has metadata with - ;; split params), only insert the node — don't also run draw-mode actions which - ;; would add the same position as an extra endpoint, corrupting the path order - ;; and misplacing stroke caps. - ;; FIXME: revisit this, using meta here breaks equality checks - (if (and is-new (some? (meta position))) - (st/emit! (drp/create-node-at-position (meta position))) - (let [is-shift (kbd/shift? event) - is-mod (kbd/mod? event)] - (cond - is-last - (st/emit! (drp/reset-last-handler)) - - (and is-move is-mod (not is-curve)) - (st/emit! (drp/make-curve position)) - - (and is-move is-mod is-curve) - (st/emit! (drp/make-corner position)) - - is-move - ;; If we're dragging a selected item we don't change the selection - (st/emit! (drp/start-move-path-point position is-shift)) - - (and is-draw is-start-path) - (st/emit! (drp/start-path-from-point position)) - - (and is-draw (not is-start-path)) - (st/emit! (drp/close-path-drag-start position)))))))] + is-draw + (st/emit! (drp/on-draw-node-pointer-down index position is-alt is-mod))))))] [:g.path-point [:circle.path-point @@ -108,7 +140,7 @@ :stroke (cond ^boolean is-active black-color ^boolean is-preview secondary-color :else accent-color) - :fill (cond is-selected accent-color + :fill (cond is-selected selected-color :else white-color)}}] [:circle {:cx x :cy y @@ -116,21 +148,24 @@ :on-pointer-down on-pointer-down :on-pointer-enter on-enter :on-pointer-leave on-leave - :pointer-events (when-not ^boolean is-preview "visible") - :class (cond ^boolean is-draw (cur/get-static "pen-node") - ^boolean is-move (cur/get-static "pointer-node")) + ;; Let insertion preview clicks reach the segment. + :pointer-events (cond ^boolean is-preview nil + ^boolean is-new "none" + :else "visible") + :class (when (some? cursor) (cur/get-static cursor)) :style {:stroke-width 0 :fill "none"}}]])) -;; FIXME: is-selected prop looks unused - (mf/defc path-handler* {::mf/private true} - [{:keys [index prefix point handler zoom is-selected is-hover edit-mode snap-angle]}] + [{:keys [index prefix point handler zoom is-selected is-hover snap-angle cursor on-grab]}] (let [x (dm/get-prop handler :x) y (dm/get-prop handler :y) - is-draw (= edit-mode :draw) - is-move (= edit-mode :move) + + ;; Placed handlers and handlers with `on-grab` are interactive. + is-interactive + (or (some? index) + (some? on-grab)) is-active (or ^boolean is-selected @@ -148,16 +183,21 @@ on-pointer-down (mf/use-fn - (mf/deps index prefix is-move) + (mf/deps index prefix is-interactive on-grab) (fn [event] - (when (dom/left-mouse? event) + (when (and ^boolean is-interactive (dom/left-mouse? event)) + (uwvv/capture-pointer event) (dom/stop-propagation event) (dom/prevent-default event) + (if (some? on-grab) + (on-grab event) + (st/emit! (drp/start-move-handler index + prefix + (kbd/shift? event) + (kbd/alt? event) + (kbd/mod? event)))))))] - (when ^boolean is-move - (st/emit! (drp/start-move-handler index prefix))))))] - - [:g.handler {:pointer-events (if ^boolean is-draw "none" "visible")} + [:g.handler {:pointer-events (if ^boolean is-interactive "visible" "none")} [:line {:x1 (:x point) :y1 (:y point) @@ -194,11 +234,70 @@ :on-pointer-down on-pointer-down :on-pointer-enter on-enter :on-pointer-leave on-leave - :class (when ^boolean is-move - (cur/get-static "pointer-move")) + :class (when (and ^boolean is-interactive (some? cursor)) + (cur/get-static cursor)) :style {:fill "none" :stroke-width 0}}]])) +(defn- segment-content + [{:keys [from to segment]}] + (path/content + [{:command :move-to + :params from} + (if (= :close-path (:command segment)) + {:command :line-to + :params to} + segment)])) + +(mf/defc path-segment* + {::mf/private true} + [{:keys [entry zoom edit-mode is-interactive is-selected is-hover cursor]}] + (let [index (:index entry) + content (mf/with-memo [entry] (segment-content entry)) + is-active (or ^boolean is-selected ^boolean is-hover) + on-enter (mf/use-fn + (mf/deps index) + (fn [_] + (st/emit! (drp/path-segment-enter index)))) + on-leave (mf/use-fn + (mf/deps index) + (fn [_] + (st/emit! (drp/path-segment-leave index)))) + on-pointer-down + (mf/use-fn + (mf/deps index is-interactive edit-mode) + (fn [event] + (when (and ^boolean is-interactive (dom/left-mouse? event)) + (uwvv/capture-pointer event) + (dom/stop-propagation event) + (dom/prevent-default event) + (if (= edit-mode :draw) + (st/emit! (drp/on-draw-segment-pointer-down index + (kbd/alt? event) + (kbd/mod? event))) + (st/emit! (drp/start-move-path-segment index + (kbd/shift? event) + (kbd/alt? event) + (kbd/mod? event)))))))] + [:g.path-segment {:pointer-events (if ^boolean is-interactive "visible" "none")} + (when ^boolean is-active + [:path {:d (.toString content) + :pointer-events "none" + :style {:fill "none" + :stroke (if ^boolean is-selected + selected-color + accent-color) + :stroke-width (/ 2 zoom)}}]) + [:path {:d (.toString content) + :on-pointer-down on-pointer-down + :on-pointer-enter on-enter + :on-pointer-leave on-leave + :pointer-events "stroke" + :class (when (some? cursor) (cur/get-static cursor)) + :style {:fill "none" + :stroke "transparent" + :stroke-width (/ point-radius-active-area zoom)}}]])) + (mf/defc path-preview* {::mf/private true} [{:keys [zoom segment from]}] @@ -229,12 +328,8 @@ (mf/defc path-snap* {::mf/private true} - [{:keys [selected points zoom]}] - (let [ranges - (mf/with-memo [selected points] - (snap/create-ranges points selected)) - - snap-matches + [{:keys [selected ranges zoom]}] + (let [snap-matches (snap/get-snap-delta-match selected ranges (/ 1 zoom)) matches @@ -262,34 +357,160 @@ angle (gpt/angle-with-other v1 v2)] (<= (- 180 angle) 0.1)))) +(defn- use-path-modifiers + "Tracks keyboard modifiers used by path cursors." + [] + (let [modifiers* (mf/use-state {:shift? false :mod? false :alt? false})] + (hooks/use-stream + (mf/with-memo [] + (rx/combine-latest ms/keyboard-shift ms/keyboard-mod ms/keyboard-alt)) + (fn [[shift? mod? alt?]] + (reset! modifiers* {:shift? (boolean shift?) + :mod? (boolean mod?) + :alt? (boolean alt?)}))) + (deref modifiers*))) + +(defn- use-insertion-preview + "Tracks the node insertion preview under the pointer." + [content zoom move-mode? mid-points] + (let [hover-point* (mf/use-state nil)] + (hooks/use-stream + (mf/with-memo [] + (rx/combine-latest ms/mouse-position ms/keyboard-mod ms/keyboard-shift ms/keyboard-alt)) + (mf/deps content zoom move-mode?) + (fn [[position mod? shift? alt?]] + (if (and ^boolean move-mode? + (not shift?) + (not mod?) + (gpt/point? position)) + (reset! hover-point* + (dwp.helpers/insertion-point + content position + (/ dwp.helpers/segment-insert-threshold zoom) + (boolean alt?) + mid-points)) + (reset! hover-point* nil)))) + (deref hover-point*))) + +(defn- create-snap-ranges + "Builds snap ranges from stationary nodes." + [content selected-nodes selected-segments include-all?] + (let [points (if include-all? + (path/get-points content) + (let [moving-indices (into selected-nodes + (dwp.helpers/segment-node-indices + content selected-segments)) + moving-positions (dwp.helpers/node-positions content moving-indices)] + (into [] (remove moving-positions) (path/get-points content))))] + (snap/create-ranges points))) + +(defn- snap-selected-points + [content selected-nodes selected-segment-nodes drag-handler preview moving-handler] + (cond + (some? drag-handler) #{drag-handler} + (some? preview) #{(path.helpers/segment->point preview)} + (some? moving-handler) #{moving-handler} + :else + (dwp.helpers/node-positions + content (into selected-nodes selected-segment-nodes)))) + +(mf/defc path-node* + {::mf/private true} + [{:keys [index position content handlers zoom edit-mode selected-nodes selected-handlers + hover-nodes hover-handlers moving-handler modifiers drag-cursor + any-node-selected]}] + (let [show-handler? (fn [[handler-index prefix]] + (not= position + (path/get-handler-point content handler-index prefix))) + point-handlers (->> (get handlers position) + (filter show-handler?) + (not-empty)) + point-selected? (contains? selected-nodes index) + point-hover? (contains? hover-nodes index) + matching-handlers? (matching-handler? content position point-handlers)] + [:g.path-node {:key (dm/str "node-" index)} + [:g.point-handlers + (for [[handler-index prefix] point-handlers] + (let [handler-position (path/get-handler-point content handler-index prefix) + handler-hover? (contains? hover-handlers [handler-index prefix]) + handler-selected? (contains? selected-handlers [handler-index prefix])] + (when (and position handler-position) + [:> path-handler* + {:key (dm/str handler-index "-" (d/name prefix)) + :point position + :handler handler-position + :index handler-index + :prefix prefix + :zoom zoom + :is-selected handler-selected? + :is-hover handler-hover? + :snap-angle (and (= handler-position moving-handler) matching-handlers?) + :edit-mode edit-mode + :cursor (or drag-cursor + (handler-cursor edit-mode modifiers handler-selected?))}])))] + + [:> path-point* {:index index + :position position + :zoom zoom + :edit-mode edit-mode + :is-selected point-selected? + :is-hover point-hover? + :cursor (or drag-cursor + (node-cursor edit-mode modifiers point-selected? + any-node-selected))}]])) + (mf/defc path-editor* [{:keys [shape zoom state]}] - (let [hover-point (mf/use-state nil) - editor-ref (mf/use-ref nil) + (let [editor-ref (mf/use-ref nil) {:keys [edit-mode drag-handler prev-handler preview content-modifiers - last-point - selected-points + selection moving-nodes moving-handler - hover-handlers - hover-points - snap-toggled]} + hover + snap-toggled + drag-cursor]} state - selected-points - (or selected-points #{}) + move-mode? + (= edit-mode :move) + + draw-mode? + (= edit-mode :draw) + + modifiers + (use-path-modifiers) + + selected-nodes (get selection :nodes #{}) + selected-segments (get selection :segments #{}) + selected-handlers (get selection :handlers #{}) + hover-nodes (get hover :nodes #{}) + hover-segments (get hover :segments #{}) + hover-handlers (get hover :handlers #{}) + + any-node-selected? + (boolean (seq selected-nodes)) + + ;; Skip segment hit targets while dragging. + dragging? + (or (some? drag-cursor) + (some? drag-handler)) base-content (get shape :content) - base-points - (mf/with-memo [base-content] - (path/get-points base-content)) + ;; Cache segment midpoints used by insertion previews. + insertion-mid-points + (mf/with-memo [base-content move-mode?] + (when move-mode? + (dwp.helpers/insertion-mid-points base-content))) + + hover-point + (use-insertion-preview base-content zoom move-mode? insertion-mid-points) content (mf/with-memo [base-content content-modifiers] @@ -299,12 +520,19 @@ (mf/with-memo [content] (path/get-points content)) - point->base (->> (map hash-map content-points base-points) (reduce merge)) - base->point (map-invert point->base) + ;; Pair each node position with its content index. + node-entries + (mf/with-memo [content content-points] + (mapv vector (dwp.helpers/node-indices content) content-points)) - points - (mf/with-memo [content-points] - (into #{} content-points)) + segment-entries + (mf/with-memo [content dragging?] + (when-not dragging? + (dwp.helpers/segment-entries content))) + + selected-segment-nodes + (mf/with-memo [content selected-segments] + (dwp.helpers/segment-node-indices content selected-segments)) last-p (->> content last path.helpers/segment->point) @@ -313,8 +541,16 @@ (mf/with-memo [content] (path/get-handlers content)) - is-path-start - (not (some? last-point)) + ;; Build snap ranges from stationary nodes. + snap-dragging-handler? + (boolean (or (some? drag-handler) + (some? preview) + (some? moving-handler))) + + snap-ranges + (mf/with-memo [base-content selected-nodes selected-segments snap-dragging-handler?] + (create-snap-ranges + base-content selected-nodes selected-segments snap-dragging-handler?)) show-snap? (and ^boolean snap-toggled @@ -329,23 +565,41 @@ (st/emit! :interrupt)))] #(events/unlistenByKey key))) - (hooks/use-stream - ms/mouse-position - (mf/deps base-content zoom) - (fn [position] - (when-let [point (path/closest-point base-content position (/ 0.01 zoom))] - (reset! hover-point (when (< (gpt/distance position point) (/ 10 zoom)) point))))) - [:g.path-editor {:ref editor-ref} [:path {:d (.toString content) :style {:fill "none" :stroke accent-color :strokeWidth (/ 1 zoom)}}] + (for [{:keys [index] :as entry} segment-entries] + (let [is-selected (or (contains? selected-segments index) + ;; Select segments between selected endpoints. + (and (contains? selected-nodes (:from-index entry)) + (contains? selected-nodes (:to-index entry)))) + is-hover (contains? hover-segments index)] + [:> path-segment* + {:key (dm/str "segment-" index) + :entry entry + :zoom zoom + :edit-mode edit-mode + :is-interactive (or ^boolean move-mode? ^boolean draw-mode?) + :is-selected is-selected + :is-hover is-hover + :cursor (or drag-cursor + (segment-cursor edit-mode modifiers is-selected + (and is-hover (some? hover-point))))}])) (when (and preview (not drag-handler)) [:> path-preview* {:segment preview :from last-p :zoom zoom}]) + ;; Let insertion preview clicks reach the segment. + (when (and ^boolean move-mode? (some? hover-point)) + [:g.hover-point {:pointer-events "none"} + [:> path-point* {:position hover-point + :edit-mode edit-mode + :is-new true + :zoom zoom}]]) + (when (and drag-handler last-p) [:g.drag-handler {:pointer-events "none"} [:> path-handler* {:point last-p @@ -353,90 +607,39 @@ :edit-mode edit-mode :zoom zoom}]]) - (when @hover-point - [:g.hover-point - [:> path-point* {:position @hover-point - :edit-mode edit-mode - :is-new true - :is-start-path is-path-start - :zoom zoom}]]) - - (for [position points] - (let [pos-x (dm/get-prop position :x) - pos-y (dm/get-prop position :y) - - show-handler? - (fn [[index prefix]] - ;; FIXME: get-handler-point is executed twice for each - ;; render, this can be optimized - (let [handler-position (path/get-handler-point content index prefix)] - (not= position handler-position))) - - position-handlers - (->> (get handlers position) - (filter show-handler?) - (not-empty)) - - point-selected? - (contains? selected-points (get point->base position)) - - point-hover? - (contains? hover-points (get point->base position)) - - is-last - (= last-point (get point->base position)) - - is-curve - (boolean position-handlers)] - - [:g.path-node {:key (dm/str pos-x "-" pos-y)} - [:g.point-handlers {:pointer-events (when (= edit-mode :draw) "none")} - (for [[hindex prefix] position-handlers] - (let [handler-position (path/get-handler-point content hindex prefix) - handler-hover? (contains? hover-handlers [hindex prefix]) - moving-handler? (= handler-position moving-handler) - matching-handler? (matching-handler? content position position-handlers)] - - (when (and position handler-position) - [:> path-handler* - {:key (dm/str hindex "-" (d/name prefix)) - :point position - :handler handler-position - :index hindex - :prefix prefix - :zoom zoom - :is-hover handler-hover? - :snap-angle (and moving-handler? matching-handler?) - :edit-mode edit-mode}])))] - - [:> path-point* {:position position - :zoom zoom - :edit-mode edit-mode - :is-selected point-selected? - :is-hover point-hover? - :is-last is-last - :is-start-path is-path-start - :is-curve is-curve}]])) + (for [[index position] node-entries] + [:> path-node* {:key (dm/str "node-" index) + :index index + :position position + :content content + :handlers handlers + :zoom zoom + :edit-mode edit-mode + :selected-nodes selected-nodes + :selected-handlers selected-handlers + :hover-nodes hover-nodes + :hover-handlers hover-handlers + :moving-handler moving-handler + :modifiers modifiers + :drag-cursor drag-cursor + :any-node-selected any-node-selected?}]) (when (and prev-handler last-p) - [:g.prev-handler {:pointer-events "none"} + [:g.prev-handler [:> path-handler* {:point last-p :edit-mode edit-mode :handler prev-handler - :zoom zoom}]]) + :zoom zoom + :on-grab (fn [_] (st/emit! (drp/start-move-prev-handler))) + :cursor (or drag-cursor + (handler-cursor edit-mode modifiers false))}]]) (when ^boolean show-snap? - (let [[snap-selected snap-points] - (cond - (some? drag-handler) [#{drag-handler} points] - (some? preview) [#{(path.helpers/segment->point preview)} points] - (some? moving-handler) [#{moving-handler} points] - :else - [(->> selected-points (map base->point) (into #{})) - (->> points (remove selected-points) (into #{}))])] + (let [snap-selected (snap-selected-points + content selected-nodes selected-segment-nodes + drag-handler preview moving-handler)] [:g.path-snap {:pointer-events "none"} [:> path-snap* {:selected snap-selected - :points snap-points + :ranges snap-ranges :zoom zoom}]]))])) - diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index abddbaf307..d607168954 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -15,6 +15,7 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as udw] [app.main.data.workspace.common :as dwc] + [app.main.data.workspace.path.state :as path.state] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] @@ -105,6 +106,18 @@ drawing (mf/deref refs/workspace-drawing) edition (mf/deref refs/selected-edition) + edit-path + (mf/deref refs/workspace-edit-path) + + edit-path-state + (path.state/current-edit-state edit-path edition) + + path-editing? + (path.state/editing? edit-path edition) + + path-node-count + (count (dm/get-in edit-path-state [:selection :nodes])) + files (mf/deref refs/files) @@ -152,12 +165,22 @@ [:div {:class (stl/css :element-options :design-options)} [:> align-options* {:shapes shapes - :objects objects}] - [:> bool-options* {:total-selected total-selected - :shapes shapes - :shapes-with-children shapes-with-children}] + :objects objects + :path-edit? path-editing? + :node-count path-node-count}] + (when-not path-editing? + [:> bool-options* {:total-selected total-selected + :shapes shapes + :shapes-with-children shapes-with-children}]) (cond + ;; Show path-specific options during node editing. + path-editing? + [:> path/path-edition-options* + {:shape (get objects edition) + :file-id file-id + :page-id page-id}] + (and edit-grid? (d/not-empty? selected-cells)) [:> grid-cell/options* {:shape-id (-> (get objects edition) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs index ce43b50d2e..3ae778553f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.main.data.workspace :as dw] + [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.shortcuts :as sc] [app.main.store :as st] [app.main.ui.icons :as deprecated-icon] @@ -16,30 +17,44 @@ [rumext.v2 :as mf])) (mf/defc align-options* - [{:keys [shapes objects]}] - (let [disabled-align - (not (dw/can-align? shapes objects)) + ;; Align path nodes or whole shapes for the current edit mode. + [{:keys [shapes objects path-edit? node-count]}] + (let [node-count (or node-count 0) + + disabled-align + (if path-edit? + (< node-count 2) + (not (dw/can-align? shapes objects))) disabled-distribute - (not (dw/can-distribute? shapes)) + (if path-edit? + (< node-count 3) + (not (dw/can-distribute? shapes))) align-objects (mf/use-fn + (mf/deps path-edit?) (fn [event] (let [value (-> (dom/get-current-target event) (dom/get-data "value") (keyword))] - (st/emit! (dw/align-objects value))))) + (st/emit! (if path-edit? + (dwdp/align-nodes value) + (dw/align-objects value)))))) distribute-objects (mf/use-fn + (mf/deps path-edit?) (fn [event] (let [value (-> (dom/get-current-target event) (dom/get-data "value") (keyword))] - (st/emit! (dw/distribute-objects value)))))] + (st/emit! (if path-edit? + (dwdp/distribute-nodes value) + (dw/distribute-objects value))))))] - (when-not (and disabled-align disabled-distribute) + ;; Keep path controls visible while their actions are disabled. + (when (or path-edit? (not (and disabled-align disabled-distribute))) [:div {:class (stl/css :align-options)} [:div {:class (stl/css :align-group-horizontal)} [:button {:class (stl/css-case :align-button true @@ -106,4 +121,3 @@ :data-value "vertical" :on-click distribute-objects} deprecated-icon/distribute-vertical-spacing]]]))) - diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index f2b27f824e..7b1b258a2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -176,6 +176,29 @@ (identical? (get o-values :proportion-lock) (get n-values :proportion-lock))))) +(mf/defc node-position-menu* + "X and Y inputs for the current path selection." + [{:keys [values on-x-change on-y-change]}] + ;; Match the shape position input layout. + [:section {:class (stl/css :element-set)} + [:div {:class (stl/css :position)} + [:div {:class (stl/css :x-position) + :title (tr "workspace.options.x")} + [:span {:class (stl/css :icon-text)} "X"] + [:> deprecated-input/numeric-input* {:no-validate true + :placeholder (if (= :multiple (:x values)) (tr "settings.multiple") "--") + :on-change on-x-change + :class (stl/css :numeric-input) + :value (:x values)}]] + [:div {:class (stl/css :y-position) + :title (tr "workspace.options.y")} + [:span {:class (stl/css :icon-text)} "Y"] + [:> deprecated-input/numeric-input* {:no-validate true + :placeholder (if (= :multiple (:y values)) (tr "settings.multiple") "--") + :on-change on-y-change + :class (stl/css :numeric-input) + :value (:y values)}]]]]) + (mf/defc measures-menu* {::mf/wrap [#(mf/memo' % check-measures-menu-props)]} [{:keys [ids values applied-tokens type shapes]}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs index 255997db20..7326666b57 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/path.cljs @@ -7,8 +7,13 @@ (ns app.main.ui.workspace.sidebar.options.shapes.path (:require [app.common.data.macros :as dm] + [app.common.files.helpers :as cfh] + [app.common.types.path :as cpath] [app.common.types.shape.layout :as ctl] + [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as path.helpers] [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu*]] [app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu*]] [app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-menu* exports-attrs]] @@ -17,7 +22,7 @@ [app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu*]] [app.main.ui.workspace.sidebar.options.menus.layout-container :refer [layout-container-flex-attrs layout-container-menu*]] [app.main.ui.workspace.sidebar.options.menus.layout-item :refer [layout-item-attrs layout-item-menu*]] - [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu*]] + [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu* node-position-menu*]] [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]] [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]] [app.main.ui.workspace.sidebar.options.menus.svg-attrs :refer [svg-attrs-menu*]] @@ -144,3 +149,96 @@ :page-id page-id :file-id file-id}]])) +(mf/defc path-edition-options* + "Options shown while editing a path." + [{:keys [shape]}] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type) + ids (mf/with-memo [id] [id]) + shapes (mf/with-memo [shape] [shape]) + + applied-tokens + (get shape :applied-tokens) + + measure-values + (select-keys shape measure-attrs) + + stroke-values + (select-keys shape stroke-attrs) + + ;; Read coordinates from the live editing content. + edit-path (mf/deref refs/workspace-edit-path) + drawing (mf/deref refs/current-drawing-shape) + objects (mf/deref refs/workspace-page-objects) + selection (get-in edit-path [id :selection]) + modifiers (get-in edit-path [id :content-modifiers]) + + content + (mf/with-memo [drawing modifiers] + (when-let [base (get drawing :content)] + (cpath/apply-content-modifiers base modifiers))) + + ;; Show coordinates relative to the parent frame. + frame (cfh/get-parent-frame objects shape) + in-frame? (and (some? frame) (not (cfh/root? frame))) + ox (if in-frame? (dm/get-prop frame :x) 0) + oy (if in-frame? (dm/get-prop frame :y) 0) + + ;; Segments use selection bounds; nodes and handlers use their positions. + node-values + (mf/with-memo [content selection ox oy] + (when (and (some? content) (some? selection)) + (let [segments (get selection :segments) + handlers (get selection :handlers) + nodes (get selection :nodes)] + (cond + (seq segments) + (when-let [rect (path.helpers/selection-coordinate-rect + content selection)] + {:x (- (dm/get-prop rect :x) ox) + :y (- (dm/get-prop rect :y) oy)}) + + (or (seq nodes) (seq handlers)) + (let [positions (into (path.helpers/node-positions content (set nodes)) + (keep (fn [[i p]] (cpath/get-handler-point content i p))) + handlers)] + (when (seq positions) + (let [xs (into #{} (map #(- (:x %) ox)) positions) + ys (into #{} (map #(- (:y %) oy)) positions)] + {:x (if (= 1 (count xs)) (first xs) :multiple) + :y (if (= 1 (count ys)) (first ys) :multiple)}))))))) + + on-node-x-change + (mf/use-fn (mf/deps ox) + (fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :x (+ value ox)))))) + + on-node-y-change + (mf/use-fn (mf/deps oy) + (fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :y (+ value oy))))))] + + [:* + (when (some? node-values) + [:> node-position-menu* {:values node-values + :on-x-change on-node-x-change + :on-y-change on-node-y-change}]) + ;; Show read-only shape measures when no path element is selected. + (when (nil? node-values) + [:div {:style {:pointer-events "none" :opacity 0.6}} + [:> measures-menu* {:ids ids + :type type + :applied-tokens applied-tokens + :values measure-values + :shapes shapes}]]) + [:> fill/fill-menu* + {:ids ids + :type type + :values shape + :applied-tokens applied-tokens}] + [:> stroke-menu* {:ids ids + :type type + :show-caps true + :values stroke-values + :applied-tokens applied-tokens}] + [:> shadow-menu* {:ids ids :values (get shape :shadow)}] + [:> blur-menu* {:ids ids + :values (select-keys shape [:blur :background-blur])}]])) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 5aa617cff4..7d9f9693ad 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -17,6 +17,7 @@ [app.main.data.workspace.drawing.common :as dwdc] [app.main.data.workspace.mcp :as mcp] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.path.state :as pst] [app.main.data.workspace.shortcuts :as sc] [app.main.features :as features] [app.main.refs :as refs] @@ -37,16 +38,16 @@ (def ^:private toolbar-hidden-ref (l/derived (fn [state] - (let [visibility (get state :hide-toolbar) - path-edit-state (get state :edit-path) - selected (get state :selected) - edition (get state :edition) + (let [visibility (get-in state [:workspace-local :hide-toolbar]) + selected (get-in state [:workspace-local :selected]) is-single (= (count selected) 1) - is-path-editing (and is-single (some? (get path-edit-state edition)))] + ;; The path edition bar replaces this toolbar. + is-path-editing (and is-single (pst/editing? state)) + is-path-drawing (pst/drawing? state)] - (if is-path-editing true visibility))) - refs/workspace-local)) + (if (or is-path-editing is-path-drawing) true visibility))) + st/state)) (def grouped-tools {:shapes {:default-tool :rect diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 18ef21db77..4771f201d3 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -13,7 +13,6 @@ [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] [app.common.types.component :as ctk] - [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] @@ -46,6 +45,7 @@ [app.main.ui.workspace.viewport.hooks :as hooks] [app.main.ui.workspace.viewport.interactions :as interactions] [app.main.ui.workspace.viewport.outline :as outline] + [app.main.ui.workspace.viewport.path-state :as path-state] [app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay] [app.main.ui.workspace.viewport.presence :as presence] [app.main.ui.workspace.viewport.rulers :as rulers] @@ -53,8 +53,7 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* - path-edition-bar* + [app.main.ui.workspace.viewport.top-bar :refer [edition-bars* view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]] @@ -177,21 +176,22 @@ selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames))) - edit-path-state (get edit-path edition) - edit-path-mode (get edit-path-state :edit-mode) + {:keys [edit-state + editing? + drawing? + editing-shape + bar-state + bar-shape + drawing-shape]} + (mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects] + (path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects)) - path-editing? (some? edit-path-state) - path-drawing? (or (= edit-path-mode :draw) - (and (= :path (get drawing-obj :type)) - (not= :curve drawing-tool))) - - editing-shape (when edition - (get base-objects edition)) - - editing-shape (mf/with-memo [editing-shape path-editing? base-objects] - (if path-editing? - (path/convert-to-path editing-shape base-objects) - editing-shape)) + edit-path-state edit-state + path-editing? editing? + path-drawing? drawing? + path-bar-state bar-state + path-bar-shape bar-shape + draw-area-shape drawing-shape create-comment? (= :comments drawing-tool) @@ -255,8 +255,14 @@ (seq selected)) show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) - (or drawing-obj transform)) - show-selrect? (and selrect (empty? drawing) (not text-editing?)) + (or drawing-obj transform) + (not path-editing?)) + + render-objects (mf/with-memo [base-objects path-editing? edition] + (cond-> base-objects + path-editing? + (assoc-in [edition :hidden] true))) + show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?)) show-measures? (and (not transform) (not path-editing?) (or show-distances? mode-inspect? read-only?)) @@ -311,7 +317,7 @@ (hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?) (hooks/setup-viewport-size vport viewport-ref) - (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) + (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) @@ -332,15 +338,14 @@ (when-not hide-ui? [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) - - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])]) + [:> edition-bars* {:layout layout + :path-editing path-editing? + :path-drawing path-drawing? + :path-state path-bar-state + :path-shape path-bar-shape + :grid-editing grid-editing? + :grid-shape editing-shape + :single-select single-select?}]]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap @@ -412,7 +417,7 @@ [:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)} ;; Render root shape [:& shapes/root-shape {:key (str page-id) - :objects base-objects + :objects render-objects :active-frames @active-frames ;; disable thumbnails when previewing a version :disable-thumbnails (some? preview-id)}]]]] @@ -595,7 +600,7 @@ (when (and ^boolean show-draw-area? ^boolean (cts/shape? drawing-obj)) [:> drawarea/draw-area* - {:shape drawing-obj + {:shape draw-area-shape :zoom zoom :tool drawing-tool}]) diff --git a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs index 236ef88e21..910688658f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/drawarea.cljs @@ -9,6 +9,7 @@ (:require [app.common.data.macros :as dm] [app.common.math :as mth] + [app.common.types.path :as path] [app.main.refs :as refs] [app.main.ui.shapes.path :refer [path-shape]] [app.main.ui.workspace.shapes :as shapes] @@ -20,6 +21,27 @@ (let [get-fn #(dm/get-in % [:edit-path id])] (l/derived get-fn refs/workspace-local))) +(def ^:private edit-fill-opacity + "Fill opacity used while editing a path." + 0.8) + +(def ^:private synced-edit-attrs + "Visual attributes copied into the live editing shape." + [:strokes :shadow :blur :background-blur :opacity :blend-mode]) + +(defn- dim-fills + [fills] + (mapv (fn [fill] + (update fill :fill-opacity #(* (or % 1) edit-fill-opacity))) + fills)) + +(defn path-edit-shape + "Builds the path shape rendered during editing." + [drawing-obj stored] + (-> (cond-> (merge drawing-obj (select-keys stored synced-edit-attrs)) + (seq (:fills stored)) (assoc :fills (:fills stored))) + (update :fills dim-fills))) + (mf/defc generic-draw-area* {::mf/private true} [{:keys [shape zoom]}] @@ -55,12 +77,36 @@ (mf/defc draw-area* [{:keys [shape zoom tool] :as props}] - [:g.draw-area - [:g {:style {:pointer-events "none"}} - [:& shapes/shape-wrapper {:shape shape}]] + (let [shape-id + (dm/get-prop shape :id) - (case tool - :path [:> path-draw-area* props] - :curve [:& path-shape {:shape shape :zoom zoom}] - #_:default [:> generic-draw-area* props])]) + edit-path-ref + (mf/with-memo [shape-id] + (make-edit-path-ref shape-id)) + ;; Keep command indices unchanged while applying drag modifiers. + dragging? + (some? (:content-modifiers (mf/deref edit-path-ref))) + + ;; Close rendered subpaths while keeping editor content untouched. + render-shape + (mf/with-memo [shape dragging?] + (if (and (= :path (dm/get-prop shape :type)) (not dragging?)) + (update shape :content #(-> % path/close-subpaths path/close-loops)) + shape))] + [:g.draw-area + [:g {:style {:pointer-events "none"}} + [:& shapes/shape-wrapper {:shape render-shape}]] + + (cond + (= tool :path) + [:> path-draw-area* props] + + (= tool :curve) + [:& path-shape {:shape shape :zoom zoom}] + + (= (:type shape) :path) + nil + + :else + [:> generic-draw-area* props])])) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 08798ee497..b7b3ff9292 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -25,6 +25,7 @@ [app.main.features :as features] [app.main.store :as st] [app.main.streams :as ms] + [app.main.ui.css-cursors :as cur] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.shapes.frame.dynamic-modifiers :as sfd] [app.main.ui.workspace.viewport.actions :as actions] @@ -91,9 +92,9 @@ (when (not= size vport) (st/emit! (dw/initialize-viewport (dom/get-client-size prnt))))))) -(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?] +(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?] (mf/use-effect - (mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?) + (mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?) (fn [] (let [show-pen? (or (= drawing-tool :path) (and drawing-path? @@ -108,18 +109,20 @@ (cond (and @mod? @space?) (utils/get-cursor :zoom) (or panning @space?) (utils/get-cursor :hand) + ;; Keep the drag cursor across the viewport. + (some? path-drag-cursor) (cur/get-static path-drag-cursor) (= drawing-tool :comments) (utils/get-cursor :comments) (= drawing-tool :frame) (utils/get-cursor :create-artboard) (= drawing-tool :rect) (utils/get-cursor :create-rectangle) (= drawing-tool :circle) (utils/get-cursor :create-ellipse) (and show-zoom? (not @alt?)) (utils/get-cursor :zoom-in) (and show-zoom? @alt?) (utils/get-cursor :zoom-out) - show-pen? (utils/get-cursor :pen) + show-pen? (utils/get-cursor :draw-path) (= drawing-tool :curve) (utils/get-cursor :pencil) drawing-tool (utils/get-cursor :create-shape) + path-editing? (utils/get-cursor :edit-path) (and @alt? - (not path-editing?) (not workspace-read-only?)) (utils/get-cursor :duplicate) :else (utils/get-cursor :pointer-inner))] diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs index 955147b26c..2ec5a82d17 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.cljs @@ -7,12 +7,13 @@ (ns app.main.ui.workspace.viewport.path-actions (:require-macros [app.main.style :as stl]) (:require - [app.common.types.path.segment :as path.segm] [app.main.data.workspace.path :as drp] + [app.main.data.workspace.path.helpers :as path.helpers] [app.main.data.workspace.path.shortcuts :as sc] [app.main.store :as st] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] + [app.util.timers :as ts] [rumext.v2 :as mf])) (def ^:private pentool-icon @@ -21,12 +22,6 @@ (def ^:private move-icon (deprecated-icon/icon-xref :move (stl/css :move-icon :pathbar-icon))) -(def ^:private add-icon - (deprecated-icon/icon-xref :add (stl/css :add-icon :pathbar-icon))) - -(def ^:private remove-icon - (deprecated-icon/icon-xref :remove (stl/css :remove :pathbar-icon))) - (def ^:private merge-nodes-icon (deprecated-icon/icon-xref :merge-nodes (stl/css :merge-nodes-icon :pathbar-icon))) @@ -42,40 +37,152 @@ (def ^:private to-curve-icon (deprecated-icon/icon-xref :to-curve (stl/css :to-curve-icon :pathbar-icon))) -(def ^:private snap-nodes-icon - (deprecated-icon/icon-xref :snap-nodes (stl/css :snap-nodes-icon :pathbar-icon))) +(def ^:private snap-icon + (deprecated-icon/icon-xref :snap (stl/css :snap-icon :pathbar-icon))) -(defn check-enabled [content selected-points] - (when content - (let [segments (path.segm/get-segments-with-points content selected-points) - num-segments (count segments) - num-points (count selected-points) - points-selected? (seq selected-points) - segments-selected? (seq segments) - ;; max segments for n points is (n × (n -1)) / 2 - max-segments (-> num-points - (* (- num-points 1)) - (/ 2)) - is-curve? (some #(path.segm/is-curve? content %) selected-points)] +;; Handler behavior icons: mirror, aligned, and independent. +(def ^:private handler-mirror-icon + (deprecated-icon/icon-xref :handlers-equal (stl/css :pathbar-icon))) - {:make-corner (and points-selected? is-curve?) - :make-curve (and points-selected? (not is-curve?)) - :add-node segments-selected? - :remove-node points-selected? - :merge-nodes segments-selected? - :join-nodes (and points-selected? (>= num-points 2) (< num-segments max-segments)) - :separate-nodes segments-selected?}))) +(def ^:private handler-aligned-icon + (deprecated-icon/icon-xref :handlers-mirror (stl/css :pathbar-icon))) + +(def ^:private handler-independent-icon + (deprecated-icon/icon-xref :handlers-independent (stl/css :pathbar-icon))) + +(defn- handler-type-icon [type] + (case type + :mirror handler-mirror-icon + :aligned handler-aligned-icon + :independent handler-independent-icon + ;; Use the independent icon for mixed selections. + :mixed handler-independent-icon + handler-independent-icon)) + +(defn toolbar-group-visibility + [structural-visible? shape-visible? handler-visible?] + (let [shape-handler-visible? (or shape-visible? handler-visible?)] + {:shape-handler-visible? shape-handler-visible? + :node-groups-separator-visible? (and structural-visible? shape-handler-visible?) + :snap-separator-visible? (or structural-visible? shape-handler-visible?)})) + +(mf/defc topbar-button* + "A path node action button." + {::mf/private true} + [{:keys [title on-click icon]}] + [:button {:class (stl/css :topbar-btn) + :title title + :on-click on-click} + icon]) + +(defn- cancel-timer! + [timer-ref*] + (when-let [timer (mf/ref-val timer-ref*)] + (ts/dispose! timer) + (mf/set-ref-val! timer-ref* nil))) + +(mf/defc handler-type-menu* + "Sets the handler behavior of selected nodes." + {::mf/private true} + [{:keys [active-type on-select]}] + (let [open* (mf/use-state false) + open? (deref open*) + + open-timer* (mf/use-ref nil) + close-timer* (mf/use-ref nil) + + select + (mf/use-fn + (mf/deps on-select) + (fn [type] + (reset! open* false) + (on-select type))) + + on-trigger-click + (mf/use-fn + (mf/deps select active-type) + (fn [] + (case (path.helpers/handler-trigger-action active-type) + :open + (do + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (reset! open* true)) + + :select + (select active-type)))) + + on-display-menu + (mf/use-fn + (fn [] + (cancel-timer! close-timer*) + (cancel-timer! open-timer*) + (mf/set-ref-val! + open-timer* + (ts/schedule 350 + #(do + (reset! open* true) + (mf/set-ref-val! open-timer* nil)))))) + + on-hide-menu + (mf/use-fn + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (mf/set-ref-val! + close-timer* + (ts/schedule 350 + #(do + (reset! open* false) + (mf/set-ref-val! close-timer* nil))))))] + + (mf/with-effect [] + (fn [] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*))) + + [:div {:class (stl/css :handler-menu) + :on-pointer-enter on-display-menu + :on-pointer-leave on-hide-menu} + [:button {:class (stl/css :topbar-btn :handler-trigger) + :title (tr "workspace.path.actions.handler-type") + :aria-haspopup true + :aria-expanded open? + :on-click on-trigger-click} + (handler-type-icon active-type) + [:svg {:view-box "0 0 6 6" + :aria-hidden true + :class (stl/css :flyout-indicator)} + [:path {:d "M4,2 L4,3.15 C4,3.62 3.62,4 3.15,4 L2,4" + :stroke-linecap "round"}]]] + [:div {:class (stl/css-case :handler-popover true :open open?) + :data-dont-clear-path true} + [:button {:class (stl/css-case :is-toggled (= active-type :mirror) :topbar-btn true) + :title (tr "workspace.path.actions.handler-mirror") + :on-click #(select :mirror)} + handler-mirror-icon] + [:button {:class (stl/css-case :is-toggled (= active-type :aligned) :topbar-btn true) + :title (tr "workspace.path.actions.handler-aligned") + :on-click #(select :aligned)} + handler-aligned-icon] + [:button {:class (stl/css-case :is-toggled (= active-type :independent) :topbar-btn true) + :title (tr "workspace.path.actions.handler-independent") + :on-click #(select :independent)} + handler-independent-icon]]])) (mf/defc path-actions* [{:keys [shape state]}] - (let [{:keys [edit-mode selected-points snap-toggled]} state + (let [{:keys [edit-mode selection snap-toggled]} state content (:content shape) + ;; Include segment endpoints in node actions. + selected-nodes (path.helpers/selected-node-indices content selection) + enabled-buttons (mf/use-memo - (mf/deps content selected-points) - #(check-enabled content selected-points)) + (mf/deps content selection) + #(path.helpers/check-enabled content selected-nodes)) on-select-draw-mode (mf/use-fn @@ -87,20 +194,6 @@ (fn [_] (st/emit! (drp/change-edit-mode :move)))) - on-add-node - (mf/use-fn - (mf/deps (:add-node enabled-buttons)) - (fn [_] - (when (:add-node enabled-buttons) - (st/emit! (drp/add-node))))) - - on-remove-node - (mf/use-fn - (mf/deps (:remove-node enabled-buttons)) - (fn [_] - (when (:remove-node enabled-buttons) - (st/emit! (drp/remove-node))))) - on-merge-nodes (mf/use-fn (mf/deps (:merge-nodes enabled-buttons)) @@ -139,81 +232,93 @@ on-toggle-snap (mf/use-fn (fn [_] - (st/emit! (drp/toggle-snap))))] + (st/emit! (drp/toggle-snap)))) + + ;; Show node actions only when they apply. + structural-visible? (or (:merge-nodes enabled-buttons) + (:join-nodes enabled-buttons) + (:separate-nodes enabled-buttons)) + shape-visible? (or (:make-corner enabled-buttons) + (:make-curve enabled-buttons)) + + ;; Resolve selected handlers to their curve nodes. + handler-nodes (path.helpers/handler-target-nodes content selection) + handler-state (path.helpers/handler-selection-state + content (:handler-types state) handler-nodes) + active-handler-type (:active-type handler-state) + handler-visible? (and (= edit-mode :move) (seq (:nodes handler-state))) + + group-visibility + (toolbar-group-visibility structural-visible? shape-visible? handler-visible?) + + node-groups-separator-visible? + (:node-groups-separator-visible? group-visibility) + + middle-visible? + (:snap-separator-visible? group-visibility) + + on-set-handler-type + (mf/use-fn + (fn [type] + (st/emit! (drp/set-handler-type type))))] [:div {:class (stl/css :sub-actions) :data-dont-clear-path true} + ;; Mode: draw / move (always visible) [:div {:class (stl/css :sub-actions-group)} - - ;; Draw Mode - [:button {:class (stl/css-case :is-toggled (= edit-mode :draw) - :topbar-btn true) + [:button {:class (stl/css-case :is-toggled (= edit-mode :draw) :topbar-btn true) :title (tr "workspace.path.actions.draw-nodes" (sc/get-tooltip :draw-nodes)) :on-click on-select-draw-mode} pentool-icon] - - ;; Edit mode - [:button {:class (stl/css-case :is-toggled (= edit-mode :move) - :topbar-btn true) + [:button {:class (stl/css-case :is-toggled (= edit-mode :move) :topbar-btn true) :title (tr "workspace.path.actions.move-nodes" (sc/get-tooltip :move-nodes)) :on-click on-select-edit-mode} move-icon]] - [:div {:class (stl/css :sub-actions-group)} - ;; Add Node - [:button {:disabled (not (:add-node enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.add-node" (sc/get-tooltip :add-node)) - :on-click on-add-node} - add-icon] + [:div {:class (stl/css :separator)}] - ;; Remove node - [:button {:disabled (not (:remove-node enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.delete-node" (sc/get-tooltip :delete-node)) - :on-click on-remove-node} - remove-icon]] + ;; Structural node ops: merge / join / separate + (when structural-visible? + [:div {:class (stl/css :sub-actions-group)} + (when (:merge-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes)) + :on-click on-merge-nodes + :icon merge-nodes-icon}]) + (when (:join-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes)) + :on-click on-join-nodes + :icon join-nodes-icon}]) + (when (:separate-nodes enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes)) + :on-click on-separate-nodes + :icon separate-nodes-icon}])]) - [:div {:class (stl/css :sub-actions-group)} - ;; Merge Nodes - [:button {:disabled (not (:merge-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes)) - :on-click on-merge-nodes} - merge-nodes-icon] + (when node-groups-separator-visible? + [:div {:class (stl/css :separator)}]) - ;; Join Nodes - [:button {:disabled (not (:join-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes)) - :on-click on-join-nodes} - join-nodes-icon] + ;; Node shape and handler-behaviour ops + (when shape-visible? + [:div {:class (stl/css :sub-actions-group)} + (when (:make-corner enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner)) + :on-click on-make-corner + :icon to-corner-icon}]) + (when (:make-curve enabled-buttons) + [:> topbar-button* {:title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve)) + :on-click on-make-curve + :icon to-curve-icon}])]) - ;; Separate Nodes - [:button {:disabled (not (:separate-nodes enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes)) - :on-click on-separate-nodes} - separate-nodes-icon]] + ;; Handler behaviour of the selected node(s) + (when handler-visible? + [:> handler-type-menu* {:active-type active-handler-type + :on-select on-set-handler-type}]) - [:div {:class (stl/css :sub-actions-group)} - ; Make Corner - [:button {:disabled (not (:make-corner enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner)) - :on-click on-make-corner} - to-corner-icon] + (when middle-visible? + [:div {:class (stl/css :separator)}]) - ;; Make Curve - [:button {:disabled (not (:make-curve enabled-buttons)) - :class (stl/css :topbar-btn) - :title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve)) - :on-click on-make-curve} - to-curve-icon]] - [:div {:class (stl/css :sub-actions-group)} - ;; Toggle snap - [:button {:class (stl/css-case :is-toggled snap-toggled - :topbar-btn true) + ;; Toggle snap (always visible, pinned to the right) + [:div {:class (stl/css :sub-actions-group :snap-group)} + [:button {:class (stl/css-case :is-toggled snap-toggled :topbar-btn true) :title (tr "workspace.path.actions.snap-nodes" (sc/get-tooltip :snap-nodes)) :on-click on-toggle-snap} - snap-nodes-icon]]])) + snap-icon]]])) diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss index f28915ab99..2bca2d1a11 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss @@ -5,22 +5,28 @@ // Copyright (c) KALEIDOS SUBSIDIARY SL @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; .sub-actions { cursor: initial; pointer-events: initial; position: absolute; - top: deprecated.$s-12; + top: var(--sp-xl); left: 50%; transform: translateX(-50%); display: flex; align-items: center; - height: deprecated.$s-56; - padding: deprecated.$s-8 deprecated.$s-16; - border-radius: deprecated.$s-8; - gap: deprecated.$s-16; - border: deprecated.$s-2 solid var(--panel-border-color); - z-index: deprecated.$z-index-3; + justify-content: flex-start; + + // Keep the bar stable as actions change. + width: $sz-430; + height: $sz-48; + padding: var(--sp-s); + border-radius: $br-8; + gap: var(--sp-s); + border: $b-2 solid var(--panel-border-color); + z-index: var(--z-index-panels); background-color: var(--color-background-primary); transition: top 0.3s, @@ -33,22 +39,88 @@ display: flex; align-items: center; margin: 0; - opacity: deprecated.$op-10; + opacity: 1; transition: opacity 0.3s ease; } +// Pin the snap toggle to the right. +.snap-group { + margin-left: auto; +} + +.separator { + flex-shrink: 0; + width: $sz-1; + height: $sz-24; + margin: 0 var(--sp-xs); + background-color: var(--panel-border-color); +} + +.handler-menu { + position: relative; + display: flex; + align-items: center; +} + +// Handler behavior flyout trigger. +.handler-trigger { + position: relative; +} + +// Flyout corner indicator. +.flyout-indicator { + position: absolute; + inset-block-end: var(--sp-xs); + inset-inline-end: var(--sp-xs); + inline-size: $sz-6; + block-size: $sz-6; + stroke: var(--pathbar-icon-color); + fill: none; + pointer-events: none; +} + +.handler-popover { + position: absolute; + top: calc(100% + var(--sp-s)); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + padding: var(--sp-xs); + gap: var(--sp-xxs); + border-radius: $br-8; + border: $b-2 solid var(--panel-border-color); + background-color: var(--color-background-primary); + z-index: var(--z-index-dropdown); + + // Hidden until the flyout opens. + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 80ms ease-out, + visibility 80ms linear; + + &.open { + opacity: 1; + visibility: visible; + pointer-events: auto; + } +} + .topbar-btn { --pathbar-icon-color: var(--color-foreground-secondary); + --button-tertiary-background-color-hover: var(--color-background-tertiary); @extend %button-tertiary; - height: deprecated.$s-36; - width: deprecated.$s-36; + height: $sz-32; + width: $sz-32; flex-shrink: 0; background-color: transparent; - border-radius: deprecated.$s-8; + border-radius: $br-8; border: none; - margin: 0 deprecated.$s-2; + margin: 0 var(--sp-xxs); &.is-toggled { --pathbar-icon-color: var(--button-radio-foreground-color-active); diff --git a/frontend/src/app/main/ui/workspace/viewport/path_state.cljs b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs new file mode 100644 index 0000000000..0f1d7da23b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/viewport/path_state.cljs @@ -0,0 +1,39 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.main.ui.workspace.viewport.path-state + (:require + [app.common.types.path :as path] + [app.main.data.workspace.path.state :as path.state] + [app.main.ui.workspace.viewport.drawarea :as drawarea])) + +(defn derive-path-state + "Derives the shared path-editing view model used by classic and WASM viewports." + [edit-path edition drawing-tool drawing-object objects] + (let [edit-state (path.state/current-edit-state edit-path edition) + editing? (path.state/editing? edit-path edition) + drawing? (path.state/drawing? edit-state edition drawing-tool drawing-object) + editing-shape (when edition + (if editing? + drawing-object + (get objects edition))) + editing-shape (if editing? + (path/convert-to-path editing-shape objects) + editing-shape) + bar-state (or edit-state + (when drawing? + (get edit-path (get drawing-object :id)))) + bar-shape (or editing-shape drawing-object) + drawing-shape (if (and editing? edition) + (drawarea/path-edit-shape drawing-object (get objects edition)) + drawing-object)] + {:edit-state edit-state + :editing? editing? + :drawing? drawing? + :editing-shape editing-shape + :bar-state bar-state + :bar-shape bar-shape + :drawing-shape drawing-shape})) diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index a8008c296d..8b75af07f0 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -14,6 +14,8 @@ [app.common.geom.shapes :as gsh] [app.common.types.component :as ctk] [app.common.types.container :as ctn] + [app.common.types.path :as path] + [app.common.types.path.helpers :as path.helpers] [app.common.types.shape :as cts] [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] @@ -46,7 +48,7 @@ (mf/defc selection-rect {::mf/wrap-props false} - [{:keys [transform rect zoom color on-move-selected on-context-menu]}] + [{:keys [transform rect zoom color on-move-selected on-context-menu hide-outline?]}] (let [x (dm/get-prop rect :x) y (dm/get-prop rect :y) width (dm/get-prop rect :width) @@ -74,13 +76,15 @@ :transform (str transform) :on-pointer-down on-move-selected :on-context-menu on-context-menu - :style {:stroke color + ;; Keep hidden outlines draggable. + :pointer-events (when ^boolean hide-outline? "all") + :style {:stroke (if ^boolean hide-outline? "none" color) :stroke-width (/ selection-rect-width zoom) :fill "none"}}])) (defn- calculate-handlers - "Calculates selection handlers for the current selection." - [selection shape zoom] + "Calculates resize and rotation handles for the selection." + [selection shape zoom only-rotation?] (let [x (dm/get-prop selection :x) y (dm/get-prop selection :y) width (dm/get-prop selection :width) @@ -120,69 +124,72 @@ :props #js {:cx x :cy (+ y height)}}]] - (when-not ^boolean horizontal-line? - (array/conj! result - #js {:type :resize-side - :position :top - :props #js {:x (if ^boolean small-width? - (+ x (/ (- width threshold-small) 2)) - x) - :y y - :length (if ^boolean small-width? - threshold-small - width) - :angle 0 - :align align - :show-handler tiny-width?}} - #js {:type :resize-side - :position :bottom - :props #js {:x (if ^boolean small-width? - (+ x (/ (+ width threshold-small) 2)) - (+ x width)) - :y (+ y height) - :length (if small-width? threshold-small width) - :angle 180 - :align align - :show-handler tiny-width?}})) + (if ^boolean only-rotation? + result + (do + (when-not ^boolean horizontal-line? + (array/conj! result + #js {:type :resize-side + :position :top + :props #js {:x (if ^boolean small-width? + (+ x (/ (- width threshold-small) 2)) + x) + :y y + :length (if ^boolean small-width? + threshold-small + width) + :angle 0 + :align align + :show-handler tiny-width?}} + #js {:type :resize-side + :position :bottom + :props #js {:x (if ^boolean small-width? + (+ x (/ (+ width threshold-small) 2)) + (+ x width)) + :y (+ y height) + :length (if small-width? threshold-small width) + :angle 180 + :align align + :show-handler tiny-width?}})) - (when-not vertical-line? - (array/conj! result - #js {:type :resize-side - :position :right - :props #js {:x (+ x width) - :y (if small-height? (+ y (/ (- height threshold-small) 2)) y) - :length (if small-height? threshold-small height) - :angle 90 - :align align - :show-handler tiny-height?}} + (when-not vertical-line? + (array/conj! result + #js {:type :resize-side + :position :right + :props #js {:x (+ x width) + :y (if small-height? (+ y (/ (- height threshold-small) 2)) y) + :length (if small-height? threshold-small height) + :angle 90 + :align align + :show-handler tiny-height?}} - #js {:type :resize-side - :position :left - :props #js {:x x - :y (if ^boolean small-height? - (+ y (/ (+ height threshold-small) 2)) - (+ y height)) - :length (if ^boolean small-height? - threshold-small - height) - :angle 270 - :align align - :show-handler tiny-height?}})) + #js {:type :resize-side + :position :left + :props #js {:x x + :y (if ^boolean small-height? + (+ y (/ (+ height threshold-small) 2)) + (+ y height)) + :length (if ^boolean small-height? + threshold-small + height) + :angle 270 + :align align + :show-handler tiny-height?}})) - (when (and (not tiny-width?) (not tiny-height?)) - (array/conj! result - #js {:type :resize-point - :position :top-left - :props #js {:cx x :cy y :align align}} - #js {:type :resize-point - :position :top-right - :props #js {:cx (+ x width) :cy y :align align}} - #js {:type :resize-point - :position :bottom-right - :props #js {:cx (+ x width) :cy (+ y height) :align align}} - #js {:type :resize-point - :position :bottom-left - :props #js {:cx x :cy (+ y height) :align align}})))) + (when (and (not tiny-width?) (not tiny-height?)) + (array/conj! result + #js {:type :resize-point + :position :top-left + :props #js {:cx x :cy y :align align}} + #js {:type :resize-point + :position :top-right + :props #js {:cx (+ x width) :cy y :align align}} + #js {:type :resize-point + :position :bottom-right + :props #js {:cx (+ x width) :cy (+ y height) :align align}} + #js {:type :resize-point + :position :bottom-left + :props #js {:cx x :cy (+ y height) :align align}})))))) (mf/defc rotation-handler {::mf/wrap-props false} @@ -339,7 +346,7 @@ :stroke-width 0}}]])) (mf/defc controls-selection* - [{:keys [shape zoom color on-move-selected on-context-menu disabled]}] + [{:keys [shape zoom color on-move-selected on-context-menu disabled hide-outline?]}] (let [selrect-transform (mf/deref refs/workspace-selrect) transform-type (mf/deref refs/current-transform) [selrect transform] (dsh/get-selrect selrect-transform shape)] @@ -353,12 +360,13 @@ :transform transform :zoom zoom :color color + :hide-outline? hide-outline? :on-move-selected on-move-selected :on-context-menu on-context-menu}]]))) (mf/defc controls-handlers* {::mf/private true} - [{:keys [shape zoom color on-resize on-rotate disabled]}] + [{:keys [shape zoom color on-resize on-rotate disabled only-rotation?]}] (let [selrect-transform (mf/deref refs/workspace-selrect) transform-type (mf/deref refs/current-transform) @@ -385,7 +393,7 @@ (= transform-type :rotate)))) [:g.controls {:pointer-events (if ^boolean disabled "none" "visible")} - (for [handler (calculate-handlers selrect shape zoom)] + (for [handler (calculate-handlers selrect shape zoom only-rotation?)] (let [type (obj/get handler "type") position (obj/get handler "position") props (obj/get handler "props") @@ -493,6 +501,46 @@ :on-move-selected on-move-selected :on-context-menu on-context-menu}])) +(mf/defc line-handlers* + "Endpoint handles for a straight path." + {::mf/private true} + [{:keys [shape zoom color disabled]}] + (let [read-only? (mf/use-ctx ctx/workspace-read-only?) + transform-type (mf/deref refs/current-transform) + content (dm/get-prop shape :content) + p1 (path.helpers/segment->point (nth content 0)) + p2 (path.helpers/segment->point (nth content 1))] + + (when (and (not ^boolean read-only?) + (not (:blocked shape)) + (not (or (= transform-type :move) + (= transform-type :rotate)))) + [:g.controls {:pointer-events (if ^boolean disabled "none" "visible")} + (for [[index point] [[0 p1] [1 p2]]] + (let [x (dm/get-prop point :x) + y (dm/get-prop point :y) + + on-pointer-down + (fn [event] + (when (dom/left-mouse? event) + (dom/stop-propagation event) + (st/emit! (dw/start-move-line-point shape index))))] + [:g.path-point {:key index} + [:circle {:cx x + :cy y + :r (/ resize-point-radius zoom) + :style {:stroke-width "1px" + :stroke color + :fill "var(--app-white)" + :vectorEffect "non-scaling-stroke"}}] + [:circle {:cx x + :cy y + :r (/ resize-point-circle-radius zoom) + :on-pointer-down on-pointer-down + :class (cur/get-static "pointer-node") + :style {:fill (if (dbg/enabled? :handlers) "red" "none") + :stroke-width 0}}]]))]))) + (mf/defc single-handlers* {::mf/private true} [{:keys [shape zoom color disabled]}] @@ -500,6 +548,9 @@ grow-type (dm/get-prop shape :grow-type) shape-type (dm/get-prop shape :type) + line? (and (cfh/path-shape? shape) + (path/single-line? (dm/get-prop shape :content))) + on-resize (mf/use-fn (mf/deps shape-id shape grow-type shape-type) @@ -537,24 +588,44 @@ (dom/stop-propagation event) (st/emit! (dw/start-rotate [shape])))))] - [:> controls-handlers* - {:shape shape - :zoom zoom - :color color - :disabled disabled - :on-rotate on-rotate - :on-resize on-resize}])) + (if ^boolean line? + [:g.line-controls + ;; Use endpoint controls with corner rotation handles. + [:> controls-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled + :on-rotate on-rotate + :on-resize on-resize + :only-rotation? true}] + [:> line-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled}]] + [:> controls-handlers* + {:shape shape + :zoom zoom + :color color + :disabled disabled + :on-rotate on-rotate + :on-resize on-resize}]))) (mf/defc single-selection* {::mf/private true} [{:keys [shape zoom color disabled on-move-selected on-context-menu]}] - [:> controls-selection* - {:shape shape - :zoom zoom - :color color - :disabled disabled - :on-move-selected on-move-selected - :on-context-menu on-context-menu}]) + (let [line? (and (cfh/path-shape? shape) + (path/single-line? (dm/get-prop shape :content)))] + [:> controls-selection* + {:shape shape + :zoom zoom + :color color + :disabled disabled + ;; Keep the line body draggable without an outline. + :hide-outline? line? + :on-move-selected on-move-selected + :on-context-menu on-context-menu}])) (mf/defc area* [{:keys [shapes edition zoom disabled on-move-selected on-context-menu]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index 07d13ddbf0..5d625d16fe 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -56,3 +56,23 @@ (mf/defc grid-edition-bar* [{:keys [shape]}] [:> grid-edition-actions* {:shape shape}]) + +(mf/defc edition-bars* + [{:keys [layout + path-editing + path-drawing + path-state + path-shape + grid-editing + grid-shape + single-select]}] + [:* + (when (or (and ^boolean path-editing ^boolean single-select) + (and ^boolean path-drawing (some? path-state))) + [:> path-edition-bar* {:shape path-shape + :edit-path-state path-state + :layout layout}]) + + (when (and ^boolean grid-editing ^boolean single-select) + [:> grid-edition-bar* {:shape grid-shape}])]) + diff --git a/frontend/src/app/main/ui/workspace/viewport/utils.cljs b/frontend/src/app/main/ui/workspace/viewport/utils.cljs index add3f045d2..4e120af115 100644 --- a/frontend/src/app/main/ui/workspace/viewport/utils.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/utils.cljs @@ -29,6 +29,8 @@ :create-ellipse (cur/get-static "create-ellipse") :pen (cur/get-static "pen") :pencil (cur/get-static "pencil") + :draw-path (cur/get-static "draw") + :edit-path (cur/get-static "move") :create-shape (cur/get-static "create-shape") :duplicate (cur/get-static "duplicate") :zoom (cur/get-static "zoom") diff --git a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs index 7c597fcfd5..fa1c126307 100644 --- a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs @@ -17,6 +17,11 @@ (defonce viewport-ref (atom nil)) (defonce viewport-brect (atom nil)) +(defn capture-pointer + [event] + (when-let [viewport @viewport-ref] + (.setPointerCapture viewport (.-pointerId event)))) + (defn- init-observer [node] (let [on-change-bounds diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 51ebd35b3d..70138b24ea 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -13,7 +13,6 @@ [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] [app.common.types.component :as ctk] - [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.data.modal :as modal] @@ -44,6 +43,7 @@ [app.main.ui.workspace.viewport.hooks :as hooks] [app.main.ui.workspace.viewport.interactions :as interactions] [app.main.ui.workspace.viewport.outline :as outline] + [app.main.ui.workspace.viewport.path-state :as path-state] [app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay] [app.main.ui.workspace.viewport.presence :as presence] [app.main.ui.workspace.viewport.rulers :as rulers] @@ -51,8 +51,7 @@ [app.main.ui.workspace.viewport.selection :as selection] [app.main.ui.workspace.viewport.snap-distances :as snap-distances] [app.main.ui.workspace.viewport.snap-points :as snap-points] - [app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar* - path-edition-bar* + [app.main.ui.workspace.viewport.top-bar :refer [edition-bars* view-only-bar*]] [app.main.ui.workspace.viewport.utils :as utils] [app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]] @@ -282,21 +281,22 @@ ;; Only when we have all the selected shapes in one frame selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames))) - edit-path-state (get edit-path edition) - edit-path-mode (get edit-path-state :edit-mode) + {:keys [edit-state + editing? + drawing? + editing-shape + bar-state + bar-shape + drawing-shape]} + (mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects] + (path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects)) - path-editing? (some? edit-path-state) - path-drawing? (or (= edit-path-mode :draw) - (and (= :path (get drawing-obj :type)) - (not= :curve drawing-tool))) - - editing-shape (when edition - (get base-objects edition)) - - editing-shape (mf/with-memo [editing-shape path-editing? base-objects] - (if path-editing? - (path/convert-to-path editing-shape base-objects) - editing-shape)) + edit-path-state edit-state + path-editing? editing? + path-drawing? drawing? + path-bar-state bar-state + path-bar-shape bar-shape + draw-area-shape drawing-shape create-comment? (= :comments drawing-tool) @@ -372,8 +372,9 @@ show-snap-points? (and (or (contains? layout :dynamic-alignment) (contains? layout :snap-guides)) (or drawing-obj transform) + (not path-editing?) (not page-transition?)) - show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?)) + show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?) (not page-transition?)) show-measures? (and (not transform) (not path-editing?) (or show-distances? mode-inspect? read-only?) @@ -629,13 +630,24 @@ (hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?) (hooks/setup-viewport-size vport viewport-ref) - (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) + (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) + (mf/with-effect [path-editing? edition @initialized?] + (when (and path-editing? edition @initialized?) + (wasm.api/use-shape edition) + (wasm.api/set-shape-hidden true) + (wasm.api/request-render "start-path-edition") + (fn [] + (when (wasm.api/initialized?) + (wasm.api/use-shape edition) + (wasm.api/set-shape-hidden false) + (wasm.api/request-render "stop-path-edition"))))) + [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} (cond @@ -650,15 +662,14 @@ (when-not hide-ui? [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) - - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])]) + [:> edition-bars* {:layout layout + :path-editing path-editing? + :path-drawing path-drawing? + :path-state path-bar-state + :path-shape path-bar-shape + :grid-editing grid-editing? + :grid-shape editing-shape + :single-select single-select?}]]) [:div {:class (stl/css :viewport-overlays)} (when show-comments? @@ -866,7 +877,7 @@ (when (and ^boolean show-draw-area? ^boolean (cts/shape? drawing-obj)) [:> drawarea/draw-area* - {:shape drawing-obj + {:shape draw-area-shape :zoom zoom :tool drawing-tool}]) diff --git a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs index 45eaff8d75..6ded7bc539 100644 --- a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs @@ -11,6 +11,7 @@ [app.common.test-helpers.shapes :as cths] [app.main.data.shortcuts :as dsc] [app.main.data.workspace :as dw] + [app.main.data.workspace.path.edition :as path.edition] [app.main.data.workspace.path.shortcuts :as psc] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shortcuts :as wsc] @@ -76,3 +77,31 @@ (t/deftest test-enter-toggles-path-editing-mode (doseq [shape-type [:rect :circle :path :image]] (run-scenario shape-type))) + +(t/deftest resolve-edit-fills-with-normal-parent-chain + (t/testing "resolve-edit-fills resolves fills from parent chain" + (let [objects {1 {:type :group :parent-id 2 :fills [{:fill-color "#ff0000"}]} + 2 {:type :frame :parent-id nil :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should inherit fills from group parent + (t/is (= [{:fill-color "#ff0000"}] result))))) + +(t/deftest resolve-edit-fills-with-circular-parent-chain + (t/testing "resolve-edit-fills handles circular parent references gracefully" + (let [objects {1 {:type :rect :parent-id 2 :fills []} + 2 {:type :rect :parent-id 1 :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should return empty fills instead of infinite loop + (t/is (= [] result))))) + +(t/deftest resolve-edit-fills-with-empty-intermediate-group + (t/testing "resolve-edit-fills traverses past empty parent groups to find fills from ancestor" + (let [objects {1 {:type :group :parent-id 2 :fills []} + 2 {:type :group :parent-id 3 :fills [{:fill-color "#00ff00"}]} + 3 {:type :frame :parent-id nil :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should inherit fills from OuterGroup through empty InnerGroup + (t/is (= [{:fill-color "#00ff00"}] result))))) diff --git a/frontend/test/frontend_tests/logic/path_actions_test.cljs b/frontend/test/frontend_tests/logic/path_actions_test.cljs new file mode 100644 index 0000000000..09451937ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_actions_test.cljs @@ -0,0 +1,120 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.path-actions-test + (:require + [app.common.geom.point :as gpt] + [app.common.types.path :as path] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.ui.workspace.viewport.path-actions :as path.actions] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth])) + +(t/deftest mixed-corner-and-curve-selection-enables-both-conversions + (let [content (pth/mixed-corner-curve-content) + points (path/get-points content) + enabled (path.helpers/check-enabled content #{0 1})] + (t/is (false? (path/is-curve-point? content (first points)))) + (t/is (true? (path/is-curve-point? content (second points)))) + (t/is (true? (:make-corner enabled))) + (t/is (true? (:make-curve enabled))))) + +(t/deftest action-eligibility-keeps-coincident-node-identities + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}]) + enabled (path.helpers/check-enabled content #{0 2})] + (t/is (true? (:make-corner enabled))) + (t/is (true? (:make-curve enabled))) + (t/is (true? (:merge-nodes enabled))) + (t/is (true? (:join-nodes enabled))))) + +(t/deftest toolbar-separators-only-render-between-visible-tool-groups + (t/are [structural? shape? handler? expected] + (= expected + (path.actions/toolbar-group-visibility structural? shape? handler?)) + false false false + {:shape-handler-visible? false + :node-groups-separator-visible? false + :snap-separator-visible? false} + + true false false + {:shape-handler-visible? false + :node-groups-separator-visible? false + :snap-separator-visible? true} + + false true false + {:shape-handler-visible? true + :node-groups-separator-visible? false + :snap-separator-visible? true} + + true true false + {:shape-handler-visible? true + :node-groups-separator-visible? true + :snap-separator-visible? true} + + true false true + {:shape-handler-visible? true + :node-groups-separator-visible? true + :snap-separator-visible? true})) + +(t/deftest handler-toolbar-represents-equal-and-mixed-multi-node-modes + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]) + nodes #{1 2}] + ;; Matching nodes share one active mode. + (t/is (= {:nodes #{1 2} :active-type :mirror} + (path.helpers/handler-selection-state content {} nodes))) + ;; Stored mixed modes return `:mixed`. + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {2 :aligned} nodes))) + (t/is (= {:nodes #{1 2} :active-type :aligned} + (path.helpers/handler-selection-state + content {1 :aligned 2 :aligned} nodes))) + (t/is (= :open (path.helpers/handler-trigger-action :mixed))) + (t/is (= :select (path.helpers/handler-trigger-action :mirror))))) + +(t/deftest handler-toolbar-detects-derived-independent-and-mirror-targets + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 5 :x 20 :y 0}}]) + node-targets #{1 2} + handler-targets (path.helpers/handler-target-nodes + content + {:nodes #{} + :segments #{} + :handlers #{[1 :c2] [2 :c2]}})] + (t/is (= :mirror (path.helpers/derive-handler-type content 1))) + (t/is (= :independent (path.helpers/derive-handler-type content 2))) + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {} node-targets))) + (t/is (= #{1 2} handler-targets)) + (t/is (= {:nodes #{1 2} :active-type :mixed} + (path.helpers/handler-selection-state content {} handler-targets))))) + +(t/deftest opposite-handler-target-matches-handler-mode + (let [node (gpt/point 10 0) + handler (gpt/point 14 3) + opposite (gpt/point 6 0)] + (t/is (= (gpt/point 6 -3) + (path.helpers/opposite-handler-target node handler opposite :mirror))) + (t/is (= 4 + (gpt/distance + node + (path.helpers/opposite-handler-target node handler opposite :aligned)))))) + diff --git a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs new file mode 100644 index 0000000000..5e79a7447a --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs @@ -0,0 +1,257 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-clipboard-test + (:require + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.path.clipboard :as path.clipboard] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.streams :as ms] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [clojure.set :as set] + [frontend-tests.helpers.state :as ths] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest cut-selected-nodes-copies-then-removes + ;; Cut emits copy followed by the regular delete action. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{1} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.clipboard/cut-selected-nodes) state nil) + (rx/subs! #(swap! events conj %))) + del (atom []) + _ (->> (ptk/watch (second @events) state nil) + (rx/subs! #(swap! del conj %))) + state' (ptk/update (first @del) state) + nodes (count (path/get-points (get-in state' [:workspace-drawing :object :content])))] + ;; two events emitted (copy, then the removal) + (t/is (= 2 (count @events))) + ;; The removal leaves fewer than three nodes. + (t/is (< nodes 3)))) + +(t/deftest duplicate-selection-content-copies-nodes-and-segments + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; A lone node copies its incoming segment. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{2} :segments #{}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 10 :y 0}] + [:line-to {:x 30 :y 10}]] + (mapv (juxt :command :params) sub))) + ;; only the new endpoint (index 1) is selected, not the attach point + (t/is (= #{1} selected))) + ;; Interior-node copies meet at one offset node. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{1} :segments #{}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 0 :y 0}] + [:line-to {:x 20 :y 10}] + [:move-to {:x 20 :y 0}] + [:line-to {:x 20 :y 10}]] + (mapv (juxt :command :params) sub))) + (t/is (= #{1 3} selected))) + ;; Segment copies select both offset endpoints. + (let [{:keys [sub selected]} + (path.helpers/duplicate-selection-content + content {:nodes #{} :segments #{1}} (gpt/point 10 10))] + (t/is (= [[:move-to {:x 10 :y 10}] + [:line-to {:x 20 :y 10}]] + (mapv (juxt :command :params) sub))) + (t/is (= #{0 1} selected))))) + +(t/deftest duplicate-offset-stays-constant-in-screen-pixels + (t/is (= (gpt/point 10 10) (path.edition/duplicate-offset 1))) + (t/is (= (gpt/point 2.5 2.5) (path.edition/duplicate-offset 4))) + (t/is (= (gpt/point 20 20) (path.edition/duplicate-offset 0.5)))) + +(t/deftest splice-duplicated-appends-copies-and-selects-only-new-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}]) + result (path.helpers/duplicate-selection-content + content {:nodes #{2} :segments #{}} (gpt/point 10 10)) + state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}}) + state' (ptk/update (path.edition/splice-duplicated result) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; Append the copy as a new subpath. + (t/is (= 5 (count content'))) + ;; Select only the new endpoint. + (t/is (= #{4} + (get-in state' [:workspace-local :edit-path id :selection :nodes]))))) + +(t/deftest pasting-path-content-splices-and-selects-new-nodes + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{1} + :segments #{} + :handlers #{}}) + sub [{:command :move-to :params {:x 30 :y 30}} + {:command :line-to :params {:x 40 :y 30}}] + ;; Center the pasted fragment at the pointer. + _ (rx/push! ms/mouse-position (gpt/point 100 100)) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (vec (get-in state' [:workspace-drawing :object :content])) + pasted (subvec content' 3)] + (t/is (= (vec content) (subvec content' 0 3))) + (t/is (= {:x 95 :y 100} (select-keys (:params (first pasted)) [:x :y]))) + (t/is (= {:x 105 :y 100} (select-keys (:params (second pasted)) [:x :y]))) + (t/is (= {:nodes #{3 4} + :segments #{} + :handlers #{}} + (get-in state' [:workspace-local :edit-path id :selection]))))) + +(t/deftest pasting-over-identical-nodes-offsets-the-fragment + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content path.helpers/empty-selection) + ;; Same coordinates as the existing segment between nodes 0 and 1 + sub [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}}] + ;; Overlapping pasted nodes receive the collision offset. + _ (rx/push! ms/mouse-position (gpt/point 5 0)) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (vec (get-in state' [:workspace-drawing :object :content])) + pasted (subvec content' 3)] + ;; Pasted nodes do not overlap existing nodes. + (t/is (= {:x 10 :y 10} (select-keys (:params (first pasted)) [:x :y]))) + (t/is (= {:x 20 :y 10} (select-keys (:params (second pasted)) [:x :y]))))) + +(t/deftest pasting-finds-a-free-offset-after-more-than-one-hundred-collisions + (let [id (random-uuid) + content (path/content + (into [{:command :move-to :params {:x 0 :y 0}}] + (map (fn [step] + {:command :line-to + :params {:x (* step 10) :y (* step 10)}})) + (range 1 101))) + state (pth/selectable-path-state id content path.helpers/empty-selection) + sub (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 1 :y 0}}]) + _ (rx/push! ms/mouse-position nil) + state' (ptk/update (path.clipboard/paste-content sub) state) + content' (get-in state' [:workspace-drawing :object :content]) + pasted (take-last 2 content')] + (t/is (= [{:x 1010 :y 1010} {:x 1011 :y 1010}] + (mapv #(select-keys (:params %) [:x :y]) pasted))) + (t/is (empty? (set/intersection + (set (path/get-points content)) + (set (path/get-points pasted))))))) + +(defn- page-paths + [state] + (->> (:objects (cthf/current-page (ths/get-file-from-state state))) + vals + (filter #(= :path (:type %))))) + +(t/deftest pasting-path-nodes-outside-editor-creates-a-new-path-shape + (t/async + done + (let [file (pth/setup-rect-file) + store (ths/setup-store file) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 40 :y 0}} + {:command :line-to :params {:x 40 :y 40}}]) + target (gpt/point 300 300)] + ;; a new path shape is centred at the pointer position + (rx/push! ms/mouse-position target) + (ths/run-store + store done + [(path.clipboard/paste-nodes-as-shape content)] + (fn [new-state] + (let [paths (page-paths new-state) + pasted (first paths)] + (t/is (= 1 (count paths))) + (when pasted + (t/is (= target (grc/rect->center (:selrect pasted)))) + (t/is (contains? (get-in new-state [:workspace-local :selected]) (:id pasted)))))))))) + +(t/deftest pasting-path-nodes-while-editing-does-not-create-a-shape + (t/async + done + (let [file (pth/setup-rect-file) + id (:id (cths/get-shape file :rect1)) + store (ths/setup-store file) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 40 :y 0}}]) + ;; Outside-editor paste does nothing during path editing. + events (conj (pth/start-path-edition-events id) + (path.clipboard/paste-nodes-as-shape content))] + (rx/push! ms/mouse-position (gpt/point 300 300)) + (ths/run-store + store done events + (fn [new-state] + (t/is (empty? (page-paths new-state)))))))) + +(t/deftest collision-step-with-exact-coordinates + (t/testing "collision-step detects collision with exact coordinates" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.0 20.0) + step (path.clipboard/collision-step pasted existing)] + ;; Should detect collision at step 1 + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest collision-step-with-floating-point-coordinates + (t/testing "collision-step detects collision with floating-point rounding differences" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.001 20.002) + step (path.clipboard/collision-step pasted existing)] + ;; x-step = 1.0001, y-step = 1.0002 + ;; With tolerance, these should be considered equal + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest available-offset-step-with-exact-coordinates + (t/testing "available-offset-step finds first available step with exact coordinates" + (let [existing #{(gpt/point 20.0 20.0)} + pasted #{(gpt/point 10.0 10.0)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should find step 0 (no collision at step 0) + (t/is (= 0 step))))) + +(t/deftest available-offset-step-with-floating-point-coordinates + (t/testing "available-offset-step finds first available step with floating-point rounding differences" + (let [existing #{(gpt/point 20.0001 20.0002)} + pasted #{(gpt/point 10.0001 10.0002)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should detect collision at step 1 and return step 0 as available + (t/is (= 0 step))))) + +(t/deftest collision-step-with-coordinates-slightly-below-integer + (t/testing "collision-step detects collision when coordinates drift slightly below integer boundary" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 19.999 19.999) + step (path.clipboard/collision-step pasted existing)] + ;; x-step = 0.9999, y-step = 0.9999 + ;; With round-based check, these should be detected as collision at step 1 + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest available-offset-step-with-coordinates-slightly-below-integer + (t/testing "available-offset-step detects collision with sub-integer coordinate drift" + (let [existing #{(gpt/point 19.999 19.999)} + pasted #{(gpt/point 10.0 10.0)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should detect collision at step 1 and return step 0 as available + (t/is (= 0 step))))) diff --git a/frontend/test/frontend_tests/logic/path_helpers_test.cljs b/frontend/test/frontend_tests/logic/path_helpers_test.cljs new file mode 100644 index 0000000000..ba0c28038f --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_helpers_test.cljs @@ -0,0 +1,117 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-helpers-test + (:require + [app.common.geom.point :as gpt] + [app.common.types.path :as path] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.streams :as path.streams] + [app.main.store :as st] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth])) + +(t/deftest to-pixel-snap-quantises-to-half-pixels-past-the-zoom-threshold + ;; Pixel snapping uses half steps above 300% zoom. + (let [original @st/state + snap (fn [zoom p] + (reset! st/state {:workspace-layout #{:snap-pixel-grid} + :workspace-local {:zoom zoom}}) + (path.streams/to-pixel-snap p))] + (try + ;; at or below the threshold, snapping rounds to whole pixels + (t/is (= (gpt/point 100 100) (snap 1 (gpt/point 100.4 100.4)))) + (t/is (= (gpt/point 100 100) (snap 3 (gpt/point 100.4 100.4)))) + ;; above 300% zoom it rounds to the nearest half pixel + (t/is (= (gpt/point 100.5 100.5) (snap 6 (gpt/point 100.4 100.4)))) + ;; with pixel snapping off the position passes through unchanged + (reset! st/state {:workspace-layout #{} :workspace-local {:zoom 6}}) + (t/is (= (gpt/point 100.4 100.4) + (path.streams/to-pixel-snap (gpt/point 100.4 100.4)))) + (finally + (reset! st/state original))))) + +(t/deftest node-merge-snap-finds-the-closest-target-for-multiple-moving-points + (let [start-point (gpt/point 0 0) + selected-points #{start-point (gpt/point 100 0)} + points (into selected-points + [(gpt/point 8 14) + (gpt/point 111 10.5) + (gpt/point 500 500)]) + snap-position (path.streams/make-node-merge-snap + start-point selected-points points 10)] + ;; The closest merge target moves the full selection. + (t/is (= (gpt/point 11 10.5) + (snap-position (gpt/point 10 10)))) + ;; Missing merge targets return no snap delta. + (t/is (nil? (snap-position (gpt/point 300 300)))))) + +(t/deftest insertion-preview-reuses-precomputed-segment-midpoints + (let [content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 10 :c1y 0 + :c2x 20 :c2y 10 + :x 20 :y 0}} + {:command :close-path :params {}}]) + midpoints (path.helpers/insertion-mid-points content) + line-mid (first midpoints) + curve-mid (second midpoints)] + (t/is (= 2 (count midpoints))) + (t/is (= (gpt/point 5 0) line-mid)) + (t/is (= {:from-p (gpt/point 0 0) + :to-p (gpt/point 10 0) + :t 0.5} + (meta line-mid))) + (t/is (= line-mid + (path.helpers/insertion-point + content (gpt/point 5.5 0) 1 false midpoints))) + (t/is (nil? (path.helpers/insertion-point + content (gpt/point 200 200) 1 false midpoints))) + ;; Alt/insert-anywhere remains dynamic and ignores the midpoint cache. + (t/is (some? (path.helpers/insertion-point + content curve-mid 1 true []))))) + +(t/deftest selected-node-indices-folds-segment-endpoints + (let [content (pth/selectable-path-content)] + ;; segment index 1 connects nodes 0 and 1 + (t/is (= #{0 1} + (path.helpers/selected-node-indices content {:nodes #{} :segments #{1}}))) + ;; explicit nodes and segment endpoints are unioned + (t/is (= #{0 1 2} + (path.helpers/selected-node-indices content {:nodes #{2} :segments #{1}}))))) + +(t/deftest remap-selection-follows-content-structure + (let [content (pth/selectable-path-content) + ;; Same command layout: index 1 turned into a line-to + corner (path/content + (assoc (vec content) 1 {:command :line-to + :params {:x 10 :y 0}})) + ;; Different layout: the middle node was removed + shorter (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 20 :y 0}}]) + selection {:nodes #{1 2} + :segments #{2} + :handlers #{[1 :c1] [2 :c2]}}] + (t/is (= {:nodes #{1 2} + :segments #{2} + :handlers #{[2 :c2]}} + (path.helpers/remap-selection selection content corner))) + (t/is (= {:nodes #{1} + :segments #{} + :handlers #{}} + (path.helpers/remap-selection selection content shorter))))) + +(t/deftest handlers-joined-detects-smooth-vs-corner-nodes + ;; node (10,0): incoming [1 :c2]=(8,0), outgoing [2 :c1] + (t/is (path.helpers/handlers-joined? (pth/selectable-path-content) 2 :c1)) + (t/is (not (path.helpers/handlers-joined? (pth/corner-path-content) 2 :c1)))) + diff --git a/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs new file mode 100644 index 0000000000..66b20090c8 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs @@ -0,0 +1,542 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-lifecycle-test + (:require + [app.common.data.undo-stack :as u] + [app.common.geom.point :as gpt] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.drawing.common :as dwdc] + [app.main.data.workspace.edition :as dwe] + [app.main.data.workspace.path.changes :as path.changes] + [app.main.data.workspace.path.common :as path.common] + [app.main.data.workspace.path.drawing :as path.drawing] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.selection :as path.selection] + [app.main.data.workspace.path.shortcuts :as path.shortcuts] + [app.main.data.workspace.path.state :as path.state] + [app.main.data.workspace.path.streams :as path.streams] + [app.main.data.workspace.path.tools :as path.tools] + [app.main.data.workspace.path.undo :as path.undo] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest path-lifecycle-selectors-use-the-active-path + (let [id (random-uuid) + edit-state {:edit-mode :draw} + edit-path {id edit-state} + state {:workspace-local {:edition id + :edit-path edit-path} + :workspace-drawing {:object {:id id :type :path}}}] + (t/is (= edit-state (path.state/current-edit-state state))) + (t/is (= edit-state (path.state/current-edit-state edit-path id))) + (t/is (path.state/editing? state)) + (t/is (path.state/editing? edit-path id)) + (t/is (path.state/drawing? edit-state id :path {:id id :type :path})))) + +(t/deftest path-drawing-selector-finds-new-paths + (let [id (random-uuid) + state {:workspace-local {:edition nil + :edit-path {id {}}} + :workspace-drawing {:tool :path + :object {:id id :type :path}}}] + (t/is (path.state/drawing? state)) + (t/is (not (path.state/editing? state))) + (t/is (path.state/drawing? nil nil :path {:id id :type :path})) + (t/is (not (path.state/drawing? nil nil :curve {:id id :type :path}))))) + +(t/deftest clear-edition-mode-finishes-path-streams + (t/is (path.streams/finish-edition? (dwe/clear-edition-mode))) + (t/is (not (path.streams/finish-edition? :interrupt)))) + +(t/deftest clear-edition-mode-finishes-active-path-before-finalizing + (let [id (random-uuid) + event (dwe/clear-edition-mode) + state {:workspace-local {:edition id + :edit-path {id {:edit-mode :move}}} + :workspace-drawing {:object {:id id}}} + state' (ptk/update event state) + emissions (atom [])] + (->> (ptk/watch event state' nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (nil? (get-in state' [:workspace-local :edition]))) + (t/is (some? (get-in state' [:workspace-local :edit-path id]))) + (t/is (= [::path.common/finish-path] + (mapv ptk/type @emissions))))) + +(t/deftest clear-non-path-edition-does-not-emit-finish-path + (let [id (random-uuid) + event (dwe/clear-edition-mode) + state {:workspace-local {:edition id}} + state' (ptk/update event state) + emissions (atom [])] + (->> (ptk/watch event state' nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (nil? (get-in state' [:workspace-local :edition]))) + (t/is (empty? @emissions)))) + +(t/deftest restarting-draw-mode-finishes-pending-subpath + (let [file (pth/setup-rect-file) + shape (-> (cths/get-shape file :rect1) + (path/convert-to-path)) + id (:id shape) + last-point (last (path/get-points (:content shape))) + state {:workspace-local + {:edition id + :edit-path + {id {:edit-mode :draw + :last-point last-point + :preview {:command :line-to + :params {:x 150 :y 150}} + :old-content (:content shape)}}} + :workspace-drawing {:object shape}} + stream (rx/subject) + emissions (atom [])] + (->> (ptk/watch (path.drawing/start-draw-mode*) state stream) + (rx/take 4) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? true})) + (t/is (= [::path.drawing/start-edition + ::path.common/finish-path + ::path.drawing/check-changed-content + ::path.drawing/start-draw-mode*] + (mapv ptk/type @emissions))) + (let [state' (ptk/update (second @emissions) state) + state'' (ptk/update (path.drawing/preview-next-point + {:x 200 :y 200}) + state')] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))) + (t/is (= :move-to + (get-in state'' [:workspace-local :edit-path id :preview :command])))))) + +(t/deftest escape-does-not-restart-edited-path-draw-loop + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw}}}} + stream (rx/subject) + emissions (atom [])] + (t/is (path.drawing/restart-draw-loop? (path.common/finish-path))) + (->> (ptk/watch (path.drawing/start-draw-mode*) state stream) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? false})) + (t/is (= [::path.drawing/start-edition] + (mapv ptk/type @emissions))))) + +(defn- run-handle-drawing-end + "Runs the draw-ending flow and passes its events to `callback`." + [restart? callback] + (let [state (pth/drawing-path-state) + stream (rx/subject) + emissions (atom [])] + (->> (ptk/watch (path.drawing/handle-drawing) state stream) + (rx/subs! #(swap! emissions conj %))) + (rx/push! stream (ptk/data-event ::path.drawing/end-edition + {:restart? restart?})) + ;; Wait for the asynchronous drawing-end event. + (js/setTimeout + (fn [] + (let [end-event (last @emissions) + end-emissions (atom [])] + (->> (ptk/watch end-event state stream) + (rx/subs! #(swap! end-emissions conj %))) + (callback @end-emissions)))))) + +(t/deftest escape-ending-new-path-draw-does-not-reenter-edition + (t/async + done + (run-handle-drawing-end + false + (fn [emissions] + (t/is (= [::path.drawing/close-drawn-loops + ::path.drawing/setup-frame + ::dwdc/handle-finish-drawing + ::dwe/clear-edition-mode] + (mapv ptk/type emissions))) + (done))))) + +(t/deftest finishing-new-path-draw-reenters-edition + (t/async + done + (run-handle-drawing-end + true + (fn [emissions] + (t/is (= [::path.common/finish-path + ::path.drawing/close-drawn-loops + ::path.drawing/setup-frame + ::dwdc/handle-finish-drawing + ::path.drawing/start-created-path-edition] + (mapv ptk/type emissions))) + (done))))) + +(t/deftest escape-with-pending-segment-cancels-it-and-keeps-drawing + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 10 10) + :preview {:command :line-to + :params {:x 20 :y 20}}}}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (= [::path.common/cancel-pending-segment] + (mapv ptk/type @emissions))) + (let [state' (ptk/update (first @emissions) state)] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))) + (t/is (= :draw (get-in state' [:workspace-local :edit-path id :edit-mode])))))) + +(t/deftest escape-while-creating-path-finishes-it-into-edition + (let [id (random-uuid) + state {:workspace-local + {:edit-path {id {:edit-mode :draw + :last-point (gpt/point 10 10)}}} + :workspace-drawing {:object {:id id :type :path}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + ;; Finishing creates the shape and clears its pending segment. + (t/is (= [::path.common/finish-path] + (mapv ptk/type @emissions))))) + +(t/deftest escape-without-pending-segment-interrupts-edition + (let [id (random-uuid) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw}}}} + emissions (atom [])] + (->> (ptk/watch (path.shortcuts/esc-pressed) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (= [:interrupt] @emissions)))) + +(t/deftest editing-path-only-updates-drawing-copy + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + delta (gpt/point 10 5) + store (ths/setup-store file) + events (conj (pth/start-path-edition-events id) + (pth/move-drawing-content delta))] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1) + drawing-copy (get-in new-state [:workspace-drawing :object])] + (t/is (= original-rect stored-shape)) + (t/is (= :path (:type drawing-copy))) + (t/is (= (path/move-content + (:content (path/convert-to-path original-rect)) + delta) + (:content drawing-copy))))))))) + +(t/deftest unchanged-path-edition-preserves-simple-shape + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + store (ths/setup-store file) + events (conj (pth/start-path-edition-events id) :interrupt)] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= original-rect stored-shape)) + (t/is (= :rect (:type stored-shape))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest changed-path-edition-is-persisted-when-finalized + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + delta (gpt/point 10 5) + original-path (path/convert-to-path original-rect) + ;; Persist the rectangle with an explicit close command. + changed-content (-> (:content original-path) + (path/move-content delta) + (path/close-loops)) + expected-shape (-> original-path + (assoc :content changed-content) + (path/update-geometry)) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(pth/move-drawing-content delta) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= expected-shape stored-shape)) + (t/is (= :path (:type stored-shape))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest created-path-edition-cleans-drawing-state-on-exit + (t/async + done + (let [file (pth/setup-rect-file) + original-rect (cths/get-shape file :rect1) + id (:id original-rect) + store (ths/setup-store file) + events [(path.drawing/start-created-path-edition id) + :interrupt]] + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + stored-shape (cths/get-shape file' :rect1)] + (t/is (= original-rect stored-shape)) + (t/is (nil? (get-in new-state [:workspace-local :edition]))) + (t/is (nil? (get-in new-state [:workspace-local :edit-path id]))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(defn- with-dangling-subpath-start + [content] + (path/content (conj (vec content) + {:command :move-to + :params {:x 30 :y 40}}))) + +(t/deftest cancel-pending-segment-drops-dangling-subpath-start + (let [id (random-uuid) + content (pth/selectable-path-content) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 30 40) + :preview {:command :line-to + :params {:x 50 :y 50}}}}} + :workspace-drawing + {:object {:id id + :type :path + :content (with-dangling-subpath-start content)}}} + state' (ptk/update (path.common/cancel-pending-segment) state)] + (t/is (= (vec content) + (vec (get-in state' [:workspace-drawing :object :content])))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :preview]))))) + +(t/deftest finish-path-drops-dangling-subpath-start + (let [id (random-uuid) + content (pth/selectable-path-content) + state {:workspace-local + {:edition id + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 30 40)}}} + :workspace-drawing + {:object {:id id + :type :path + :content (with-dangling-subpath-start content)}}} + state' (ptk/update (path.common/finish-path) state)] + (t/is (= (vec (path/close-subpaths content)) + (vec (get-in state' [:workspace-drawing :object :content])))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))))) + +(t/deftest finalize-ignores-dangling-subpath-start + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + path-shape (path/convert-to-path rect) + old-content (:content path-shape) + state {:current-file-id (:id file) + :current-page-id (cthf/current-page-id file) + :files {(:id file) file} + :workspace-local + {:edition id + :edit-path {id {:old-content old-content}}} + :workspace-drawing + {:object (assoc path-shape + :content + (with-dangling-subpath-start old-content))}} + emissions (atom [])] + (->> (ptk/watch (path.changes/finalize-path-content id) state nil) + (rx/subs! #(swap! emissions conj %))) + (t/is (empty? @emissions)))) + +(t/deftest next-point-preview-is-suppressed-during-a-modifier-drag + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 100 :y 0}}]) + mk (fn [modifiers] + ;; Draw mode keeps the path in the drawing object. + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :content-modifiers modifiers}}}}) + event (path.drawing/preview-next-point {:x 150 :y 40 :shift? false}) + idle (ptk/update event (mk {})) + during (ptk/update event (mk {1 {:c1x 5 :c1y 5}}))] + ;; no active drag: the next-point preview updates as usual + (t/is (some? (get-in idle [:workspace-local :edit-path id :preview]))) + ;; a placed handler is being dragged mid-draw: the preview must not move + (t/is (nil? (get-in during [:workspace-local :edit-path id :preview]))))) + +(t/deftest dragging-the-current-curve-forward-handle-while-drawing + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}]) + mk (fn [] + ;; Store the backward handle and transient forward handle. + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :prev-handler (gpt/point 130 0)}}}}) + drag (fn [alt?] + (ptk/update (path.drawing/drag-prev-handler + {:x 100 :y 50 :alt? alt? :shift? false}) + (mk))) + finish (fn [state] (ptk/update (path.drawing/finish-drag) state)) + c2-of (fn [state] + (-> (get-in state [:workspace-drawing :object :content]) + (vec) (nth 1) :params (select-keys [:c2x :c2y])))] + + (t/testing "no alt: the forward handle follows the pointer and the committed backward handle mirrors it" + (let [dragged (drag false)] + ;; the forward handle tracks the pointer + (t/is (= (gpt/point 100 50) + (get-in dragged [:workspace-local :edit-path id :drag-handler]))) + ;; the stale transient forward handle is cleared so it is not double-rendered + (t/is (nil? (get-in dragged [:workspace-local :edit-path id :prev-handler]))) + (let [committed (finish dragged)] + ;; c2 = 2*node - forward = (100,-50) + (t/is (= {:c2x 100 :c2y -50} (c2-of committed))) + ;; the new forward handle becomes the prev-handler + (t/is (= (gpt/point 100 50) + (get-in committed [:workspace-local :edit-path id :prev-handler])))))) + + (t/testing "alt: the forward handle moves on its own, the backward handle stays put" + (let [committed (finish (drag true))] + (t/is (= {:c2x 70 :c2y 0} (c2-of committed))))))) + +(t/deftest dragging-the-current-curve-backward-handle-while-drawing + ;; Dragging the backward handle mirrors the transient forward handle. + (let [id (random-uuid) + content (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}]) + mk (fn [] + {:workspace-drawing {:object {:id id :type :path :content content}} + :workspace-local {:edition nil + :zoom 1 + :edit-path {id {:edit-mode :draw + :last-point (gpt/point 100 0) + :prev-handler (gpt/point 130 0)}}}}) + ;; Drag the backward handle to `(70, -40)`. + drag (fn [mode] + (ptk/update (path.edition/modify-selected-handlers + id [1 :c2] {} 0 -40 mode (= mode :smart)) + (mk))) + prev-of (fn [state] (get-in state [:workspace-local :edit-path id :prev-handler]))] + + (t/testing "smart (no modifier): the forward handle mirrors the angle, keeping its own length" + ;; Keep the forward handle's length while mirroring its angle. + (t/is (= (gpt/point 118 24) (prev-of (drag :smart))))) + + (t/testing "mirror (mod): the forward handle full-mirrors to equal length" + ;; Mirror the forward handle around the node. + (t/is (= (gpt/point 130 40) (prev-of (drag :mirror))))) + + (t/testing "independent (alt): the forward handle is left untouched" + (t/is (= (gpt/point 130 0) (prev-of (drag :independent))))))) + +(t/deftest path-local-undo-redo-restores-content-and-clears-preview + (let [id (random-uuid) + content-a (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}}]) + content-b (path/content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 5}}]) + base (-> (pth/selectable-path-state id content-a path.helpers/empty-selection) + (assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack))) + ;; Capture both content states around a stale preview. + s1 (ptk/update (path.undo/add-undo-entry) base) + s2 (-> (path.state/set-content s1 content-b) + (assoc-in [:workspace-local :edit-path id :preview] + {:command :line-to :params {:x 99 :y 99}})) + s3 (ptk/update (path.undo/add-undo-entry) s2) + s4 (ptk/update (path.undo/undo-path) s3) + s5 (ptk/update (path.undo/redo-path) s4)] + (t/is (= content-b (path.state/get-path s3 :content))) + (t/is (= content-a (path.state/get-path s4 :content))) + ;; Restoring an entry drops its render-only preview. + (t/is (nil? (get-in s4 [:workspace-local :edit-path id :preview]))) + (t/is (= content-b (path.state/get-path s5 :content))))) + +(t/deftest path-undo-entry-never-captures-the-transient-preview + (let [id (random-uuid) + state (-> (pth/selectable-path-state id (pth/selectable-path-content) + path.helpers/empty-selection) + (assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack)) + (assoc-in [:workspace-local :edit-path id :preview] + {:command :line-to :params {:x 99 :y 99}})) + state' (ptk/update (path.undo/add-undo-entry) state) + entry (u/peek (get-in state' [:workspace-local :edit-path id :undo-stack]))] + (t/is (some? entry)) + (t/is (not (contains? entry :preview))))) + +;; Tool operations through the full edition lifecycle. + +(t/deftest tool-make-curve-persists-through-edition-lifecycle + (t/async + done + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(path.selection/select-node 1 false) + (path.tools/make-curve) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)] + (t/is (= :path (:type stored))) + ;; make-curve on a corner introduces at least one curve segment + (t/is (some #(= :curve-to (:command %)) (seq (:content stored)))) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + +(t/deftest tool-remove-node-persists-through-edition-lifecycle + (t/async + done + (let [file (pth/setup-rect-file) + rect (cths/get-shape file :rect1) + id (:id rect) + orig-nodes (count (path/get-points (:content (path/convert-to-path rect)))) + store (ths/setup-store file) + events (into (pth/start-path-edition-events id) + [(path.selection/select-node 1 false) + (path.tools/remove-node) + :interrupt])] + (ths/run-store + store done events + (fn [new-state] + (let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)] + (t/is (= :path (:type stored))) + ;; removing a node leaves fewer nodes than the converted rect had + (t/is (< (count (path/get-points (:content stored))) orig-nodes)) + (t/is (nil? (get-in new-state [:workspace-drawing :object]))))))))) + diff --git a/frontend/test/frontend_tests/logic/path_test_helpers.cljs b/frontend/test/frontend_tests/logic/path_test_helpers.cljs new file mode 100644 index 0000000000..d55fcd17a4 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_test_helpers.cljs @@ -0,0 +1,94 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-test-helpers + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.shapes :as cths] + [app.common.types.path :as path] + [app.main.data.workspace.edition :as dwe] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.state :as path.state])) + +(defn setup-rect-file + [] + (ctho/add-rect (cthf/sample-file :file1) + :rect1 + :x 10 + :y 20 + :width 100 + :height 80)) + +(defn start-path-edition-events + [id] + [(dwe/start-edition-mode id) + (path.edition/start-path-edit id)]) + +(defn move-drawing-content + [delta] + (fn [state] + (path.state/set-content + state + (path/move-content (path.state/get-path state :content) delta)))) + +(defn drawing-path-state + [] + (let [file (setup-rect-file) + shape (-> (cths/get-shape file :rect1) + (path/convert-to-path))] + {:workspace-drawing {:object shape}})) + +(defn selectable-path-content + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 + :c2x 18 :c2y 0 + :x 20 :y 0}}])) + +(defn selectable-path-state + [id content selection] + {:workspace-local {:edition id + :edit-path {id {:selection selection}}} + :workspace-drawing {:object {:id id + :type :path + :content content}}}) + +(defn mixed-corner-curve-content + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 4 + :c2x 18 :c2y 4 + :x 20 :y 0}}])) + +(defn corner-path-content + "Returns selectable content with a corner at `(10, 0)`." + [] + (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 + :c2x 8 :c2y 0 + :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 6 + :c2x 18 :c2y 0 + :x 20 :y 0}}])) + diff --git a/frontend/test/frontend_tests/logic/path_tools_test.cljs b/frontend/test/frontend_tests/logic/path_tools_test.cljs new file mode 100644 index 0000000000..fd286ff728 --- /dev/null +++ b/frontend/test/frontend_tests/logic/path_tools_test.cljs @@ -0,0 +1,804 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + + +(ns frontend-tests.logic.path-tools-test + (:require + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.types.path :as path] + [app.main.data.workspace.path.drawing :as path.drawing] + [app.main.data.workspace.path.edition :as path.edition] + [app.main.data.workspace.path.helpers :as path.helpers] + [app.main.data.workspace.path.selection :as path.selection] + [app.main.data.workspace.path.state :as path.state] + [app.main.data.workspace.path.tools :as path.tools] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.logic.path-test-helpers :as pth] + [potok.v2.core :as ptk])) + +(t/deftest mixed-node-conversions-only-change-opposite-node-type + (let [id (random-uuid) + content (pth/mixed-corner-curve-content) + points (path/get-points content) + corner-point (first points) + curve-point (second points) + state (pth/selectable-path-state + id content + {:nodes #{0 1} :segments #{} :handlers #{}}) + curved-state (ptk/update (path.tools/make-curve) state) + cornered-state (ptk/update (path.tools/make-corner) state) + curved-content (path.state/get-path curved-state :content) + corner-content (path.state/get-path cornered-state :content)] + (t/is (path/is-curve-point? curved-content corner-point)) + (t/is (= (nth content 2) (nth curved-content 2))) + (t/is (not (path/is-curve-point? corner-content corner-point))) + (t/is (not (path/is-curve-point? corner-content curve-point))))) + +(t/deftest plain-and-shift-selection-work-across-path-element-types + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state + id content + {:nodes #{0} + :segments #{1} + :handlers #{[1 :c1]}}) + state' (ptk/update (path.selection/select-handler 1 :c2 false) state) + state'' (ptk/update (path.selection/select-segment 2 true) state') + state''' (ptk/update (path.selection/select-handler 1 :c2 true) state'') + state'''' (ptk/update (path.selection/select-handler 2 :c1 true) state''')] + (t/is (= {:nodes #{} + :segments #{} + :handlers #{[1 :c2]}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{2} + :handlers #{[1 :c2]}} + (get-in state'' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{2} + :handlers #{[2 :c1]}} + (get-in state'''' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-prioritizes-nodes-over-segments-over-handlers + (let [id (random-uuid) + content (pth/selectable-path-content) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection) + ;; Contains segment 1 and handler [1 :c1] but no node + rect (grc/make-rect 1 -2 3 4) + state' (ptk/update (path.selection/select-path-area + rect selection false) + state) + ;; Contains node 0, segment 1 and handler [1 :c1] + node-rect (grc/make-rect -1 -1 4 2) + state'' (ptk/update (path.selection/select-path-area + node-rect selection false) + state)] + (t/is (= {:nodes #{} + :segments #{1} + :handlers #{}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{0} + :segments #{} + :handlers #{}} + (get-in state'' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-picks-handlers-only-when-nothing-else-is-inside + (let [id (random-uuid) + ;; Curve bulging up to y 7.5 with both handlers on y 10, away + ;; from the curve itself + content (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 0 :c1y 10 + :c2x 10 :c2y 10 + :x 10 :y 0}}]) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection) + ;; Contains only the [1 :c1] handler control point + handler-rect (grc/make-rect -1 9 2 2) + state' (ptk/update (path.selection/select-path-area + handler-rect selection false) + state) + ;; Contains both handlers and the top of the curve + mixed-rect (grc/make-rect -1 5 12 7) + state'' (ptk/update (path.selection/select-path-area + mixed-rect selection false) + state)] + (t/is (= {:nodes #{} + :segments #{} + :handlers #{[1 :c1]}} + (get-in state' [:workspace-local :edit-path id :selection]))) + (t/is (= {:nodes #{} + :segments #{1} + :handlers #{}} + (get-in state'' [:workspace-local :edit-path id :selection]))))) + +(t/deftest path-area-selection-ignores-empty-buffer-emissions + (let [id (random-uuid) + content (pth/selectable-path-content) + selection path.helpers/empty-selection + state (pth/selectable-path-state id content selection)] + (t/is (= state + (ptk/update (path.selection/select-path-area + nil selection false) + state))))) + +(t/deftest selected-segments-resolve-to-unique-endpoint-nodes + (let [content (pth/selectable-path-content)] + (t/is (= #{0 1} + (path.helpers/segment-node-indices content #{1}))) + (t/is (= #{0 1 2} + (path.helpers/segment-node-indices content #{1 2}))))) + +(t/deftest moving-selected-segments-translates-endpoints-and-handlers + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{} + :segments #{1} + :handlers #{}} + state (pth/selectable-path-state id content selection) + event (path.edition/move-selected-path-segment + (gpt/point 5 0) + (gpt/point 8 4)) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 5 4) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))))) + +(t/deftest moving-a-segment-between-selected-nodes-moves-the-node-selection + ;; A segment between selected nodes moves with the node selection. + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{0 1} :segments #{} :handlers #{}} + state (pth/selectable-path-state id content selection) + event (path.edition/move-selected-path-segment + (gpt/point 5 0) + (gpt/point 8 4)) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + ;; both selected nodes translate by (+3,+4); the unselected node stays put + (t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))) + +(t/deftest moving-selected-opposite-handlers-translates-both + (let [id (random-uuid) + content (pth/selectable-path-content) + selection {:nodes #{} + :segments #{} + :handlers #{[1 :c2] [2 :c1]}} + state (pth/selectable-path-state id content selection) + event (path.edition/modify-selected-handlers + id [1 :c2] {} 3 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= {:c2x 3 :c2y 4} (get modifiers 1))) + (t/is (= {:c1x 3 :c1y 4} (get modifiers 2))) + (t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))))) + +(t/deftest moving-selected-handlers-honours-each-explicit-node-mode + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]) + selection {:nodes #{} + :segments #{} + :handlers #{[2 :c1] [3 :c1]}} + state (-> (pth/selectable-path-state id content selection) + (assoc-in [:workspace-local :edit-path id :handler-types] + {1 :independent 2 :mirror})) + event (path.edition/modify-selected-handlers + id [2 :c1] {} 3 4 :independent true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + ;; Selected handlers receive the same drag delta. + (t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 25 4) (path/get-handler-point content' 3 :c1))) + ;; Each node applies its own mode to the opposite handle. + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 15 -4) (path/get-handler-point content' 2 :c2))))) + +(t/deftest arrow-move-nudges-selected-handlers + ;; Arrow keys nudge selected handlers. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[2 :c1]}}) + state' (ptk/update (path.edition/set-move-modifier [] #{[2 :c1]} (gpt/point 0 5)) + state) + mods (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content mods)] + (t/is (= {:c1x 0 :c1y 5} (get mods 2))) + ;; [2 :c1] base (12,0) -> (12,5); node 1 and its opposite handle stay put + (t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))))) + +(t/deftest arrow-move-nudges-selected-segment-endpoints + ;; Arrow keys nudge segment endpoints and their handles. + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + node-idx (path.helpers/segment-node-indices content #{1}) + points (path.helpers/node-positions content node-idx) + state' (ptk/update (path.edition/set-move-modifier points #{} (gpt/point 0 5)) + state) + mods (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content mods)] + ;; segment 1 connects node 0 (0,0) and node 1 (10,0); both move by (0,5) + (t/is (= (gpt/point 0 5) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 5) (path.helpers/node-position content' 1))) + ;; the endpoint nodes' handles move rigidly with them + (t/is (= (gpt/point 2 5) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1))))) + +(t/deftest align-nodes-aligns-selected-nodes-to-an-edge + ;; Aligning nodes updates the drawing content. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 4}} + {:command :line-to :params {:x 4 :y 20}}]) + state (pth/selectable-path-state id content + {:nodes #{0 1 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/align-nodes :hleft) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; every selected node's x becomes the min x (0), y is untouched + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 4) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 0 20) (path.helpers/node-position content' 2))))) + +(t/deftest distribute-nodes-spaces-selected-nodes-evenly + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 3 :y 5}} + {:command :line-to :params {:x 10 :y 9}}]) + state (pth/selectable-path-state id content + {:nodes #{0 1 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/distribute-nodes :horizontal) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the middle node is centered on x between the two extremes (0 and 10) + (t/is (= (gpt/point 5 5) (path.helpers/node-position content' 1))) + ;; the extreme nodes stay put + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 9) (path.helpers/node-position content' 2))))) + +(t/deftest set-selection-coordinate-moves-selected-points + ;; Coordinate edits move selected nodes and handlers. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; single node: only that node moves + (let [state (pth/selectable-path-state id content + {:nodes #{1} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :y 7) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 7) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))) + ;; multi node: every selected node's coordinate is set to the value + (let [state (pth/selectable-path-state id content + {:nodes #{0 2} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 5) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))))) + ;; a coincident closed-seam node moves as one logical node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :y 7) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 0 7) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2)))) + ;; a selected handler on an independent node moves only its own control point + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[1 :c1]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 4) state) + curve (nth (get-in state' [:workspace-drawing :object :content]) 1)] + ;; c1 x set to 4; c1y, c2 and the anchor untouched + (t/is (= 4 (get-in curve [:params :c1x]))) + (t/is (= 2 (get-in curve [:params :c1y]))) + (t/is (= 8 (get-in curve [:params :c2x]))) + (t/is (= 10 (get-in curve [:params :x]))))) + +(t/deftest set-selection-coordinate-mirrors-opposite-handler + ;; Moving a mirrored handler updates its opposite. + (let [id (random-uuid) + ;; node 1 (10,0) has collinear equal handles: c2 of cmd1 at (8,-2) and + ;; c1 of cmd2 at (12,2) — a mirror node by geometry + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to :params {:c1x 2 :c1y 0 :c2x 8 :c2y -2 :x 10 :y 0}} + {:command :curve-to :params {:c1x 12 :c1y 2 :c2x 18 :c2y 0 :x 20 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{[1 :c2]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 6) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; dragged handle c2 of cmd1 -> x=6 (keeps y=-2) + (t/is (= (gpt/point 6 -2) (path/get-handler-point content' 1 :c2))) + ;; opposite (c1 of cmd2) mirrors it about the node (10,0): 2*10-6=14, 2*0-(-2)=2 + (t/is (= (gpt/point 14 2) (path/get-handler-point content' 2 :c1))))) + +(t/deftest change-to-draw-mode-starts-a-line-from-the-selected-node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}}])] + ;; a middle node: opens a new subpath (move-to) at the node and makes it the + ;; pending origin, so the next click draws a line from it + (let [state (pth/selectable-path-state id content + {:nodes #{1} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 0) + (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (= 4 (count content'))) + (t/is (= :move-to (:command (nth content' 3)))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3)))) + ;; the drawing tip: just becomes the pending origin (extends), no new subpath + (let [state (pth/selectable-path-state id content + {:nodes #{2} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 20 0) + (get-in state' [:workspace-local :edit-path id :last-point]))) + (t/is (= 3 (count content')))) + ;; nothing selected: no pending line + (let [state (pth/selectable-path-state id content + {:nodes #{} :segments #{} :handlers #{}}) + state' (ptk/update (path.drawing/change-edit-mode :draw) state)] + (t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))))) + +(t/deftest set-selection-coordinate-translates-segments + ;; Coordinate edits translate selected segments by their bounds. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}}]) + ;; select segment index 2 (the vertical line from (10,0) to (10,10)); + ;; its surrounding rect top-left x is 10 + state (pth/selectable-path-state id content + {:nodes #{} :segments #{2} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 30) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the segment's endpoint nodes (1 and 2) move +20 in x; node 0 stays + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 10) (path.helpers/node-position content' 2)))) + ;; Moving a segment attached to a closed seam keeps both seam commands together. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}} + {:command :line-to :params {:x 0 :y 10}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{4} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 20) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 20 10) (path.helpers/node-position content' 3))) + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4))))) + +(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection + ;; Selected segments and nodes translate as one group. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + ;; The combined bounds start at x=0. + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{3} :handlers #{}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 10) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3))))) + +(t/deftest set-selection-coordinate-translates-mixed-segment-and-handler-selection + ;; Standalone selected handlers translate with the group. + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + ;; The standalone handler makes the bounds start at x=2. + state (pth/selectable-path-state id content + {:nodes #{} + :segments #{3} + :handlers #{[1 :c1]}}) + state' (ptk/update (path.tools/set-selection-coordinate :x 12) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= (gpt/point 12 2) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1))) + (t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2))) + (t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3))))) + +(t/deftest flip-nodes-includes-selected-segment-endpoints + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + state' (ptk/update (path.tools/flip-nodes :horizontal) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; segment 1's endpoints (nodes 0 and 1) mirror across their bbox centre (x=5) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 0 0) (path.helpers/node-position content' 1))) + ;; node 2 is not an endpoint of segment 1, so it stays put + (t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))) + +(t/deftest merge-nodes-includes-selected-segment-endpoints + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{1} :handlers #{}}) + state' (ptk/update (path.tools/merge-nodes) state) + content' (get-in state' [:workspace-drawing :object :content]) + pts (path/get-points content')] + ;; segment 1's endpoints (0,0) and (10,0) merge to their midpoint (5,0) + (t/is (some #(= (gpt/point 5 0) %) pts)) + (t/is (< (count pts) 3)))) + +(t/deftest delete-selected-opens-segments-else-removes-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + run (fn [selection] + (let [state (pth/selectable-path-state id content selection) + events (atom [])] + (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + ;; delete-selected emits a single node-tool event; apply it + (ptk/update (first @events) state))) + move-tos (fn [st] (->> (get-in st [:workspace-drawing :object :content]) + vec + (filter #(= :move-to (:command %))) + count)) + nodes (fn [st] (count (path/get-points + (get-in st [:workspace-drawing :object :content]))))] + ;; deleting the middle segment (index 2) opens the path into two subpaths + (t/is (> (move-tos (run {:nodes #{} :segments #{2} :handlers #{}})) 1)) + ;; Deleting a node leaves fewer than four nodes. + (t/is (< (nodes (run {:nodes #{1} :segments #{} :handlers #{}})) 4)) + ;; Mixed node and segment deletion heals the selected node. + (let [mixed (run {:nodes #{1} :segments #{2} :handlers #{}})] + (t/is (< (nodes mixed) 4)) + (t/is (= 1 (move-tos mixed)))))) + +(t/deftest deleting-a-closed-seam-node-heals-its-adjacent-segments + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 10 :y 10}} + {:command :line-to :params {:x 0 :y 10}} + {:command :line-to :params {:x 0 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{0} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (vec (get-in state' [:workspace-drawing :object :content]))] + (t/is (= [:move-to :line-to :line-to :curve-to] + (mapv :command content'))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3))))) + +(t/deftest deleting-a-touching-subpath-seam-heals-before-exiting-edition + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 10}} + {:command :line-to :params {:x -10 :y 7}} + {:command :line-to :params {:x -10 :y 3}} + {:command :line-to :params {:x 0 :y 0}} + {:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 3}} + {:command :line-to :params {:x 10 :y 7}} + {:command :line-to :params {:x 0 :y 10}}]) + state (pth/selectable-path-state id content + {:nodes #{0 7} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (vec (get-in state' [:workspace-drawing :object :content]))] + (t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to] + (mapv :command content'))) + (t/is (= (gpt/point -10 7) (path.helpers/node-position content' 0))) + (t/is (= (gpt/point -10 7) (path.helpers/node-position content' 5))))) + +(t/deftest delete-selected-with-segments-opens-a-gap-around-the-node + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}} + {:command :line-to :params {:x 40 :y 0}}]) + state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}}) + events (atom []) + _ (->> (ptk/watch (path.tools/delete-selected-with-segments) state nil) + (rx/subs! #(swap! events conj %))) + state' (ptk/update (first @events) state) + content' (get-in state' [:workspace-drawing :object :content]) + move-tos (->> content' vec (filter #(= :move-to (:command %))) count) + nodes (count (path/get-points content'))] + ;; Removing incident segments opens a gap around the node. + (t/is (= 4 nodes)) + (t/is (= 2 move-tos)))) + +(t/deftest group-handler-drag-ignores-stale-handler-identities + (let [id (random-uuid) + content (path/content + [{:command :move-to + :params {:x 0 :y 0}} + {:command :line-to + :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 0 + :c2x 18 :c2y 0 + :x 20 :y 0}}]) + selection {:nodes #{} + :segments #{} + ;; [1 :c1] points to a line-to and [9 :c2] is out of range + :handlers #{[1 :c1] [2 :c1] [9 :c2]}} + state (pth/selectable-path-state id content selection) + event (path.edition/modify-selected-handlers + id [2 :c1] {} 3 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])] + (t/is (= {:c1x 3 :c1y 4} (get modifiers 2))) + (t/is (nil? (get modifiers 1))) + (t/is (nil? (get modifiers 9))))) + +(t/deftest handler-drag-smart-keeps-a-smooth-node-smooth + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Smart mode keeps the handles aligned. + event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :smart true) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1))) + ;; the opposite rotated to stay collinear -> still a smooth node + (t/is (not= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest handler-drag-independent-breaks-a-smooth-node + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Independent mode leaves the opposite handle in place. + event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :independent false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2))) + (t/is (not (path.helpers/handlers-joined? content' 2 :c1))))) + +(t/deftest handler-drag-mirror-rejoins-a-corner-node + (let [id (random-uuid) + content (pth/corner-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{[2 :c1]}}) + ;; Mirror mode matches the opposite handle's angle and length. + event (path.edition/modify-selected-handlers id [2 :c1] {} 2 -6 :mirror false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 14 0) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 6 0) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest handler-drag-aligned-mirrors-angle-keeping-length + (let [id (random-uuid) + content (pth/corner-path-content) + state (pth/selectable-path-state id content {:nodes #{} :segments #{} + :handlers #{}}) + ;; Aligned mode matches the angle and keeps the opposite length. + event (path.edition/modify-selected-handlers id [2 :c1] {} -2 -2 :aligned false) + state' (ptk/update event state) + modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers]) + content' (path/apply-content-modifiers content modifiers)] + (t/is (= (gpt/point 10 4) (path/get-handler-point content' 2 :c1))) + (t/is (= (gpt/point 10 -2) (path/get-handler-point content' 1 :c2))) + (t/is (path.helpers/handlers-joined? content' 2 :c1)))) + +(t/deftest remove-handler-collapses-the-clicked-handler + (let [id (random-uuid) + content (pth/selectable-path-content) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{} + :handlers #{[1 :c2]}}) + state' (ptk/update (path.tools/remove-handler 1 :c2) state) + content' (get-in state' [:workspace-drawing :object :content])] + ;; the clicked handler rests on its node (10,0); the others are untouched + (t/is (= (gpt/point 10 0) (path/get-handler-point content' 1 :c2))) + (t/is (= (gpt/point 2 0) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 12 0) (path/get-handler-point content' 2 :c1))))) + +(t/deftest toggle-segment-curve-switches-line-and-curve + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content path.helpers/empty-selection) + state' (ptk/update (path.tools/toggle-segment-curve 1) state) + content' (get-in state' [:workspace-drawing :object :content]) + state'' (ptk/update (path.tools/toggle-segment-curve 1) state') + content'' (get-in state'' [:workspace-drawing :object :content])] + (t/is (= :curve-to (:command (nth content' 1)))) + ;; handles a third along, offset perpendicular (0.25 * length) into a bow + (t/is (= (gpt/point 10 7.5) (path/get-handler-point content' 1 :c1))) + (t/is (= (gpt/point 20 7.5) (path/get-handler-point content' 1 :c2))) + (t/is (= :line-to (:command (nth content'' 1)))))) + +(t/deftest remove-segment-opens-the-path-keeping-nodes + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content + {:nodes #{} :segments #{2} :handlers #{}}) + state' (ptk/update (path.tools/remove-segment 2) state) + content' (get-in state' [:workspace-drawing :object :content])] + (t/is (= [[:move-to {:x 0 :y 0}] [:line-to {:x 10 :y 0}] + [:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]] + (mapv (juxt :command :params) content'))) + ;; the removed segment's now-stale selection is pruned + (t/is (= #{} (get-in state' [:workspace-local :edit-path id :selection :segments]))))) + +(t/deftest remove-segment-remaps-handler-types-when-node-indices-shift + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :curve-to + :params {:c1x 12 :c1y 4 :c2x 18 :c2y 4 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 22 :c1y -4 :c2x 28 :c2y -4 :x 30 :y 0}}]) + state (-> (pth/selectable-path-state + id content {:nodes #{2} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :handler-types] + {2 :aligned})) + state' (ptk/update (path.tools/remove-segment 1) state)] + ;; Remap the selected node after dropping the dangling start. + (t/is (= 3 (count (get-in state' [:workspace-drawing :object :content])))) + (t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes]))) + ;; Keep the mode attached to the surviving node. + (t/is (= {1 :aligned} + (get-in state' [:workspace-local :edit-path id :handler-types]))))) + +(t/deftest removing-an-earlier-node-preserves-a-surviving-mirror-mode + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 3 :c1y 0 :c2x 7 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 13 :c1y 0 :c2x 17 :c2y 0 :x 20 :y 0}} + {:command :curve-to + :params {:c1x 23 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}} + {:command :curve-to + :params {:c1x 34 :c1y 0 :c2x 37 :c2y 0 :x 40 :y 0}} + {:command :curve-to + :params {:c1x 43 :c1y 0 :c2x 47 :c2y 0 :x 50 :y 0}}]) + state (-> (pth/selectable-path-state + id content {:nodes #{0} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :handler-types] + {3 :mirror})) + state' (ptk/update (path.tools/remove-node) state)] + ;; Geometry alone derives the fourth node as aligned. + (t/is (= :aligned (path.helpers/derive-handler-type content 3))) + ;; Remap the explicit mode with the surviving node. + (t/is (= {2 :mirror} + (get-in state' [:workspace-local :edit-path id :handler-types]))))) + +(t/deftest remove-node-with-segments-opens-a-gap + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :line-to :params {:x 20 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + state (pth/selectable-path-state id content path.helpers/empty-selection) + emitted (atom []) + _ (->> (ptk/watch (path.tools/remove-node-with-segments 1) state nil) + (rx/subs! #(swap! emitted conj %))) + state' (reduce #(ptk/update %2 %1) state @emitted) + content' (get-in state' [:workspace-drawing :object :content])] + ;; node (10,0) and both incident segments are gone; the (0,0) start is + ;; left dangling and dropped too, the rest of the path survives + (t/is (= [[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]] + (mapv (juxt :command :params) content'))))) + +(t/deftest dragging-a-node-or-segment-onto-another-merges-them + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 0}} + {:command :move-to :params {:x 12 :y 0}} + {:command :line-to :params {:x 30 :y 0}}]) + mk (fn [selection] + (-> (pth/selectable-path-state id content selection) + (assoc-in [:workspace-local :zoom] 1))) + emit-of (fn [state] + (let [out (atom [])] + (->> (ptk/watch (path.edition/merge-dragged-on-drop) + state (rx/subject)) + (rx/subs! #(swap! out conj %))) + @out)) + welded [{:x 0 :y 0} {:x 11 :y 0} {:x 30 :y 0}]] + (t/testing "a single node dropped within range of another node merges them" + (let [state (mk {:nodes #{1} :segments #{} :handlers #{}}) + events (emit-of state) + content' (vec (get-in (ptk/update (first events) state) + [:workspace-drawing :object :content]))] + (t/is (= 1 (count events))) + ;; Dropped subpath endpoints weld at their midpoint. + (t/is (= welded (mapv :params content'))))) + (t/testing "a dragged segment whose endpoint lands on a node merges too" + ;; segment 1 (nodes (0,0)-(10,0)); its (10,0) end is within range of (12,0) + (let [state (mk {:nodes #{} :segments #{1} :handlers #{}}) + events (emit-of state) + content' (vec (get-in (ptk/update (first events) state) + [:workspace-drawing :object :content]))] + (t/is (= 1 (count events))) + (t/is (= welded (mapv :params content'))))) + (t/testing "a node dropped with no neighbour in range does not merge" + (t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}}))))))) + +;; Path-local undo and redo events use a seeded local stack. diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 4a799f425f..629e6c9477 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -37,6 +37,11 @@ [frontend-tests.logic.groups-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] + [frontend-tests.logic.path-actions-test] + [frontend-tests.logic.path-clipboard-test] + [frontend-tests.logic.path-helpers-test] + [frontend-tests.logic.path-lifecycle-test] + [frontend-tests.logic.path-tools-test] [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] [frontend-tests.main-errors-test] @@ -135,6 +140,11 @@ 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test 'frontend-tests.logic.nudge-selected-shapes-test + 'frontend-tests.logic.path-actions-test + 'frontend-tests.logic.path-clipboard-test + 'frontend-tests.logic.path-helpers-test + 'frontend-tests.logic.path-lifecycle-test + 'frontend-tests.logic.path-tools-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.main-errors-test 'frontend-tests.logic.sidebar-transform-coalescing-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2be53cb160..701c91e5b7 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5400,6 +5400,14 @@ msgid "shortcuts.delete-node" msgstr "Delete node" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.delete-node-and-segments" +msgstr "Delete node and segments" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.deselect-all" +msgstr "Deselect all" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:104 msgid "shortcuts.detach-component" msgstr "Detach component" @@ -8858,6 +8866,22 @@ msgstr "To corner (%s)" msgid "workspace.path.actions.make-curve" msgstr "To curve (%s)" +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-type" +msgstr "Handler behaviour" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-mirror" +msgstr "Equal" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-aligned" +msgstr "Aligned" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-independent" +msgstr "Independent" + #: src/app/main/ui/workspace/viewport/path_actions.cljs:181 msgid "workspace.path.actions.merge-nodes" msgstr "Merge nodes (%s)" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 897a41f317..945d30a19e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5264,6 +5264,13 @@ msgid "shortcuts.delete-node" msgstr "Borrar nodo" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.delete-node-and-segments" +msgstr "Borrar nodo y segmentos" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.deselect-all" +msgstr "Deseleccionar todo" + msgid "shortcuts.detach-component" msgstr "Desvincular componente" @@ -9716,6 +9723,22 @@ msgstr "Comentarios (%s)" msgid "workspace.toolbar.curve" msgstr "Curva (%s)" +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-type" +msgstr "Comportamiento manejador" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-mirror" +msgstr "Igual" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-aligned" +msgstr "Alineado" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs +msgid "workspace.path.actions.handler-independent" +msgstr "Independiente" + #: src/app/main/ui/workspace/top_toolbar.cljs:231 #, unused msgid "workspace.toolbar.debug" diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 362b912a8d..e5202e651a 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1533,7 +1533,6 @@ impl Shape { }; let path_transform = self.to_path_transform(); - let apply_doc_transform = path_transform.is_some(); for stroke in self.visible_strokes() { let Some(stroke_region) = stroke_to_path( @@ -1546,10 +1545,7 @@ impl Shape { ) else { continue; }; - let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); - if apply_doc_transform { - sk = sk.make_transform(&self.shape_document_transform()); - } + let sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); } From 46fdec5c0f385a1e1af33cc1d131a5cf48870b49 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 11:08:55 +0200 Subject: [PATCH 208/298] :paperclip: Fix linter issues on frontend --- frontend/src/app/main/data/workspace/path/edition.cljs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index 867938beed..d35434da55 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -884,10 +884,10 @@ (cond (nil? parent) [] (cfh/group-shape? parent) (let [fills (svg-fills/resolve-shape-fills parent)] - (if (seq fills) - fills - (recur (:parent-id parent) - (conj visited parent-id)))) + (if (seq fills) + fills + (recur (:parent-id parent) + (conj visited parent-id)))) (cfh/frame-shape? parent) [] :else (recur (:parent-id parent) (conj visited parent-id))))))))) From 582187dd036652e01fab7c5a0cabd0224a6e5a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Thu, 27 Aug 2026 11:50:14 +0200 Subject: [PATCH 209/298] :tada: Allow to validate and repair files from /dbg (#11335) --- backend/resources/app/templates/debug.tmpl | 43 ++++++++++ backend/src/app/http/debug.clj | 93 +++++++++++++++++++++- backend/src/app/srepl/helpers.clj | 5 +- backend/src/app/srepl/main.clj | 8 +- 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index 42894b570c..635380282b 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -190,6 +190,20 @@ Debug Main Page + +
    + Validate file: + Given an FILE-ID, check the referential integrity. +
    +
    + +
    +
    + +
    +
    +
    +
    @@ -222,6 +236,7 @@ Debug Main Page
    +
    Import binfile: Import penpot file in binary format. @@ -236,6 +251,34 @@ Debug Main Page
    + +
    + Repair file: + Given an FILE-ID, repair the referential integrity errors. +
    +
    + WARNING: the reparation is not guaranteed and may cause loss of data! +
    +
    + You may need to give several repair rounds until all errors are cleared. +
    +
    +
    + +
    +
    + + +
    + + A snapshot is made just before the validation, unless skipped. + +
    +
    + +
    +
    +
    {% endblock %} diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 26cac774f6..e86cc1ffda 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -13,6 +13,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.features :as cfeat] + [app.common.files.changes :as cfc] + [app.common.files.repair :as cfr] + [app.common.files.validate :as cfv] [app.common.logging :as l] [app.common.pprint :as pp] [app.common.time :as ct] @@ -28,6 +31,7 @@ [app.rpc.commands.teams :as teams] [app.setup :as-alias setup] [app.setup.clock :as clock] + [app.srepl.helpers :as h] [app.srepl.main :as srepl] [app.storage :as-alias sto] [app.storage.tmp :as tmp] @@ -130,7 +134,7 @@ :hint "invalid button")) (ex/raise :type :not-found - :code :enpty-data + :code :empty-data :hint "empty response")))) (defn- is-file-exists? @@ -484,6 +488,89 @@ {::yres/status 302 ::yres/headers {"location" "/dbg"}})))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; VALIDATE / REPAIR +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- validate-file + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid)] + + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments)) + + (db/tx-run! (assoc cfg ::db/rollback true) + (fn [cfg] + (let [file (bfc/get-file cfg file-id) + libs (bfc/get-resolved-file-libraries cfg file-id)] + (if file + (let [errors (cfv/validate-file file libs)] + {::yres/status 200 + ::yres/headers {"content-type" "text/plain"} + ::yres/body (if (empty? errors) + "NO VALIDATION ERRORS FOUND" + (pp/pprint-str errors))}) + (ex/raise :type :not-found + :code :empty-data + :hint "empty response"))))))) + +(defn- repair-file + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid) + skip-snapshot? (contains? params :skip-snapshot) + profile-id (:app.http.session/profile-id request)] + + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments)) + + (let [output (StringBuilder.) + + repair-file + (fn [file libs _] + (let [errors (cfv/validate-file file libs)] + (.append output (if (empty? errors) + "NO VALIDATION ERRORS FOUND\n" + (str "VALIDATION ERRORS FOUND:\n" + (pp/pprint-str errors) "\n"))) + (if (empty? errors) + file + (let [changes (cfr/repair-file file libs errors)] + (-> file + (update :revn inc) + (update :data cfc/process-changes changes))))))] + + (add-watch l/log-record ::repair-watcher + (fn [_ _ _ record] + (when (= "app.common.files.repair" (::l/logger record)) + (let [props (::l/props record) + hint (get props :hint "") + args (dissoc props :hint) + message (str hint " " + (when-not (empty? args) + args) + "\n")] + (.append output message))))) + (try + (db/tx-run! cfg + h/process-file! + file-id + repair-file + {::h/with-libraries? true + ::h/validate? false + ::h/profile-id profile-id + ::h/snapshot-label (when-not skip-snapshot? "repair")}) + + (.append output "\nREPAIR FINISHED") + + {::yres/status 200 + ::yres/headers {"content-type" "text/plain"} + ::yres/body (.toString output)} + + (finally + (remove-watch l/log-record ::repair-watcher)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; OTHER SMALL VIEWS/HANDLERS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -578,5 +665,7 @@ {:handler (partial handle-team-features cfg)}] ["/file-export" {:handler (partial export-handler cfg)}] ["/file-import" {:handler (partial import-handler cfg)}] - ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]]) + ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}] + ["/file-validate" {:handler (partial validate-file cfg)}] + ["/file-repair" {:handler (partial repair-file cfg)}]]]]) diff --git a/backend/src/app/srepl/helpers.clj b/backend/src/app/srepl/helpers.clj index 658181635b..1cfaf09e05 100644 --- a/backend/src/app/srepl/helpers.clj +++ b/backend/src/app/srepl/helpers.clj @@ -153,7 +153,7 @@ (defn process-file! [system file-id update-fn - & {:keys [::snapshot-label ::validate? ::with-libraries?] + & {:keys [::profile-id ::snapshot-label ::validate? ::with-libraries?] :or {validate? true} :as opts}] (let [file (bfc/get-file system file-id :lock-for-update? true @@ -177,8 +177,9 @@ (when (string? snapshot-label) (fsnap/create! system file {:label snapshot-label + :profile-id profile-id :deleted-at (ct/in-future {:days 30}) - :created-by "admin"})) + :created-by "system"})) (let [file' (update file' :revn inc)] (bfc/update-file! system file' opts) diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 6745858f0e..fdc0caea1a 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -398,10 +398,6 @@ (println (sm/humanize-explain explain)) (ex/print-throwable cause)))))))) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; PROCESSING -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - (defn repair-file! "Repair the list of errors detected by validation." [file-id & {:keys [rollback?] :or {rollback? true} :as options}] @@ -410,6 +406,10 @@ options (assoc options ::h/with-libraries? true)] (db/tx-run! system h/process-file! file-id procs.file-repair/repair-file options))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PROCESSING +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn update-file! "Apply a function to the file. Optionally save the changes or not. The function receives the decoded and migrated file data." From c9a2b8f12fd68b74479b7433f823c4cb2f3109a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Thu, 27 Aug 2026 11:51:47 +0200 Subject: [PATCH 210/298] :wrench: Add TokensStatus data type for forward compatibility (#11314) * :wrench: Add TokensStatus data type for forward compatibility * :books: Add Transit round-trip tests for TokensStatus type Add serialization tests validating TokensStatus survives Transit encode/decode and Fressian round-trip (JVM). Clarify tokens-source field comment as forward-compatibility placeholder. AI-assisted-by: mimo-v2.5-pro --------- Co-authored-by: Andrey Antukh --- common/src/app/common/types/file.cljc | 8 +- .../src/app/common/types/tokens_status.cljc | 147 ++++++++++++++++++ common/test/common_tests/runner.cljc | 2 + .../types/tokens_status_test.cljc | 101 ++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 common/src/app/common/types/tokens_status.cljc create mode 100644 common/test/common_tests/types/tokens_status_test.cljc diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index d7e9feb676..3d67b4b6b1 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -28,7 +28,8 @@ [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.types.text :as txt] - [app.common.types.tokens-lib :refer [schema:tokens-lib]] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.types.typographies-list :as ctyl] [app.common.types.typography :as cty] [app.common.uuid :as uuid] @@ -86,7 +87,9 @@ [:components {:optional true} schema:components] [:typographies {:optional true} schema:typographies] [:plugin-data {:optional true} schema:plugin-data] - [:tokens-lib {:optional true} schema:tokens-lib]]) + [:tokens-source {:optional true} ::sm/uuid] ;; Forward-compat: UUID of external library containing tokens-lib (full support in follow-up PR) + [:tokens-lib {:optional true} ctob/schema:tokens-lib] + [:tokens-status {:optional true} ctos/schema:tokens-status]]) (def schema:file-metadata [:map {:title "Metadata"} @@ -316,6 +319,7 @@ (update-objects-tree container f))))) ;; Asset helpers + (defn find-component-file [file libraries component-file] (if (and (some? file) (= component-file (:id file))) diff --git a/common/src/app/common/types/tokens_status.cljc b/common/src/app/common/types/tokens_status.cljc new file mode 100644 index 0000000000..c2953cc4c1 --- /dev/null +++ b/common/src/app/common/types/tokens_status.cljc @@ -0,0 +1,147 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.common.types.tokens-status + (:require + #?(:clj [app.common.fressian :as fres]) + #?(:clj [clojure.data.json :as c.json]) + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.transit :as t] + [clojure.core.protocols :as cp] + [clojure.datafy :refer [datafy]] + [clojure.pprint :as pp])) + +;; TokensStatus datatype contains the activation status of the themes and sets +;; in a tokens library. + +(defprotocol ITokensStatus + (get-active-theme-ids [_] "Return a clojure set of active theme ids") + (get-active-set-ids [_] "Return a clojure set of active set ids") + (theme-active? [_ theme-id] "Check if a theme is active") + (set-active? [_ set-id] "Check if a set is active") + (set-tokens-status [_ theme-ids set-ids] "Set the activation status of the themes and sets")) + +(deftype TokensStatus [active-theme-ids active-set-ids] + cp/Datafiable + (datafy [_] + {:active-theme-ids active-theme-ids + :active-set-ids active-set-ids}) + + #?@(:clj + [c.json/JSONWriter + (-write [this writter options] + (c.json/-write (datafy this) writter options))]) + + ITokensStatus + (get-active-theme-ids [_] + active-theme-ids) + + (get-active-set-ids [_] + active-set-ids) + + (theme-active? [_ theme-id] + (assert (uuid? theme-id)) + (contains? active-theme-ids theme-id)) + + (set-active? [_ set-id] + (assert (uuid? set-id)) + (contains? active-set-ids set-id)) + + (set-tokens-status [_ theme-ids set-ids] + (assert (set? theme-ids)) + (assert (set? set-ids)) + (TokensStatus. theme-ids set-ids))) + +;; === Helper & Predicate === + +(defn map->TokensStatus + [{:keys [active-theme-ids active-set-ids]}] + (TokensStatus. active-theme-ids active-set-ids)) + +(defn tokens-status? + [o] + (instance? TokensStatus o)) + +;; === Schemas, Check functions & Constructor === + +(declare make-tokens-status) + +(def schema:tokens-status-attrs + [:map {:title "TokensStatus"} + [:active-theme-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]] + [:active-set-ids {:optional true} [:set {:gen/max 5} ::sm/uuid]]]) + +(def schema:tokens-status + [:and {:gen/gen (->> (sg/generator schema:tokens-status-attrs) + (sg/fmap #(make-tokens-status %)))} + [:fn tokens-status?]]) + +(def ^:private check-tokens-status-attrs + (sm/check-fn schema:tokens-status-attrs + :hint "expected valid params for tokens-status")) + +(def check-tokens-status + (sm/check-fn schema:tokens-status + :hint "expected valid tokens-status")) + +(defn make-tokens-status + [& {:as attrs}] + (-> attrs + (update :active-theme-ids #(or % #{})) + (update :active-set-ids #(or % #{})) + (check-tokens-status-attrs) + (map->TokensStatus))) + +;; === Pretty-print for debugging === + +(defmethod pp/simple-dispatch TokensStatus [^TokensStatus obj] + (.write *out* "#penpot/tokens-status ") + (pp/pprint-newline :miser) + (pp/pprint (datafy obj))) + +#?(:clj + (do + (defmethod print-method TokensStatus + [^TokensStatus this ^java.io.Writer w] + (.write w "#penpot/tokens-status ") + (print-method (datafy this) w)) + + (defmethod print-dup TokensStatus + [^TokensStatus this ^java.io.Writer w] + (print-method this w))) + + :cljs + (extend-type TokensStatus + cljs.core/IPrintWithWriter + (-pr-writer [this writer opts] + (-write writer "#penpot/tokens-status ") + (-pr-writer (datafy this) writer opts)) + + cljs.core/IEncodeJS + (-clj->js [this] + (clj->js (datafy this))))) + +;; === Transit serialization === + +(t/add-handlers! + {:id "penpot/tokens-status" + :class TokensStatus + :wfn datafy + :rfn #(make-tokens-status %)}) + +;; === Fressian serialization === + +#?(:clj + (fres/add-handlers! + {:name "penpot/tokens-status/v1" + :class TokensStatus + :wfn (fn [n w o] + (fres/write-tag! w n 1) + (fres/write-object! w (datafy o))) + :rfn (fn [r] + (let [obj (fres/read-object! r)] + (make-tokens-status obj)))})) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index cc08609837..1b55f1d1e4 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -86,6 +86,7 @@ [common-tests.types.shape-layout-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] + [common-tests.types.tokens-status-test] [common-tests.undo-stack-test] [common-tests.uuid-test])) @@ -164,6 +165,7 @@ 'common-tests.types.shape-layout-test 'common-tests.types.token-test 'common-tests.types.tokens-lib-test + 'common-tests.types.tokens-status-test 'common-tests.undo-stack-test 'common-tests.uuid-test]) diff --git a/common/test/common_tests/types/tokens_status_test.cljc b/common/test/common_tests/types/tokens_status_test.cljc new file mode 100644 index 0000000000..d2ebb81dd3 --- /dev/null +++ b/common/test/common_tests/types/tokens_status_test.cljc @@ -0,0 +1,101 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns common-tests.types.tokens-status-test + (:require + #?(:clj [app.common.fressian :as fres]) + #?(:clj [clojure.data.json :as json]) + [app.common.transit :as tr] + [app.common.types.tokens-status :as ctos] + [app.common.uuid :as uuid] + [clojure.datafy :refer [datafy]] + [clojure.test :as t])) + +(t/deftest make-tokens-status + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})] + (t/is (ctos/tokens-status? status)) + (t/is (ctos/check-tokens-status status)) + (t/is (= 1 (count (ctos/get-active-theme-ids status)))) + (t/is (ctos/theme-active? status theme-id)) + (t/is (= 1 (count (ctos/get-active-set-ids status)))) + (t/is (ctos/set-active? status set-id)))) + +(t/deftest make-tokens-status-defaults + (let [status (ctos/make-tokens-status)] + (t/is (ctos/tokens-status? status)) + (t/is (ctos/check-tokens-status status)) + (t/is (= 0 (count (ctos/get-active-theme-ids status)))) + (t/is (= 0 (count (ctos/get-active-set-ids status)))))) + +(t/deftest make-invalid-tokens-status + (t/testing "non-set for active-themes" + (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) + #"expected valid params for tokens-status" + (ctos/make-tokens-status :active-theme-ids [])))) + (t/testing "non-uuid in active-sets" + (t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) + #"expected valid params for tokens-status" + (ctos/make-tokens-status :active-set-ids #{"not-a-uuid"}))))) + +(t/deftest set-tokens-status + (let [theme1-id (uuid/next) + theme2-id (uuid/next) + theme3-id (uuid/next) + set1-id (uuid/next) + set2-id (uuid/next) + set3-id (uuid/next) + status (-> (ctos/make-tokens-status {:active-theme-ids #{theme3-id} + :active-set-ids #{set3-id}}) + (ctos/set-tokens-status #{theme1-id theme2-id} #{set1-id set2-id}))] + (t/is (= #{theme1-id theme2-id} (ctos/get-active-theme-ids status))) + (t/is (= #{set1-id set2-id} (ctos/get-active-set-ids status))))) + +(t/deftest datafy-tokens-status + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + result (datafy status)] + (t/is (map? result)) + (t/is (not (ctos/tokens-status? result))) + (t/is (= (:active-theme-ids result) #{theme-id})) + (t/is (= (:active-set-ids result) #{set-id})))) + +(t/deftest transit-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + encoded (tr/encode-str status) + status' (tr/decode-str encoded)] + (t/is (ctos/tokens-status? status')) + (t/is (= (datafy status') (datafy status))))) + +#?(:clj + (t/deftest fressian-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + encoded (fres/encode status) + status' (fres/decode encoded)] + (t/is (ctos/tokens-status? status')) + (t/is (= (datafy status') (datafy status)))))) + +#?(:clj + (t/deftest json-serialization + (let [theme-id (uuid/next) + set-id (uuid/next) + status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id}) + json-str (json/write-str status) + parsed (json/read-str json-str :key-fn keyword)] + (t/is (map? parsed)) + (t/is (= [(str theme-id)] (:active-theme-ids parsed))) + (t/is (= [(str set-id)] (:active-set-ids parsed)))))) From 0e388442a1387ff4b38e2401b95bf3ef13e30baa Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 12:37:05 +0200 Subject: [PATCH 211/298] :sparkles: Add storage object status lifecycle and verified dedup (#11345) * :recycle: Simplify storage GC delays and add skip-delay task params The touched GC no longer applies an extra deletion-delay when marking storage objects as deleted. By the time a storage object is touched, its referencing domain row has already passed its own deletion delay, and the reference scan is the only safety check needed. Touched objects are now marked with deleted_at = now, so the deleted GC removes them on the next run. For the tempfile bucket, upload chunks now set touched-at in the future (1h, aligned with the upload-session-gc TTL) instead of relying on a special-case deletion delay. Task handlers now read their task props: - storage-gc-touched accepts :skip-delay to process all touched objects immediately, bypassing the min-age threshold. - objects-gc accepts :chunk-size and :skip-delay to process recently deleted rows without waiting for the deletion delay. This allows running the deletion cascade immediately from the REPL via run-task! with the skip-delay option. AI-assisted-by: deepseek-v4-flash * :sparkles: Add storage object status lifecycle, verified dedup, and deletion retry tracking Storage object lifecycle hardening: - Add status column ('valid' | 'pending') as write-ahead marker for object creation. put-object! inserts in 'pending' state, writes blob, then promotes to 'valid'. Failed writes remove the pending row. - Add :storage-pending-gc task to reclaim orphaned pending rows (e.g. after crash between blob write and promotion). - Verify blob existence on every dedup hit via exists-object? (fs stat / s3 headObject). Missing blobs mark the row as deleted and create fresh object. - Add deletion_attempts column (migration 0154) to track physical blob deletion attempts. Restructure gc_deleted to use chunked processing with per-chunk transactions (short lock duration). Failed deletions are deferred to tomorrow (deleted_at = NOW() + 1 day) to prevent infinite loops. After 7 attempts, give up and accept orphan. - Change del-objects-in-bulk contract to return #{fail-ids} for precise per-id tracking (fs and s3 backends updated). - Use tmp/tempfile for fs atomic writes with cleanup queue registration (crashed-JVM temp files swept ~60min later). Document ATOMIC_MOVE POSIX-only assumption. - Add linear backoff to s3 exists-object? retries (100ms/200ms/300ms). - Wrap compensating delete in put-object! catch block to prevent masking original error when connection is aborted. - Fix assert messages in pending_gc.clj and gc_deleted.clj (pool assertion said 'expected valid storage' instead of 'db pool'). - Add pending-objects-excluded-from-gc-deleted test. Use unique path in put-object-write-failure-leaves-no-row test to avoid collisions. AI-assisted-by: qwen3.7-plus * :bug: Fix review comments on gc-deleted and storage - Fix process-chunk! returning nil causing (+ acc nil) crash - Add FOR UPDATE SKIP LOCKED to sql:get-deleted-chunk to prevent infinite loop when another worker holds locks - Pass :cause to log messages in gc_deleted.clj and s3.clj - Fix extra space in log hint string - Remove unused ::blob-missing? reference from storage memory - Rename test to match actual behavior (leaves pending row) - Add test for gc-deleted giving up after max attempts AI-assisted-by: qwen3.7-plus --- .serena/memories/backend/storage.md | 36 ++ backend/src/app/binfile/common.clj | 22 - backend/src/app/binfile/v3.clj | 30 +- backend/src/app/features/fdata.clj | 7 + backend/src/app/features/file_snapshots.clj | 5 +- backend/src/app/main.clj | 9 + backend/src/app/migrations.clj | 5 +- ...ge-object-status-and-deletion-attempts.sql | 24 + .../src/app/rpc/commands/files_thumbnails.clj | 46 +- backend/src/app/rpc/commands/media.clj | 3 +- backend/src/app/storage.clj | 177 ++++--- backend/src/app/storage/fs.clj | 62 ++- backend/src/app/storage/gc_deleted.clj | 144 +++-- backend/src/app/storage/gc_touched.clj | 21 +- backend/src/app/storage/impl.clj | 13 +- backend/src/app/storage/pending_gc.clj | 88 +++ backend/src/app/storage/s3.clj | 74 ++- backend/src/app/storage/tmp.clj | 7 +- backend/src/app/tasks/file_gc.clj | 9 +- backend/src/app/tasks/objects_gc.clj | 10 +- backend/test/backend_tests/storage_test.clj | 500 +++++++++++++++++- 21 files changed, 1064 insertions(+), 228 deletions(-) create mode 100644 backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql create mode 100644 backend/src/app/storage/pending_gc.clj diff --git a/.serena/memories/backend/storage.md b/.serena/memories/backend/storage.md index b0d81330f6..101c77702a 100644 --- a/.serena/memories/backend/storage.md +++ b/.serena/memories/backend/storage.md @@ -30,10 +30,46 @@ - `objects-gc` removes deleted domain rows and touches their storage object IDs. - Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction. +## Connection Reuse Details + +### `app.storage/resolve` patterns: + +**1. Pool mode (default)** - `(sto/resolve cfg)` +- Returns storage abstraction from config +- Uses whatever database pool is available +- **Safe to call outside transaction context** +- Used in: `rpc/commands/media.clj:363`, `rpc/commands/auth.clj:327`, `rpc/commands/profile.clj:362` + +**2. Connection reuse mode** - `(sto/resolve cfg ::db/reuse-conn true)` +- Internally calls `db/get-connection cfg` to obtain connectable +- Configures storage with the specific connection from config +- **Must be paired with transaction that owns this connection** +- Used in: `features/fdata.clj:100`, `rpc/commands/media.clj:425`, `rpc/commands/files_thumbnails.clj:307,319`, `binfile/v3.clj:722` + +**3. Explicit configuration** - `(sto/configure storage conn)` +- Sets `::db/conn` on storage map directly +- Asserts `db/conn? connection` (storage.clj:349) +- Used inside `db/tx-run!` blocks where `conn` is already available +- Used in: `tasks/file_gc.clj:256`, `rpc/commands/files_thumbnails.clj:347,371` + +### Key Warning (from function notes): + +The improved note in `import-storage-objects` and `handle-persistence` warns: +**Do not reuse the main database connection for storage operations within a transaction.** The storage upload process can fail mid-operation, leaving orphaned objects on the backend. If the outer transaction aborts, pending storage objects become unreconciliable because the storage subsystem registers its pending state in separate transactions. + +### Rule of Thumb for `sto/put-object!`: + +Since `put-object!` uses backend-specific operations (`impl/resolve-backend` + `impl/put-object`) and does not directly use `::db/conn` or `::db/pool`, **all usage of `put-object!` will never run inside a common transaction** (if configured at all). The storage backend operations are independent of the database transaction boundary. + ## Deduplication - Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata. - The lookup matches hash, bucket, backend, and `deleted_at IS NULL`. +- The lookup only considers rows with `status='valid'`; pending rows are invisible. +- A hit whose blob is missing is repaired in place: the same row/id is kept, + and `put-object!` rewrites the blob under that id. This heals all existing + references to the object. If the rewrite fails, the row is left live and + valid for a later retry. - The lookup does not include file ID, profile ID, team ID, or organization ID. - Objects can therefore share content across users and files within one bucket. - Deleted objects are not reused. diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 2f37e34e78..16652c3526 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -27,7 +27,6 @@ [app.features.file-migrations :as fmigr] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] - [app.storage :as sto] [app.util.blob :as blob] [app.util.pointer-map :as pmap] [app.worker :as-alias wrk] @@ -654,27 +653,6 @@ (db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"]) (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"]))) -(defn invalidate-thumbnails - [cfg file-id] - (let [storage (sto/resolve cfg) - - sql-1 - (str "update file_tagged_object_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id") - - sql-2 - (str "update file_thumbnail " - " set deleted_at = now() " - " where file_id=? returning media_id")] - - (run! #(sto/touch-object! storage %) - (sequence - (keep :media-id) - (concat - (db/exec! cfg [sql-1 file-id]) - (db/exec! cfg [sql-2 file-id])))))) - (defn process-file [cfg {:keys [id] :as file}] (let [libs (delay (get-resolved-file-libraries cfg file))] diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index b6f610000d..4fd561041f 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -866,6 +866,13 @@ [{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}] (events/tap :progress {:section :storage-objects}) + ;; IMPORTANT: we strongly do not reuse the main connection that can + ;; run inside a transaction because the storage upload process can + ;; fail in the middle of uploading and leave garbage on the underlying + ;; backend, if we participate in the main transaction and it aborts + ;; we will lose all registry of the pending to reconcile blobs + ;; what the storage subsystem registers in other parallel + ;; transaction (let [storage (sto/resolve cfg) entries (keep (match-storage-entry-fn) entries)] @@ -1051,6 +1058,27 @@ {:file-ids file-ids :resolution resolution}))) +(defn- invalidate-thumbnails + [cfg file-id] + (let [storage (sto/resolve cfg ::db/reuse-conn true) + + sql-1 + (str "update file_tagged_object_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id") + + sql-2 + (str "update file_thumbnail " + " set deleted_at = now() " + " where file_id=? returning media_id")] + + (run! #(sto/touch-object! storage %) + (sequence + (keep :media-id) + (concat + (db/exec! cfg [sql-1 file-id]) + (db/exec! cfg [sql-2 file-id])))))) + (defn- import-file-and-overwrite* [{:keys [::manifest ::bfc/file-id] :as cfg}] @@ -1074,7 +1102,7 @@ (import-storage-objects cfg) (import-file cfg file) - (bfc/invalidate-thumbnails cfg file-id) + (invalidate-thumbnails cfg file-id) (bfm/apply-pending-migrations! cfg) {:file-ids [file-id] diff --git a/backend/src/app/features/fdata.clj b/backend/src/app/features/fdata.clj index e65aa5d930..43484a696b 100644 --- a/backend/src/app/features/fdata.clj +++ b/backend/src/app/features/fdata.clj @@ -151,6 +151,13 @@ (cond (= backend "storage") + ;; IMPORTANT: we strongly do not reuse the main connection that can + ;; run inside a transaction because the storage upload process can + ;; fail in the middle of uploading and leave garbage on the underlying + ;; backend, if we participate in the main transaction and it aborts + ;; we will lose all registry of the pending to reconcile blobs + ;; what the storage subsystem registers in other parallel + ;; transaction (let [storage (sto/resolve cfg) content (sto/content data) sobject (sto/put-object! storage diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 6bc70fd52d..9be4b1b572 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -326,8 +326,11 @@ (let [file (d/update-when row :metadata fdata/decode-metadata) vern (rand-int Integer/MAX_VALUE) + ;; We reuse the main connection here for storage operations + ;; becaue the main operations are touching and we need them + ;; to be atomic with the current transaction storage - (sto/resolve cfg {::db/reuse-conn true}) + (sto/resolve cfg ::db/reuse-conn true) snapshot (get-snapshot cfg file-id snapshot-id)] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index dc51498bb3..f1aadd0a2d 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -37,6 +37,7 @@ [app.storage.fs :as-alias sto.fs] [app.storage.gc-deleted :as-alias sto.gc-deleted] [app.storage.gc-touched :as-alias sto.gc-touched] + [app.storage.pending-gc :as-alias sto.pending-gc] [app.storage.s3 :as-alias sto.s3] [app.system :as sys] [app.util.cron] @@ -199,6 +200,10 @@ ::sto.gc-touched/handler {::db/pool (ig/ref ::db/pool)} + ::sto.pending-gc/handler + {::db/pool (ig/ref ::db/pool) + ::sto/storage (ig/ref ::sto/storage)} + ::http.client/client {} @@ -386,6 +391,7 @@ :upload-session-gc (ig/ref :app.tasks.upload-session-gc/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) + :storage-pending-gc (ig/ref ::sto.pending-gc/handler) :session-gc (ig/ref ::session.tasks/gc) :audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler) :audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler) @@ -545,6 +551,9 @@ {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :storage-gc-touched} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily + :task :storage-pending-gc} + {:cron #penpot/cron "0 0 0 * * ?" ;; daily :task :tasks-gc} diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 9255a14f03..ff65057bff 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -499,7 +499,10 @@ :fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")} {:name "0152-rename-version-and-add-indexes-to-server-error-report" - :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}]) + :fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")} + + {:name "0153-add-storage-object-status-and-deletion-attempts" + :fn (mg/resource "app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql b/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql new file mode 100644 index 0000000000..68efad792a --- /dev/null +++ b/backend/src/app/migrations/sql/0153-add-storage-object-status-and-deletion-attempts.sql @@ -0,0 +1,24 @@ +--- Add a status column and a deletion attempts counter to storage_object. + +--- The status column tracks the write-ahead lifecycle of newly created +--- objects. A row is inserted as 'pending' before its blob is written to +--- the underlying storage subsystem and promoted to 'valid' once the +--- write succeeds. Rows in 'pending' state are excluded from the normal +--- lifecycle (deduplication, gc, reads) until they become valid; a +--- periodic task (:storage-pending-gc) reclaims pending rows that were +--- never promoted (e.g. after a crash). + +ALTER TABLE storage_object + ADD COLUMN status text NOT NULL DEFAULT 'valid' + CHECK (status IN ('valid', 'pending')); + +CREATE INDEX storage_object__status_created_at__idx + ON storage_object (status, created_at) + WHERE status = 'pending'; + +--- The deletion_attempts counter tracks how many times the gc_deleted +--- task has attempted to physically delete the blob. After max attempts +--- the row is removed and the blob is left as an orphan. + +ALTER TABLE storage_object + ADD COLUMN deletion_attempts bigint NOT NULL DEFAULT 0; diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 74d277ee79..1a821c4027 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -299,30 +299,32 @@ ;; --- MUTATION COMMAND: delete-file-object-thumbnail (defn- delete-file-object-thumbnail! - [{:keys [::db/conn ::sto/storage]} file-id object-id] + [{:keys [::db/conn] :as cfg} file-id object-id] (when-let [{:keys [media-id tag]} (db/get* conn :file-tagged-object-thumbnail {:file-id file-id :object-id object-id} {::sql/for-update true})] - (sto/touch-object! storage media-id) - (db/update! conn :file-tagged-object-thumbnail - {:deleted-at (ct/now)} - {:file-id file-id - :object-id object-id - :tag tag}))) + (let [storage (sto/resolve cfg ::db/reuse-conn true)] + (sto/touch-object! storage media-id) + (db/update! conn :file-tagged-object-thumbnail + {:deleted-at (ct/now)} + {:file-id file-id + :object-id object-id + :tag tag})))) (defn- delete-file-object-thumbnails! "Soft-deletes multiple object thumbnails in a single UPDATE statement with RETURNING, then touches all returned media objects." - [{:keys [::db/conn ::sto/storage]} object-ids] - (let [ids (db/create-array conn "text" (seq object-ids)) - sql (str/concat - "UPDATE file_tagged_object_thumbnail" - " SET deleted_at = now()" - " WHERE object_id = ANY(?)" - " AND deleted_at IS NULL" - " RETURNING media_id") - rows (db/exec! conn [sql ids])] + [{:keys [::db/conn] :as cfg} object-ids] + (let [storage (sto/resolve cfg ::db/reuse-conn true) + ids (db/create-array conn "text" (seq object-ids)) + sql (str/concat + "UPDATE file_tagged_object_thumbnail" + " SET deleted_at = now()" + " WHERE object_id = ANY(?)" + " AND deleted_at IS NULL" + " RETURNING media_id") + rows (db/exec! conn [sql ids])] (doseq [{:keys [media-id]} rows] (sto/touch-object! storage media-id)))) @@ -342,10 +344,8 @@ ::audit/skip true} [cfg {:keys [::rpc/profile-id file-id object-id]}] (files/check-edition-permissions! cfg profile-id file-id) - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (-> cfg - (update ::sto/storage sto/configure conn) - (delete-file-object-thumbnail! file-id object-id)) + (db/tx-run! cfg (fn [cfg] + (delete-file-object-thumbnail! cfg file-id object-id) nil))) (sv/defmethod ::delete-file-object-thumbnails @@ -366,11 +366,7 @@ (doseq [file-id file-ids] (files/check-edition-permissions! conn profile-id file-id)))) ;; Delete all matching thumbnails in one transaction - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (-> cfg - (update ::sto/storage sto/configure conn) - (delete-file-object-thumbnails! object-ids)) - nil))))) + (db/tx-run! cfg delete-file-object-thumbnails! object-ids)))) ;; --- MUTATION COMMAND: create-file-thumbnail diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index a7bd2ec7fe..7bf42c57a9 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -377,7 +377,7 @@ (sto/put-object! storage {::sto/content data ::sto/deduplicate? false - ::sto/touch true + ::sto/touched-at (ct/in-future {:hours 1}) :content-type (:mtype content) :bucket sto/tempfile-bucket :upload-id (str session-id) @@ -393,6 +393,7 @@ FROM storage_object WHERE (metadata->>'~:upload-id') = ?::text AND deleted_at IS NULL + AND status = 'valid' ORDER BY (metadata->>'~:chunk-index')::integer ASC") (defn- get-upload-chunks diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index 2ae8bf5632..0f35b0a54a 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -70,7 +70,7 @@ [:map {:title "storage"} [::backends schema:backends] [::backend [:enum :s3 :fs]] - ::db/connectable]) + ::db/pool]) (def valid-storage? (sm/validator schema:storage)) @@ -96,7 +96,7 @@ (-> (d/without-nils cfg) (assoc ::backends backends) (assoc ::backend backend) - (assoc ::db/connectable pool)))) + (assoc ::db/pool pool)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Database Objects @@ -118,60 +118,26 @@ " and (metadata->>'~:bucket') = ? " " and backend = ?" " and deleted_at is null" + " and status = 'valid'" " limit 1")] - (some-> (db/exec-one! connectable [sql hash bucket (name backend)]) - (update :metadata db/decode-transit-pgobject)))) + ;; NOTE: metadata is left encoded; row->storage-object is + ;; responsible for decoding it. + (db/exec-one! connectable [sql hash bucket (name backend)]))) -(defn- create-database-object - [{:keys [::backend ::db/connectable]} {:keys [::content ::expired-at ::touched-at ::touch] :as params}] - (let [id (or (::id params) (uuid/random)) - mdata (cond-> (get-metadata params) - (satisfies? impl/IContentHash content) - (assoc :hash (impl/get-hash content))) - - touched-at (if touch - (or touched-at (ct/now)) - touched-at) - - ;; NOTE: for now we don't reuse the deleted objects, but in - ;; futute we can consider reusing deleted objects if we - ;; found a duplicated one and is marked for deletion but - ;; still not deleted. - result (when (and (::deduplicate? params) - (:hash mdata) - (:bucket mdata) - (not= tempfile-bucket (:bucket mdata))) - (let [result (get-database-object-by-hash connectable backend - (:bucket mdata) - (:hash mdata))] - (if touch - (do - (db/update! connectable :storage-object - {:touched-at touched-at} - {:id (:id result)} - {::db/return-keys false}) - (assoc result :touced-at touched-at)) - result))) - - result (or result - (-> (db/insert! connectable :storage-object - {:id id - :size (impl/get-size content) - :backend (name backend) - :metadata (db/tjson mdata) - :deleted-at expired-at - :touched-at touched-at}) - (update :metadata db/decode-transit-pgobject) - (update :metadata assoc ::created? true)))] - - (impl/storage-object - (:id result) - (:size result) - (:created-at result) - (:deleted-at result) - (:touched-at result) - backend - (:metadata result)))) +(defn- promote-object! + [storage object] + (let [ds (db/get-connectable storage) + res (-> (db/update! ds :storage-object + {:status "valid"} + {:id (:id object)} + {::db/return-keys false}) + (db/get-update-count))] + (when-not (pos? res) + ;; The pending row disappeared while the blob was being written + ;; (e.g. reclaimed by :storage-pending-gc); make it observable. + (l/wrn :hint "unable to promote storage object, pending row not found" + :id (str (:id object)))) + res)) (defn row->storage-object [res] (let [mdata (or (some-> (:metadata res) (db/decode-transit-pgobject)) {})] @@ -188,7 +154,8 @@ "SELECT * FROM storage_object WHERE id = ? - AND (deleted_at IS NULL)") + AND (deleted_at IS NULL) + AND status = 'valid'") (defn- get-database-object [conn id] @@ -213,29 +180,93 @@ (dm/export impl/object?) (defn get-object - [{:keys [::db/connectable] :as storage} id] + [storage id] (assert (valid-storage? storage)) - (get-database-object connectable id)) + (let [ds (db/get-connectable storage)] + (get-database-object ds id))) (defn put-object! "Creates a new object with the provided content." - [{:keys [::backend] :as storage} {:keys [::content] :as params}] + [{:keys [::backend ::db/pool] :as storage} + {:keys [::content ::expired-at ::touched-at ::touch] :as params}] (assert (valid-storage? storage)) (assert (impl/content? content) "expected an instance of content") - (let [object (create-database-object storage params)] - (if (::created? (meta object)) - ;; Store the data finally on the underlying storage subsystem. - (-> (impl/resolve-backend storage backend) - (impl/put-object object content)) - object))) + (let [id (or (::id params) (uuid/random)) + mdata (cond-> (get-metadata params) + (satisfies? impl/IContentHash content) + (assoc :hash (impl/get-hash content))) + + touched-at (if touch + (or touched-at (ct/now)) + touched-at) + + backend' (impl/resolve-backend storage backend)] + + ;; NOTE: for now we don't reuse the deleted objects, but in futute + ;; we can consider reusing deleted objects if we found a duplicated + ;; one and is marked for deletion but still not deleted. + + ;; PHASE 1: deduplication lookup. + (if-some [hit (when (and (::deduplicate? params) + (:hash mdata) + (:bucket mdata) + (not= tempfile-bucket (:bucket mdata))) + (get-database-object-by-hash pool backend + (:bucket mdata) + (:hash mdata)))] + + ;; PHASE 2: an existing reference is found: reuse or repair it. + (if (impl/exists-object? backend' hit) + + ;; PHASE 2a: healthy reference. Optionally refresh touched_at + ;; and reuse the object as it is. + (do + (when touch + (db/update! pool :storage-object + {:touched-at touched-at} + {:id (:id hit)} + {::db/return-keys false})) + (row->storage-object (cond-> hit touch (assoc :touched-at touched-at)))) + + ;; PHASE 2b: the referenced blob is missing (a stale/broken row). + ;; Repair the reference in place: rewrite the incoming content + ;; under the same id, restoring the blob for all existing + ;; references to it. If the write fails, the exception propagates + ;; and the row stays live and valid, so a later matching upload + ;; retries the heal. + (let [object (row->storage-object hit)] + (l/wrn :hint "blob not found on reusing storage object" + :id (:id object) + :backend (name backend)) + (impl/put-object backend' object content) + (promote-object! storage object) + object)) + + ;; PHASE 3: no dedup hit: create a fresh object. The row is + ;; inserted in 'pending' state so it is not visible to the normal + ;; lifecycle (dedup, gc, reads) until the blob has been written + ;; and the object promoted to 'valid'. + (let [row (db/insert! pool :storage-object + {:id id + :size (impl/get-size content) + :backend (name backend) + :metadata (db/tjson mdata) + :deleted-at expired-at + :touched-at touched-at + :status "pending"}) + object (row->storage-object row)] + (impl/put-object backend' object content) + (promote-object! storage object) + object)))) (defn touch-object! "Mark object as touched." - [{:keys [::db/connectable] :as storage} object-or-id] + [storage object-or-id] (assert (valid-storage? storage)) - (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id)] - (-> (db/update! connectable :storage-object + (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id) + ds (db/get-connectable storage)] + (-> (db/update! ds :storage-object {:touched-at (ct/now)} {:id id}) (db/get-update-count) @@ -282,10 +313,11 @@ (-> (impl/get-object-url backend object nil) file-url->path)))) (defn del-object! - [{:keys [::db/connectable] :as storage} object-or-id] + [storage object-or-id] (assert (valid-storage? storage)) (let [id (if (impl/object? object-or-id) (:id object-or-id) object-or-id) - res (db/update! connectable :storage-object + ds (db/get-connectable storage) + res (db/update! ds :storage-object {:deleted-at (ct/now)} {:id id})] (pos? (db/get-update-count res)))) @@ -295,9 +327,10 @@ (dm/export impl/get-size) (defn configure - [storage connectable] + [storage connection] + (assert (db/connection? connection)) (assert (valid-storage? storage)) - (assoc storage ::db/connectable connectable)) + (assoc storage ::db/conn connection)) (defn resolve "Resolves the storage instance with preconfigured backend. You can @@ -306,5 +339,5 @@ [cfg & {:as opts}] (let [storage (::storage cfg)] (if (::db/reuse-conn opts false) - (configure storage (db/get-connectable cfg)) + (configure storage (db/get-connection cfg)) storage))) diff --git a/backend/src/app/storage/fs.clj b/backend/src/app/storage/fs.clj index a90fecff88..cf07128579 100644 --- a/backend/src/app/storage/fs.clj +++ b/backend/src/app/storage/fs.clj @@ -11,6 +11,7 @@ [app.common.uri :as u] [app.storage :as-alias sto] [app.storage.impl :as impl] + [app.storage.tmp :as tmp] [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] @@ -18,8 +19,11 @@ (:import java.io.InputStream java.io.OutputStream + java.nio.file.AtomicMoveNotSupportedException + java.nio.file.CopyOption java.nio.file.Files - java.nio.file.Path)) + java.nio.file.Path + java.nio.file.StandardCopyOption)) (set! *warn-on-reflection* true) @@ -59,17 +63,49 @@ (assert (valid-backend? backend) "expected a valid backend instance") (let [base (fs/path (::directory backend)) path (fs/path (impl/id->path id)) - full (fs/normalize (fs/join base path))] + full (fs/normalize (fs/join base path)) + parent-dir (fs/parent full)] - (when-not (fs/exists? (fs/parent full)) - (fs/create-dir (fs/parent full))) + (when-not (fs/exists? parent-dir) + (fs/create-dir parent-dir)) - (with-open [^InputStream src (io/input-stream content)] - (with-open [^OutputStream dst (io/output-stream full)] - (io/copy src dst))) + ;; Create temp file in the same directory (same filesystem → atomic + ;; move preserved) and register with cleanup queue (crashed-JVM files + ;; are swept ~60min later). + (let [tmp (tmp/tempfile :dir (str parent-dir) + :prefix (str (fs/name full) ".") + :suffix ".tmp" + :min-age "1h")] + + ;; Write to a temporary file in the same directory and atomically move + ;; it into place, so a failed write never leaves a partial blob at the + ;; final path. + (try + (with-open [^InputStream src (io/input-stream content)] + (with-open [^OutputStream dst (io/output-stream tmp)] + (io/copy src dst))) + ;; ATOMIC_MOVE is POSIX-only; on non-POSIX filesystems (e.g. Windows) + ;; this may throw FileAlreadyExistsException if the target exists. + (try + (Files/move ^Path tmp ^Path full + (into-array CopyOption [StandardCopyOption/ATOMIC_MOVE])) + (catch AtomicMoveNotSupportedException _ + (Files/move ^Path tmp ^Path full + (into-array CopyOption [StandardCopyOption/REPLACE_EXISTING])))) + (catch Throwable cause + ;; Temp file cleanup is handled by the cleanup queue; just rethrow. + (throw cause)))) object)) +(defmethod impl/exists-object? :fs + [backend {:keys [id]}] + (assert (valid-backend? backend) "expected a valid backend instance") + (let [^Path base (fs/path (::directory backend)) + ^Path path (fs/path (impl/id->path id)) + ^Path full (fs/normalize (fs/join base path))] + (fs/exists? full))) + (defmethod impl/get-object-data :fs [backend {:keys [id] :as object}] (assert (valid-backend? backend) "expected a valid backend instance") @@ -108,8 +144,12 @@ [backend ids] (assert (valid-backend? backend) "expected a valid backend instance") (let [base (fs/path (::directory backend))] - (doseq [id ids] - (let [path (fs/path (impl/id->path id)) - path (fs/join base path)] - (Files/deleteIfExists ^Path path))))) + (reduce (fn [fail-ids id] + (let [path (fs/normalize (fs/join base (fs/path (impl/id->path id))))] + (try + (Files/deleteIfExists ^Path path) + fail-ids + (catch Throwable _ + (conj fail-ids id))))) + #{} ids))) diff --git a/backend/src/app/storage/gc_deleted.clj b/backend/src/app/storage/gc_deleted.clj index 2aadca4b55..ab49030041 100644 --- a/backend/src/app/storage/gc_deleted.clj +++ b/backend/src/app/storage/gc_deleted.clj @@ -19,8 +19,18 @@ [app.db :as db] [app.storage :as sto] [app.storage.impl :as impl] + [clojure.set :as set] [integrant.core :as ig])) +(def ^:private max-attempts + "Maximum number of deletion attempts before giving up and accepting + the orphan blob." + 7) + +(def ^:private chunk-size + "Number of rows to process per transaction." + 25) + (def ^:private sql:lock-sobjects "SELECT id FROM storage_object WHERE id = ANY(?::uuid[]) @@ -47,66 +57,110 @@ (-> (db/exec-one! conn [sql:delete-sobjects ids]) (db/get-update-count)))) -(defn- delete-in-bulk! - [cfg backend-id ids] - ;; We run the deletion on a separate transaction. This is - ;; because if some exception is raised inside procesing - ;; one chunk, it does not affects the rest of the chunks. - (try - (db/tx-run! cfg - (fn [{:keys [::db/conn ::sto/storage]}] - (when-let [ids (lock-ids conn ids)] - (let [total (delete-sobjects! conn ids)] - (-> (impl/resolve-backend storage backend-id) - (impl/del-objects-in-bulk ids)) +(def ^:private sql:increment-attempts-and-defer + "UPDATE storage_object + SET deletion_attempts = deletion_attempts + 1, + deleted_at = NOW() + INTERVAL '1 day' + WHERE id = ANY(?::uuid[])") - (doseq [id ids] - (l/dbg :hint "permanently delete storage object" - :id (str id) - :backend (name backend-id))) - total)))) - (catch Throwable cause - (l/err :hint "unexpected error on bulk deletion" - :ids ids - :cause cause)))) +(defn- increment-attempts-and-defer! + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:increment-attempts-and-defer ids]))) +(def ^:private sql:delete-give-up + "DELETE FROM storage_object + WHERE id = ANY(?::uuid[]) + AND deletion_attempts >= ?") + +(defn- delete-give-up! + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:delete-give-up ids max-attempts]))) + +(defn- process-chunk + "Attempt to delete a chunk of storage objects from a specific backend. + + This function runs inside the caller's transaction (clean-deleted!) — + it does NOT open its own transaction. The caller is responsible for + ensuring the rows are locked via FOR UPDATE SKIP LOCKED before calling. + + Returns the number of successfully deleted objects, or 0 if no rows + could be locked." + [conn storage backend-id ids] + (if-let [locked-ids (lock-ids conn ids)] + (let [fail-ids (try + (-> (impl/resolve-backend storage backend-id) + (impl/del-objects-in-bulk locked-ids)) + (catch Throwable cause + (l/err :hint "error on physical deletion, will retry" + :ids locked-ids + :cause cause) + locked-ids)) + ok-ids (set/difference locked-ids fail-ids)] + + (doseq [id ok-ids] + (l/dbg :hint "permanently delete storage object" + :id (str id) + :backend (name backend-id))) + + (when (seq ok-ids) + (delete-sobjects! conn ok-ids)) + + (when (seq fail-ids) + (increment-attempts-and-defer! conn fail-ids) + (let [given-up (delete-give-up! conn fail-ids)] + (when (pos? (db/get-update-count given-up)) + (l/wrn :hint "giving up on orphan blob after max attempts" + :ids fail-ids + :max-attempts max-attempts)))) + + (count ok-ids)) + 0)) (defn- group-by-backend [items] (d/group-by (comp keyword :backend) :id #{} items)) -(def ^:private sql:get-deleted-sobjects - "SELECT s.* - FROM storage_object AS s - WHERE s.deleted_at IS NOT NULL - AND s.deleted_at <= ? - ORDER BY s.deleted_at ASC") +(def ^:private sql:get-deleted-chunk + "SELECT id, backend + FROM storage_object + WHERE deleted_at IS NOT NULL + AND deleted_at <= ? + AND status = 'valid' + ORDER BY deleted_at ASC + LIMIT ? + FOR UPDATE + SKIP LOCKED") -(defn- get-buckets - [conn] - (let [now (ct/now)] - (sequence - (comp (partition-all 25) - (mapcat group-by-backend)) - (db/cursor conn [sql:get-deleted-sobjects now])))) +(defn- get-deleted-chunk + [conn size] + (db/exec! conn [sql:get-deleted-chunk (ct/now) size])) (defn- clean-deleted! - [{:keys [::db/conn] :as cfg}] - (reduce (fn [total [backend-id ids]] - (let [deleted (delete-in-bulk! cfg backend-id ids)] - (+ total (or deleted 0)))) - 0 - (get-buckets conn))) + [cfg] + (loop [total 0] + (let [deleted (db/tx-run! cfg + (fn [{:keys [::db/conn ::sto/storage]}] + (let [chunk (get-deleted-chunk conn chunk-size)] + (when (seq chunk) + (let [by-backend (group-by-backend chunk)] + (reduce-kv (fn [acc backend-id ids] + (+ acc (process-chunk conn storage backend-id ids))) + 0 + by-backend))))))] + (if deleted + (recur (+ total deleted)) + total)))) (defmethod ig/assert-key ::handler [_ params] (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage") - (assert (db/pool? (::db/pool params)) "expect valid storage")) + (assert (db/pool? (::db/pool params)) "expect valid db pool")) (defmethod ig/init-key ::handler [_ cfg] (fn [_] - (db/tx-run! cfg (fn [cfg] - (let [total (clean-deleted! cfg)] - (l/inf :hint "task finished" :total total) - {:deleted total}))))) + (let [total (clean-deleted! cfg)] + (l/inf :hint "task finished" :total total) + {:deleted total}))) diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index cfdf6788f7..af16af5f82 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -23,7 +23,6 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.time :as ct] - [app.config :as cf] [app.db :as db] [app.storage :as sto] [app.storage.impl :as impl] @@ -108,10 +107,9 @@ WHERE id = ANY(?::uuid[])") (defn- mark-delete-in-bulk! - [conn deletion-delay ids] - (let [ids (db/create-array conn "uuid" ids) - now (ct/plus (ct/now) deletion-delay)] - (db/exec-one! conn [sql:mark-delete-in-bulk now ids]))) + [conn ids] + (let [ids (db/create-array conn "uuid" ids)] + (db/exec-one! conn [sql:mark-delete-in-bulk (ct/now) ids]))) ;; NOTE: A getter that retrieves the key which will be used for group ;; ids; previously we have no value, then we introduced the @@ -149,11 +147,9 @@ :status "delete" :bucket bucket) (recur to-freeze (conj to-delete id) (rest objects)))) - (let [deletion-delay (if (= sto/tempfile-bucket bucket) - (ct/duration {:hours 2}) - (cf/get-deletion-delay))] + (do (some->> (seq to-freeze) (mark-freeze-in-bulk! conn)) - (some->> (seq to-delete) (mark-delete-in-bulk! conn deletion-delay)) + (some->> (seq to-delete) (mark-delete-in-bulk! conn)) [(count to-freeze) (count to-delete)])))) (defn- process-bucket! @@ -186,6 +182,7 @@ FROM storage_object AS so WHERE so.touched_at IS NOT NULL AND so.touched_at <= ? + AND so.status = 'valid' ORDER BY touched_at ASC FOR UPDATE SKIP LOCKED @@ -221,7 +218,9 @@ (defmethod ig/init-key ::handler [_ {:keys [::min-age] :as cfg}] - (fn [_] - (let [threshold (ct/minus (ct/now) min-age)] + (fn [{:keys [props]}] + (let [threshold (if (:skip-delay props) + (ct/now) + (ct/minus (ct/now) min-age))] (process-touched! (assoc cfg ::timestamp threshold))))) diff --git a/backend/src/app/storage/impl.clj b/backend/src/app/storage/impl.clj index 9b5a33b7dd..76fafe2cac 100644 --- a/backend/src/app/storage/impl.clj +++ b/backend/src/app/storage/impl.clj @@ -71,7 +71,10 @@ :code :invalid-storage-backend :context cfg)) -(defmulti del-objects-in-bulk (fn [cfg _] (::sto/type cfg))) +(defmulti del-objects-in-bulk + "Delete multiple objects in bulk. Returns #{fail-ids} — the set of ids + whose blob deletion failed. Empty set = all succeeded." + (fn [cfg _] (::sto/type cfg))) (defmethod del-objects-in-bulk :default [cfg _] @@ -79,6 +82,14 @@ :code :invalid-storage-backend :context cfg)) +(defmulti exists-object? (fn [cfg _] (::sto/type cfg))) + +(defmethod exists-object? :default + [cfg _] + (ex/raise :type :internal + :code :invalid-storage-backend + :context cfg)) + ;; --- HELPERS (defn uuid->hex diff --git a/backend/src/app/storage/pending_gc.clj b/backend/src/app/storage/pending_gc.clj new file mode 100644 index 0000000000..7b8f04ff13 --- /dev/null +++ b/backend/src/app/storage/pending_gc.clj @@ -0,0 +1,88 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.storage.pending-gc + "A maintenance task that reclaims storage objects created in 'pending' + state that were never promoted to 'valid' (e.g. after a crash between + writing the blob and promoting the row). + + Pending rows are invisible to the normal lifecycle (dedup, gc, reads). This + task removes the orphaned blob (if any) and the pending row itself, without + ever iterating the whole physical store." + (:require + [app.common.logging :as l] + [app.db :as db] + [app.storage :as sto] + [app.storage.impl :as impl] + [integrant.core :as ig])) + +(def ^:private sql:get-pending-sobjects + "SELECT id, backend + FROM storage_object + WHERE status = 'pending' + AND created_at <= now() - interval '24 hours' + ORDER BY created_at ASC + LIMIT ? + FOR UPDATE + SKIP LOCKED") + +(defn- get-pending-chunk + [conn chunk-size] + (db/exec! conn [sql:get-pending-sobjects chunk-size])) + +(def ^:private sql:delete-pending-sobject + "DELETE FROM storage_object WHERE id = ? AND status = 'pending'") + +(def ^:private chunk-size + 100) + +(defn- delete-pending-rows! + "Select, lock and delete a chunk of pending rows in a single transaction. + Returns the deleted rows or nil when there is nothing left to reclaim." + [cfg] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + ;; NOTE: db/exec! returns an empty vector when there are no + ;; rows left; use not-empty to detect it. + (when-let [chunk (not-empty (get-pending-chunk conn chunk-size))] + (doseq [{:keys [id]} chunk] + (db/exec-one! conn [sql:delete-pending-sobject id])) + chunk)))) + +(defn- delete-blobs! + "Best-effort removal of the orphaned blobs. Runs after the pending rows + have been committed so a failure here never blocks their reclamation." + [storage rows] + (doseq [{:keys [id backend]} rows] + (try + (-> (impl/resolve-backend storage (keyword backend)) + (impl/del-object {:id id})) + (catch Throwable cause + (l/err :hint "error deleting orphaned pending blob" + :id (str id) + :backend backend + :cause cause))))) + +(defn- process! + [{::sto/keys [storage] :as cfg}] + (loop [total 0] + (if-let [rows (delete-pending-rows! cfg)] + (do + (delete-blobs! storage rows) + (recur (long (+ total (count rows))))) + total))) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected valid db pool") + (assert (sto/valid-storage? (::sto/storage params)) "expect valid storage")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [_] + (let [total (process! cfg)] + (l/inf :hint "task finished" :total total) + {:processed total}))) diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index c55158a7ba..6cf97321f1 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -47,6 +47,7 @@ software.amazon.awssdk.services.s3.model.DeleteObjectsRequest software.amazon.awssdk.services.s3.model.DeleteObjectsResponse software.amazon.awssdk.services.s3.model.GetObjectRequest + software.amazon.awssdk.services.s3.model.HeadObjectRequest software.amazon.awssdk.services.s3.model.NoSuchKeyException software.amazon.awssdk.services.s3.model.ObjectIdentifier software.amazon.awssdk.services.s3.model.PutObjectRequest @@ -78,6 +79,7 @@ (declare get-object-url) (declare del-object) (declare del-object-in-bulk) +(declare head-object) (declare build-s3-client) (declare build-s3-presigner) @@ -186,10 +188,46 @@ [backend object] (p/await! (del-object backend object))) +(defmethod impl/exists-object? :s3 + [backend object] + (assert (valid-backend? backend) "expected a valid backend instance") + (loop [result (p/await (head-object backend object)) + retryn 0] + (if (ex/exception? result) + (cond + ;; A missing key is a definitive answer, no need to retry. + (ex/instance? NoSuchKeyException result) + false + + ;; Any other error is considered transient and retried. + (< retryn max-retries) + (do + (Thread/sleep (* 100 (inc retryn))) + (recur (p/await (head-object backend object)) (inc retryn))) + + :else + (throw result)) + true))) + (defmethod impl/del-objects-in-bulk :s3 [backend ids] (assert (valid-backend? backend) "expected a valid backend instance") - (p/await! (del-object-in-bulk backend ids))) + (let [key->id (into {} (map (fn [id] + [(str (::prefix backend) (impl/id->path id)) id])) + ids) + result (try + (p/await! (del-object-in-bulk backend ids)) + (catch Throwable cause + (l/err :hint "error on s3 bulk deletion" + :ids ids + :cause cause) + ::network-error))] + (cond + (= ::network-error result) (set ids) + (map? result) (into #{} (map (fn [{:keys [key]}] + (get key->id key))) + (:errors result)) + :else #{}))) ;; --- HELPERS @@ -330,6 +368,14 @@ ^AsyncResponseTransformer rxf) (p/fmap #(.asInputStream ^ResponseBytes %))))))) +(defn- head-object + [{:keys [::client ::bucket ::prefix]} {:keys [id]}] + (let [hor (.. (HeadObjectRequest/builder) + (bucket bucket) + (key (str prefix (impl/id->path id))) + (build))] + (.headObject ^S3AsyncClient client ^HeadObjectRequest hor))) + (defn- get-object-bytes [{:keys [::client ::bucket ::prefix]} {:keys [id]}] (let [gor (.. (GetObjectRequest/builder) @@ -379,12 +425,11 @@ (defn- del-object-in-bulk [{:keys [::bucket ::client ::prefix]} ids] - - (let [oids (map (fn [id] - (.. (ObjectIdentifier/builder) - (key (str prefix (impl/id->path id))) - (build))) - ids) + (let [oids (mapv (fn [id] + (.. (ObjectIdentifier/builder) + (key (str prefix (impl/id->path id))) + (build))) + ids) delc (.. (Delete/builder) (objects ^Collection oids) (build)) @@ -392,14 +437,9 @@ (bucket bucket) (delete ^Delete delc) (build))] - (->> (.deleteObjects ^S3AsyncClient client ^DeleteObjectsRequest dor) - (p/fmap (fn [dres] - (when (.hasErrors ^DeleteObjectsResponse dres) - (let [errors (seq (.errors ^DeleteObjectsResponse dres))] - (ex/raise :type :internal - :code :error-on-s3-bulk-delete - :s3-errors (mapv (fn [^S3Error error] - {:key (.key error) - :msg (.message error)}) - errors))))))))) + (p/fmap (fn [^DeleteObjectsResponse dres] + (when (.hasErrors dres) + {:errors (mapv (fn [^S3Error e] + {:key (.key e) :msg (.message e)}) + (.errors dres))})))))) diff --git a/backend/src/app/storage/tmp.clj b/backend/src/app/storage/tmp.clj index 4448920d99..7a4ae3b901 100644 --- a/backend/src/app/storage/tmp.clj +++ b/backend/src/app/storage/tmp.clj @@ -80,11 +80,12 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn tempfile* - [& {:keys [suffix prefix] + [& {:keys [suffix prefix dir] :or {prefix "penpot." - suffix ".tmp"}}] + suffix ".tmp" + dir default-tmp-dir}}] (let [attrs (fs/make-permissions "rw-r--r--") - path (fs/join default-tmp-dir (str prefix (uuid/next) suffix))] + path (fs/join dir (str prefix (uuid/next) suffix))] (Files/createFile path attrs))) (defn tempfile diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index f2e6ee16b5..78461e9b99 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -251,12 +251,9 @@ (try (-> cfg (assoc ::db/rollback (:rollback? props)) - (db/tx-run! (fn [{:keys [::db/conn] :as cfg}] - (let [cfg (-> cfg - (update ::sto/storage sto/configure conn) - (assoc ::timestamp (ct/now))) - processed? (process-file! cfg props)] - + (assoc ::timestamp (ct/now)) + (db/tx-run! (fn [cfg] + (let [processed? (process-file! cfg props)] (when (and processed? (contains? cf/flags :tiered-file-data-storage)) (wrk/submit! (-> cfg (assoc ::wrk/task :offload-file-data) diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index 8b6c90e82d..a72459e432 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -321,8 +321,14 @@ (defmethod ig/init-key ::handler [_ cfg] - (fn [_] - (let [cfg (assoc cfg ::timestamp (ct/now))] + (fn [{:keys [props]}] + (let [skip-delay (:skip-delay props) + chunk-size (or (:chunk-size props) (::chunk-size cfg)) + cfg (-> cfg + (assoc ::chunk-size chunk-size) + (assoc ::timestamp (if skip-delay + (ct/in-future {:days 3650}) + (ct/now))))] (loop [procs (map deref deletion-proc-vars) total 0] (if-let [proc-fn (first procs)] diff --git a/backend/test/backend_tests/storage_test.clj b/backend/test/backend_tests/storage_test.clj index adc84e64ac..cc34773e67 100644 --- a/backend/test/backend_tests/storage_test.clj +++ b/backend/test/backend_tests/storage_test.clj @@ -12,12 +12,23 @@ [app.db :as db] [app.rpc :as-alias rpc] [app.storage :as sto] + [app.storage.fs :as-alias sto.fs] + [app.storage.impl :as impl] + [app.storage.s3 :as-alias sto.s3] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]] + [promesa.core :as p]) + (:import + (software.amazon.awssdk.services.s3 + S3AsyncClient) + (software.amazon.awssdk.services.s3.model + NoSuchKeyException) + (software.amazon.awssdk.services.s3.presigner + S3Presigner))) (t/use-fixtures :once th/state-init) (t/use-fixtures :each (th/serial @@ -368,27 +379,498 @@ now (ct/now) object1 (sto/put-object! storage {::sto/content content1 - ::sto/touched-at (ct/plus now {:minutes 1}) + ::sto/touched-at (ct/plus now {:hours 1}) :bucket "tempfile" :content-type "text/plain"})] - + ;; not eligible while the touched-at is in the future (binding [ct/*clock* (ct/fixed-clock now)] (let [res (th/run-task! :storage-gc-touched {})] (t/is (= 0 (:freeze res))) (t/is (= 0 (:delete res))))) - - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))] + ;; still not eligible: touched-at (now+1h) is beyond the threshold + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))] (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; eligible: marked for deletion immediately, without any extra delay + (let [clock (ct/plus now {:hours 3})] + (binding [ct/*clock* (ct/fixed-clock clock)] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 1 (:delete res))))) + + (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus clock {:seconds 1}))))) + + ;; removed on the next deleted gc run + (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 4}))] + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res))))))) + +(t/deftest touched-gc-task-skip-delay + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content1") + now (ct/now) + + object1 (sto/put-object! storage {::sto/content content + ::sto/touched-at now + :bucket "tempfile" + :content-type "text/plain"})] + + ;; too recent: not processed without skip-delay + (binding [ct/*clock* (ct/fixed-clock now)] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; processed immediately with skip-delay + (binding [ct/*clock* (ct/fixed-clock now)] + (let [res (th/run-task! :storage-gc-touched {:skip-delay true})] (t/is (= 0 (:freeze res))) (t/is (= 1 (:delete res))))) + ;; and marked for deletion without any additional delay + (let [row (th/db-exec-one! ["select deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-before-or-equal? (:deleted-at row) (ct/plus now {:seconds 1})))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 1}))] +(t/deftest storage-gc-deleted-immediate + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content1") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + ;; mark as deleted right now + (th/db-exec! ["update storage_object set deleted_at = ?" (ct/now)]) + + ;; the deleted gc removes it on the next run + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res)))))) + +(t/deftest objects-gc-task-skip-delay + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + prof (th/create-profile* 1) + proj (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + params {::th/type :upload-file-media-object + ::rpc/profile-id (:id prof) + :file-id (:id file) + :is-local true + :name "testfile" + :content mfile} + out1 (th/command! params) + out2 (th/command! params)] + + (t/is (nil? (:error out1))) + (t/is (nil? (:error out2))) + + (let [result-1 (:result out1) + result-2 (:result out2)] + + ;; mark as deleted but in the future (not yet eligible) + (th/db-update! :file-media-object + {:deleted-at (ct/in-future {:days 1})} + {:id (:id result-1)}) + + ;; without skip-delay the future deleted row is not processed + (let [res (th/run-task! :objects-gc {})] + (t/is (= 0 (:processed res)))) + + ;; with skip-delay it is processed immediately + (let [res (th/run-task! :objects-gc {:skip-delay true})] + (t/is (= 1 (:processed res))))))) + +(t/deftest put-object-write-failure-leaves-pending-row + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + ;; Point the fs backend at a path that is actually a file so the + ;; blob write fails. + blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next))) + _ (spit (str blocked) "x")] + (try + (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked)) + content (sto/content "content") + ex (try + (sto/put-object! broken {::sto/content content + :content-type "text/plain"}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + + ;; the pending row stays behind and is reclaimed asynchronously + ;; by the :storage-pending-gc task + (let [rows (th/db-query :storage-object {:status "pending"})] + (t/is (= 1 (count rows))) + + (th/db-update! :storage-object + {:created-at (ct/in-past {:days 2})} + {:id (:id (first rows))}) + + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 1 (:processed res)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 0 (:count row)))))) + (finally + (fs/delete blocked))))) + +(t/deftest pending-gc-reclaims-unpromoted-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"}) + path (sto/get-object-path storage object)] + + ;; valid objects are never reclaimed + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 0 (:processed res)))) + + ;; simulate a crash: the object was created but never promoted + (th/db-update! :storage-object {:status "pending" + :created-at (ct/in-past {:days 2})} + {:id (:id object)}) + + (t/is (fs/exists? path)) + + (let [res (th/run-task! :storage-pending-gc {})] + (t/is (= 1 (:processed res)))) + + ;; both the row and the orphaned blob are removed + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))) + (t/is (not (fs/exists? path))))) + +(t/deftest pending-objects-excluded-from-gc-touched + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + ::sto/touched-at (ct/now) + :content-type "text/plain"})] + + ;; mark it pending and touched in the past + (th/db-update! :storage-object {:status "pending" + :touched-at (ct/in-past {:days 1})} + {:id (:id object)}) + + (binding [ct/*clock* (ct/fixed-clock (ct/now))] + (let [res (th/run-task! :storage-gc-touched {})] + (t/is (= 0 (:freeze res))) + (t/is (= 0 (:delete res))))) + + ;; still present and not marked as deleted + (let [row (th/db-exec-one! ["select * from storage_object where id = ?" (:id object)])] + (t/is (some? row)) + (t/is (nil? (:deleted-at row)))))) + +(t/deftest pending-objects-excluded-from-get + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + (t/is (some? (sto/get-object storage (:id object)))) + + (th/db-update! :storage-object {:status "pending"} {:id (:id object)}) + + (t/is (nil? (sto/get-object storage (:id object)))))) + +(t/deftest pending-objects-excluded-from-dedup + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + + ;; mark the only matching row as pending + (th/db-update! :storage-object {:status "pending"} {:id (:id object1)}) + + (let [object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (not= (:id object1) (:id object2)))))) + +(t/deftest dedup-reuses-existing-blob + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (= (:id object1) (:id object2))) + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row)))))) + +(t/deftest dedup-repairs-stale-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object1 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + + ;; remove the physical blob to simulate a stale/broken object + (let [path (sto/get-object-path storage object1)] + (fs/delete path)) + + ;; re-uploading identical content repairs the same reference in place + (let [object2 (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"})] + (t/is (= (:id object1) (:id object2))) + + ;; the row stays live: no tombstone and no extra row + (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object1)])] + (t/is (= "valid" (:status row))) + (t/is (nil? (:deleted-at row)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row)))) + + ;; the repaired blob is readable again under the original id + (t/is (= "content" (slurp (sto/get-object-data storage object2))))))) + +(t/deftest gc-deleted-removes-broken-object + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + ;; mark as deleted and remove the physical blob + (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1})} + {:id (:id object)}) + (let [path (sto/get-object-path storage object)] + (fs/delete path)) + + ;; the deleted gc removes the row without error even though the blob is + ;; missing (the physical deletion is best-effort) + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 1 (:deleted res)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))))) + +(t/deftest pending-objects-excluded-from-gc-deleted + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + ;; mark as pending + deleted in the past + (th/db-update! :storage-object {:status "pending" + :deleted-at (ct/in-past {:minutes 1})} + {:id (:id object)}) + ;; gc-deleted skips it because status != 'valid' + (let [res (th/run-task! :storage-gc-deleted {})] + (t/is (= 0 (:deleted res)))) + ;; row still exists (with deleted_at set — we set it above) + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" + (:id object)])] + (t/is (= 1 (:count row)))))) + +(t/deftest gc-deleted-gives-up-after-max-attempts + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (sto/content "content") + object (sto/put-object! storage {::sto/content content + :content-type "text/plain"})] + + (th/db-update! :storage-object {:deleted-at (ct/in-past {:minutes 1}) + :deletion_attempts 6} + {:id (:id object)}) + + (with-mocks [_mock {:target 'app.storage.impl/del-objects-in-bulk + :return (fn [_ ids] (set ids))}] (let [res (th/run-task! :storage-gc-deleted {})] (t/is (= 0 (:deleted res))))) - (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))] - (let [res (th/run-task! :storage-gc-deleted {})] - (t/is (= 0 (:deleted res))))))) + (let [row (th/db-exec-one! ["select count(*) from storage_object where id = ?" (:id object)])] + (t/is (= 0 (:count row)))))) + +(t/deftest dedup-reuses-existing-blob-with-touch + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + t0 (ct/now) + params {::sto/deduplicate? true + ::sto/touch true + :bucket "file-media-object" + :content-type "text/plain"} + object1 (binding [ct/*clock* (ct/fixed-clock t0)] + (sto/put-object! storage (assoc params ::sto/content content)))] + + ;; a touched hit reuses the object and updates its touched_at + (let [object2 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 1}))] + (sto/put-object! storage (assoc params ::sto/content content)))] + (t/is (= (:id object1) (:id object2))) + + (let [row (th/db-exec-one! ["select touched_at from storage_object where id = ?" (:id object1)])] + (t/is (ct/is-after? (:touched-at row) t0)))) + + ;; with the blob removed, the touched hit repairs the stale row in + ;; place: the same id is kept, the row is not deleted and touched_at + ;; is left untouched (the touch flag only applies to healthy hits) + (let [path (sto/get-object-path storage object1)] + (fs/delete path)) + + (let [object3 (binding [ct/*clock* (ct/fixed-clock (ct/plus t0 {:hours 2}))] + (sto/put-object! storage (assoc params ::sto/content content)))] + (t/is (= (:id object1) (:id object3))) + + (let [row (th/db-exec-one! ["select deleted_at, touched_at from storage_object where id = ?" (:id object1)])] + (t/is (nil? (:deleted-at row))) + ;; the touch flag does not apply to repairs: touched_at was last + ;; set by the healthy hit and is not bumped by the repair + (t/is (ct/is-before? (:touched-at row) (ct/plus t0 {:hours 2}))))))) + +(t/deftest put-object-repair-failure-leaves-row-intact + (let [storage (-> (:app.storage/storage th/*system*) + (configure-storage-backend)) + content (-> (sto/content "content") + (sto/wrap-with-hash "same-hash")) + object (sto/put-object! storage {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + path (sto/get-object-path storage object) + + ;; Point the fs backend at a path that is actually a file so the + ;; blob write fails. + blocked (fs/path "/tmp/penpot" (str "blocked-" (uuid/next))) + _ (spit (str blocked) "x")] + (try + ;; remove the physical blob to simulate a stale/broken object + (fs/delete path) + + (let [broken (assoc-in storage [::sto/backends :fs ::sto.fs/directory] (str blocked)) + ex (try + (sto/put-object! broken {::sto/content content + ::sto/deduplicate? true + :bucket "file-media-object" + :content-type "text/plain"}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + + ;; the failed repair leaves the original row exactly as it was: + ;; live and valid, so a later upload can retry the healing + (let [row (th/db-exec-one! ["select status, deleted_at from storage_object where id = ?" (:id object)])] + (t/is (= "valid" (:status row))) + (t/is (nil? (:deleted-at row)))) + + (let [row (th/db-exec-one! ["select count(*) from storage_object"])] + (t/is (= 1 (:count row))))) + (finally + (fs/delete blocked))))) + +(t/deftest upload-chunks-exclude-pending + (let [prof (th/create-profile* 1) + _ (th/create-project* 1 {:profile-id (:id prof) + :team-id (:default-team-id prof)}) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + mfile {:filename "chunk" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + session-id (-> (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks 1}) + :result :session-id) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index 0 + :content mfile})] + + (t/is (nil? (:error out))) + + ;; mark all the chunks of this session as pending (simulates rows that + ;; were never promoted) + (th/db-exec! ["update storage_object set status = 'pending' where (metadata->>'~:upload-id') = ?" + (str session-id)]) + + ;; assembling fails because no chunk is visible anymore + (let [assemble-out (th/command! {::th/type :assemble-file-media-object + ::rpc/profile-id (:id prof) + :session-id session-id + :file-id (:id file) + :is-local true + :name "assembled-image" + :mtype "image/jpeg"})] + (t/is (some? (:error assemble-out)))))) + +(defn- fake-s3-backend + [] + {::sto/type :s3 + ::sto.s3/client (reify S3AsyncClient) + ::sto.s3/presigner (reify S3Presigner)}) + +(t/deftest s3-exists-object-returns-true-on-found + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/resolved {})}] + (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + (t/is (= 1 (:call-count @mock))))) + +(t/deftest s3-exists-object-returns-false-on-missing-key + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/rejected (-> (NoSuchKeyException/builder) + (.message "no key") + (.build)))}] + (t/is (false? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + ;; a missing key is a definitive answer: no retries + (t/is (= 1 (:call-count @mock))))) + +(t/deftest s3-exists-object-retries-transient-errors + (let [calls (atom 0)] + (with-mocks [_mock {:target 'app.storage.s3/head-object + :return (fn [& _] + (swap! calls inc) + (if (< @calls 3) + (p/rejected (RuntimeException. "boom")) + (p/resolved {})))}] + (t/is (true? (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}))) + (t/is (= 3 @calls))))) + +(t/deftest s3-exists-object-throws-after-retries-exhausted + (with-mocks [mock {:target 'app.storage.s3/head-object + :return (p/rejected (RuntimeException. "boom"))}] + ;; p/await returns the rejection wrapped in an ExecutionException + (let [ex (try + (impl/exists-object? (fake-s3-backend) {:id (uuid/next)}) + nil + (catch Throwable cause cause))] + (t/is (some? ex)) + (t/is (= "boom" (ex-message (ex-cause ex))))) + ;; one initial attempt plus max-retries + (t/is (= 4 (:call-count @mock))))) From 6bedd5ac76c469d5fe58c82a83ae5046f53a1560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Thu, 27 Aug 2026 12:41:32 +0200 Subject: [PATCH 212/298] :bug: Fix move organization modal (#11390) * :bug: Fix move organization modal * :paperclip: Code review --- frontend/src/app/main/data/nitrate.cljs | 10 ++- frontend/src/app/main/ui/dashboard/team.cljs | 90 +++++++++++++------- frontend/src/app/main/ui/dashboard/team.scss | 4 + frontend/translations/en.po | 11 ++- frontend/translations/es.po | 11 ++- 5 files changed, 84 insertions(+), 42 deletions(-) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 81008c3fff..2addcc0429 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -415,6 +415,7 @@ is-own? (= profile-id (:owner-id organization))] (or (= perm "any") is-own?))) all-organizations) team (first (filter #(= (:id %) team-id) teams)) + current-organization (:organization team) on-confirm (fn [organization-id] (st/emit! (add-team-to-organization {:team-id team-id :organization-id organization-id}))) @@ -422,11 +423,11 @@ (fn [organizations-allowed] (let [has-filtered? (< (count organizations) (count all-organizations)) extra-props (when has-filtered? - {:info-message-key "dashboard.select-organization-modal.permission-info"})] + {:info-message-key "dashboard.select-organization-modal.permission-info-add"})] (modal/show :select-organization-modal (merge {:organizations organizations :organizations-allowed organizations-allowed - :current-organization-id (dm/get-in team [:organization :id]) + :current-organization current-organization :on-confirm on-confirm :team-id team-id :title-key "dashboard.select-organization-modal.title" @@ -509,11 +510,12 @@ :title (tr "dashboard.change-organization-modal.title")}) (modal/show :select-organization-modal (merge {:organizations selectable-organizations - :organizations-allowed organizations-allowed - :current-organization-id current-organization-id + :organizations-allowed organizations-allowed + :current-organization source-organization :on-confirm on-confirm :team-id team-id :title-key "dashboard.change-organization-modal.title" + :description-key "dashboard.change-organization-modal.description" :choose-key "dashboard.change-organization-modal.choose" :placeholder-key "dashboard.change-organization-modal.select" :accept-key "dashboard.change-organization-modal.accept" diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 80d010c75b..a39b5eb9be 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -915,34 +915,57 @@ (mf/defc select-organization-modal {::mf/register modal/components ::mf/register-as :select-organization-modal} - [{:keys [organizations organizations-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key team-id]}] - (let [valid-organizations (mf/with-memo [organizations] - (remove #(= (:id %) current-organization-id) organizations)) - options (mf/with-memo [valid-organizations organizations-allowed] - (mapv (fn [organization] - (let [organization-id (:id organization) - ;; organizations-allowed is a map of organization-id and a boolean indicating if it is allowed - enabled? (or (nil? organizations-allowed) - (true? (get organizations-allowed organization-id)))] - (cond-> {:id (str organization-id) - :label (:name organization) - :disabled (not enabled?) - :dimmed (not enabled?) - :avatar {:render-fn render-organization-combobox-avatar* - :organization organization - :size "xl"}} - (not enabled?) - (assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip"))))) - valid-organizations)) + [{:keys [organizations organizations-allowed current-organization on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key description-key team-id]}] + (let [current-organization-id (:id current-organization) + has-current-org? (some? current-organization) + valid-organizations (mf/with-memo [organizations current-organization-id] + (remove #(= (:id %) current-organization-id) organizations)) + all-organizations (mf/with-memo [organizations current-organization] + (cond-> organizations + (and has-current-org? + (not (some #(= (:id %) current-organization-id) organizations))) + (conj current-organization))) + options (mf/with-memo [valid-organizations organizations-allowed current-organization] + (let [other-options + (mapv (fn [organization] + (let [organization-id (:id organization) + enabled? (or (nil? organizations-allowed) + (true? (get organizations-allowed organization-id)))] + (cond-> {:id (str organization-id) + :label (:name organization) + :disabled (not enabled?) + :dimmed (not enabled?) + :avatar {:render-fn render-organization-combobox-avatar* + :organization organization + :size "xl"}} + (not enabled?) + (assoc :title (tr "dashboard.team-organization.disabled-organization-tooltip"))))) + valid-organizations)] + (if has-current-org? + (into [{:id (str current-organization-id) + :label (:name current-organization) + :avatar {:render-fn render-organization-combobox-avatar* + :organization current-organization + :size "xl"}}] + other-options) + other-options))) - form (fm/use-form :schema schema:organization-form :initial {}) + initial-form (mf/with-memo [has-current-org? current-organization-id] + (if has-current-org? + {:selected-id (str current-organization-id)} + {})) + form (fm/use-form :schema schema:organization-form :initial initial-form) - warning-info* (mf/use-state nil) - warning-info (deref warning-info*) - selected-organization (mf/with-memo [warning-info valid-organizations] - (when warning-info - (d/seek #(= (:id %) (:organization-id warning-info)) valid-organizations))) + warning-info* (mf/use-state nil) + warning-info (deref warning-info*) + selected-organization (mf/with-memo [warning-info all-organizations] + (when warning-info + (d/seek #(= (:id %) (:organization-id warning-info)) all-organizations))) + selected-id (dm/get-in @form [:data :selected-id]) + disabled? (or (not (:valid @form)) + (and has-current-org? + (= (str selected-id) (str current-organization-id)))) on-change (mf/use-fn (mf/deps form team-id) @@ -978,18 +1001,25 @@ [:div {:class (stl/css :modal-content :modal-select-organization-text)} (tr text-key)]) [:div {:class (stl/css :modal-select-organization-body)} - (when info-message-key + (when (or description-key info-message-key) [:div {:class (stl/css :modal-select-organization-info)} - (tr info-message-key)]) + (when description-key + [:div + (tr description-key)]) + (when info-message-key + [:div + (tr info-message-key)])]) [:div {:class (stl/css :modal-select-organization-content)} (tr choose-key)] [:> combobox* {:id "selected-id" :class (stl/css :team-member) :options options :select-only true - :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") :placeholder (tr placeholder-key) - :on-change on-change}] + :on-change on-change + :default-selected (if has-current-org? + (str current-organization-id) + "")}] ;; Warning for external invitations (when (and warning-info @@ -1017,7 +1047,7 @@ {:class (stl/css :accept-btn) :variant "primary" :type "button" - :disabled (not (:valid @form)) + :disabled disabled? :on-click on-confirm'} (tr accept-key)]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 3408a4697e..34f97d4850 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -963,7 +963,11 @@ .modal-select-organization-info { @include t.use-typography("body-medium"); + display: flex; + flex-direction: column; + gap: var(--sp-m); color: var(--color-foreground-secondary); + margin-block-end: var(--sp-xxl); } .modal-select-organization-title { diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b85b0f4cc0..d4b5f28d2c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -388,6 +388,9 @@ msgstr "" msgid "dashboard.change-organization-modal.title" msgstr "Change team's organization" +msgid "dashboard.change-organization-modal.description" +msgstr "Projects and files will remain available to team members. The team will get the configuration from the new organization." + #: src/app/main/ui/dashboard/deleted.cljs:316 msgid "dashboard.clear-trash-button" msgstr "Clear trash" @@ -1111,11 +1114,11 @@ msgstr "" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgstr "Pending invitations to external users will be canceled." -#, unused msgid "dashboard.select-organization-modal.permission-info" -msgstr "" -"Here you find all your organizations where you are allowed to create or add " -"teams." +msgstr "Here you'll find the organizations you are part of where you are allowed to move the team." + +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "Here you'll find the organizations you are part of where you are allowed to create or add teams." #, unused msgid "dashboard.select-organization-modal.select" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index acfeeb8fab..d9421628a4 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -395,6 +395,9 @@ msgstr "" msgid "dashboard.change-organization-modal.title" msgstr "Cambiar el equipo de organización" +msgid "dashboard.change-organization-modal.description" +msgstr "Los proyectos y archivos permanecerán disponibles para los miembros del equipo. El equipo obtendrá la configuración de la nueva organización." + #: src/app/main/ui/dashboard/deleted.cljs:316 msgid "dashboard.clear-trash-button" msgstr "Vaciar papelera" @@ -1118,11 +1121,11 @@ msgstr "" msgid "dashboard.select-organization-modal.external-invitations-will-be-canceled" msgstr "Las invitaciones pendientes a usuarios externos serán canceladas." -#, unused msgid "dashboard.select-organization-modal.permission-info" -msgstr "" -"Aquí encontrarás todas las organizaciones en las que tienes permiso para " -"crear o añadir equipos." +msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para mover el equipo." + +msgid "dashboard.select-organization-modal.permission-info-add" +msgstr "Aquí encontrarás las organizaciones de las que eres parte donde tienes permiso para crear o añadir equipos." #, unused msgid "dashboard.select-organization-modal.select" From 43bbf756b0a89b2ff76b7e15cb40134549fc1e91 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 18:30:33 +0200 Subject: [PATCH 213/298] :bug: Fix font change regression and activate font preview by default (#11341) * :sparkles: Materialize font-preview flag to make it permanent * :bug: Fix font change regression when WebGL is disabled * :paperclip: Add fixes for e2e tests * :bug: Fix font change from legacy render --------- Co-authored-by: Luis de Dios --- .../playwright/ui/specs/tokens/crud.spec.js | 8 +-- .../src/app/main/data/workspace/texts.cljs | 17 +++-- frontend/src/app/main/fonts.cljs | 6 +- frontend/src/app/main/ui/workspace.cljs | 8 +-- .../main/ui/workspace/shapes/text/editor.cljs | 38 ++++++----- .../workspace/sidebar/options/menus/text.cljs | 2 +- .../sidebar/options/menus/typography.cljs | 64 ++++++++----------- .../sidebar/options/menus/typography.scss | 21 +++--- .../src/app/main/ui/workspace/viewport.cljs | 4 +- .../app/main/ui/workspace/viewport_wasm.cljs | 4 +- frontend/src/app/util/dom.cljs | 10 +++ .../data/workspace_texts_test.cljs | 10 +-- 12 files changed, 95 insertions(+), 97 deletions(-) diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 4ba8d7fd49..35922e0af1 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -569,7 +569,7 @@ test.describe("Tokens - creation", () => { }); await selectDropdown.click(); - const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); + const fontOption = tokensUpdateCreateModal.getByRole('img', { name: 'ABeeZee' }) await expect(fontOption).toBeVisible(); await fontOption.click(); @@ -583,7 +583,7 @@ test.describe("Tokens - creation", () => { name: "Search font", }); await searchField.fill("alme"); - const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); + const fontOption2 = tokensUpdateCreateModal.getByRole('img', {name: "Almendra Display"}); await expect(fontOption2).toBeVisible(); await fontOption2.click(); @@ -1521,7 +1521,7 @@ test.describe("Tokens - creation", () => { }); await selectDropdown.click(); - const fontOption = tokensUpdateCreateModal.getByText("ABeeZee"); + const fontOption = tokensUpdateCreateModal.getByRole("img", {name: "ABeeZee"}); await expect(fontOption).toBeVisible(); await fontOption.click(); @@ -1536,7 +1536,7 @@ test.describe("Tokens - creation", () => { name: "Search font", }); await searchField.fill("alme"); - const fontOption2 = tokensUpdateCreateModal.getByText("Almendra Display"); + const fontOption2 = tokensUpdateCreateModal.getByRole("img", {name: "Almendra Display"}); await expect(fontOption2).toBeVisible(); await fontOption2.click(); await expect( diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 3db75ccb8e..dd8741218d 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -174,7 +174,12 @@ (rx/mapcat (fn [_] (rx/from (fonts/ensure-loaded! font-id font-variant-id)))) (rx/take-until (text-work-stopper stream)) - (rx/ignore) + (rx/mapcat (fn [_] + (st/emit! (dwsh/update-shapes + ids + #(dissoc % :position-data) + {:save-undo? false})) + (rx/empty))) (wrf/with-pending :font ids)))) ;; -- Content helpers @@ -850,16 +855,16 @@ ::resize-text-debounce-event))))) (rx/empty)))))) -(defn save-font +(defn save-default-font [data] - (ptk/reify ::save-font + (ptk/reify ::save-default-font ptk/UpdateEvent (update [_ state] - (let [multiple? (->> data vals (d/seek #(= % :multiple)))] + (let [multiple? (->> data vals (d/seek #(= % :multiple))) + font (dissoc data :typography-ref-id :typography-ref-file)] (cond-> state (not multiple?) - (assoc-in [:workspace-global :default-font] - (dissoc data :typography-ref-id :typography-ref-file))))))) + (update :workspace-global assoc :default-font font)))))) (defn apply-text-modifier [shape text-modifier] diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index 644169d3de..6db6fd3817 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -242,7 +242,7 @@ (when (zero? (:refs new-state)) (dom/remove! node)))) -(defn- add-font-css! +(defn- add-font-css "Creates a style element and attaches it to the dom." [id css] (let [node (dom/create-element "style")] @@ -306,7 +306,7 @@ (->> (request-gfont-css url) (rx/map process-gfont-css) (rx/tap #(on-loaded id)) - (rx/subs! (partial add-font-css! id) + (rx/subs! (partial add-font-css id) #(when (fn? on-failed) (on-failed %)))) nil))) @@ -346,7 +346,7 @@ (when (globals/browser?) (log/dbg :hint "load-font" :font-id id :backend "custom") (let [css (generate-custom-font-css font)] - (add-font-css! id css) + (add-font-css id css) (when (fn? on-loaded) (on-loaded))))) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 278663bbcb..a5920570a6 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] [app.main.data.persistence :as dps] @@ -229,12 +228,8 @@ (st/emit! (dps/initialize-persistence) (dpl/update-plugins-permissions-peek))) - ;; FLAG :font-preview — prefetch the preview sprite markup on workspace mount - ;; (kept in memory, not the DOM) so the typography selector renders previews on - ;; open with no network wait. Remove the flag check to drop the feature. (mf/with-effect [] - (when (contains? cf/flags :font-preview) - (fonts/prefetch-preview-sprite!))) + (fonts/prefetch-preview-sprite!)) ;; Setting the layout preset by its name (mf/with-effect [layout-name] @@ -302,4 +297,3 @@ (when (uuid? file-id) [:> workspace* props]))) - 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 536febb61b..6f949e5e72 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -30,18 +30,24 @@ ;; --- Text Editor Rendering -(mf/defc block-component* - [{:keys [block-props] :as props}] - (let [data (.-data ^js block-props) - style (sts/generate-paragraph-styles (.-shape ^js block-props) data) - dir (:text-direction data "auto")] +(mf/defc block-component + {::mf/wrap-props false} + [props] + (let [bprops (obj/get props "blockProps") + data (obj/get bprops "data") + style (sts/generate-paragraph-styles (obj/get bprops "shape") + (obj/get bprops "data")) + dir (:text-direction data "auto")] + [:div {:style style :dir dir} [:> draft/EditorBlock props]])) -(mf/defc selection-component* - [{:keys [children]}] - [:span {:style {:background "#ccc" :display "inline-block"}} children]) +(mf/defc selection-component + {::mf/wrap-props false} + [props] + (let [children (obj/get props "children")] + [:span {:style {:background "#ccc" :display "inline-block"}} children])) (defn- render-block [block shape] @@ -49,7 +55,7 @@ (case type "unstyled" #js {:editable true - :component block-component* + :component block-component :props #js {:data (ted/get-editor-block-data block) :shape shape}} nil))) @@ -63,7 +69,7 @@ (sts/generate-text-styles shape data {:show-text? false}))) (def default-decorator - (ted/create-decorator "PENPOT_SELECTION" selection-component*)) + (ted/create-decorator "PENPOT_SELECTION" selection-component)) (def empty-editor-state (ted/create-editor-state nil default-decorator)) @@ -89,11 +95,12 @@ "bottom" "flex-end" nil)) -(mf/defc text-shape-edit-html* +(mf/defc text-shape-edit-html {::mf/wrap [mf/memo] + ::mf/wrap-props false ::mf/forward-ref true} - [{:keys [shape]} _] - (let [{:keys [id content]} shape + [props _] + (let [{:keys [id content] :as shape} (obj/get props "shape") state-map (mf/deref refs/workspace-editor-state) state (get state-map id empty-editor-state) @@ -268,7 +275,8 @@ (-> (gpt/subtract pt box) (gpt/multiply zoom))))) -(mf/defc text-editor-svg* +(mf/defc text-editor-svg + {::mf/wrap-props false} [{:keys [shape modifiers]}] (let [shape-id (dm/get-prop shape :id) modifiers (dm/get-in modifiers [shape-id :modifiers]) @@ -341,6 +349,6 @@ [:foreignObject {:x x :y y :width width :height height} [:div {:style style} - [:> text-shape-edit-html* + [:& text-shape-edit-html {:shape shape :key (dm/str shape-id)}]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60b0c15c02..2054cc763c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -412,7 +412,7 @@ (select-keys txt/text-node-attrs))] (when (features/active-feature? @st/state "text-editor-wasm/v1") (st/emit! (dwt-v3/v3-update-text-editor-styles (first ids) attrs))) - (st/emit! (dwt/save-font updated-attrs) + (st/emit! (dwt/save-default-font updated-attrs) (dwt/update-all-attrs ids attrs))))) on-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 1a085ef1c4..53a5a26b01 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -12,7 +12,6 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.types.text :as txt] - [app.config :as cf] [app.main.constants :refer [max-input-length]] [app.main.data.common :as dcm] [app.main.data.fonts :as fts] @@ -91,19 +90,13 @@ (constantly nil))))) @loaded?)) -;; --- FEATURE: font preview (flag :font-preview) ------------------------------ -;; font-item-preview* and use-font-lazy-load are the whole feature. They are only -;; rendered/called behind the `:font-preview` flag check in font-item* below, so -;; their hooks never run when the flag is off. To remove the flag, inline -;; font-item-preview* into font-item* and drop the plain-name branch. - (mf/defc font-item-preview* "Row content with previews: a vector preview from the shared sprite for catalog fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't cover." {::mf/wrap [mf/memo]} [{:keys [font]}] - (let [font-id (:id font) + (let [font-id (get font :id) sprite (mf/deref fonts/preview-sprite) ;; The sprite is only referenceable once it's been attached to the DOM, @@ -133,29 +126,20 @@ {::mf/wrap [mf/memo]} [{:keys [font is-current on-click style]}] (let [item-ref (mf/use-ref) - on-click (mf/use-fn (mf/deps font) #(on-click font)) - ;; FLAG :font-preview — gates the feature markup AND its row styling - ;; (.font-item-preview-on in the scss). Remove this and its two uses below. - preview? (contains? cf/flags :font-preview)] + on-click (mf/use-fn (mf/deps font) #(on-click font))] - (mf/use-effect - (mf/deps is-current) - (fn [] - (when is-current - (let [element (mf/ref-val item-ref)] - (when-not (dom/is-in-viewport? element) - (dom/scroll-into-view! element)))))) + (mf/with-effect [is-current] + (when is-current + (let [element (mf/ref-val item-ref)] + (when-not (dom/is-in-viewport? element) + (dom/scroll-into-view! element))))) [:div {:class (stl/css :font-wrapper) :style style :ref item-ref :on-click on-click} - [:div {:class (stl/css-case :font-item true - :font-item-preview-on preview? - :selected is-current)} - (if preview? - [:> font-item-preview* {:font font}] - [:span {:class (stl/css :font-item-label)} (:name font)]) + [:div {:class (stl/css-case :font-item true :selected is-current)} + [:> font-item-preview* {:font font}] (when is-current [:> icon* {:icon-id i/tick :size "s"}])]])) @@ -260,14 +244,13 @@ (let [key (events/listen js/document "keydown" on-key-down)] #(events/unlistenByKey key))) - ;; FLAG :font-preview — materialize the preview sprite into the DOM only while - ;; the picker is open (markup is prefetched on workspace load), removing it on - ;; close so its ~2000 nodes aren't kept around idle. The attachment is deferred - ;; so the dropdown can paint first with plain names, then the sprite swaps in - ;; on the next tick. Remove the flag clause to drop the feature. + ;; Materialize the preview sprite into the DOM only while the picker is open + ;; (markup is prefetched on workspace load), removing it on close so its + ;; ~2000 nodes aren't kept around idle. The attachment is deferred so the + ;; dropdown can paint first with plain names, then the sprite swaps in on the + ;; next tick. (mf/with-effect [sprite-status] - (when (and (contains? cf/flags :font-preview) - (= :ready sprite-status)) + (when (= :ready sprite-status) (let [node* (volatile! nil) task (tm/schedule (fn [] @@ -278,9 +261,11 @@ (fonts/detach-preview-sprite! n)))))) (mf/with-effect [@selected] - (when-let [inst (mf/ref-val flist)] - (when-let [index (:index @selected)] - (.scrollToRow ^js inst index)))) + (let [node (mf/ref-val flist) + index (:index @selected)] + ;; This is nil safe operation, do nothing if node or index are + ;; invalid. + (dom/scroll-to-row node index))) (mf/with-effect [@selected] (on-select @selected)) @@ -291,11 +276,12 @@ (st/emit! (dsc/pop-shortcuts :typography)))) (mf/with-effect [] - (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) - inst (mf/ref-val flist)] + (let [index (d/index-of-pred fonts #(= (:id %) (:id current-font))) + node (mf/ref-val flist)] (tm/schedule - #(let [offset (.getOffsetForRow ^js inst #js {:alignment "center" :index index})] - (.scrollToPosition ^js inst offset))))) + #(let [offset (.getOffsetForRow ^js node #js {:alignment "center" :index index})] + ;; Safe operaton, do nothing if node or offset has invalid values + (dom/scroll-to-position node offset))))) [:div {:class [(stl/css-case :font-selector true :fonts-on-modal (not full-size?))]} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index c5b1a59d2f..967e9d7c0f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -388,20 +388,7 @@ $font-preview-box-height: 28px; &.selected { color: var(--color-foreground-primary); } -} -.font-item-label { - @include t.use-typography("body-small"); - @include text-ellipsis; - - flex-grow: 1; - min-inline-size: 0; -} - -// --- FLAG :font-preview row styling. Only applied when font-item* adds the -// .font-item-preview-on modifier; remove this whole block with the flag so rows -// render exactly as before. -.font-item-preview-on { // Center & clip so a previewed font's own metrics never grow/overflow the row. align-items: center; overflow: hidden; @@ -414,6 +401,14 @@ $font-preview-box-height: 28px; } } +.font-item-label { + @include t.use-typography("body-small"); + @include text-ellipsis; + + flex-grow: 1; + min-inline-size: 0; +} + // `currentColor` makes the glyph fill follow the row text color (theme + selected). .font-item-preview { flex-grow: 1; diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 4814be5042..0f5fbfeeac 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -455,8 +455,8 @@ [:& editor-v2/text-editor {:shape editing-shape :canvas-ref canvas-ref :modifiers modifiers}] - [:> editor-v1/text-editor-svg* {:shape editing-shape - :modifiers modifiers}])) + [:& editor-v1/text-editor-svg {:shape editing-shape + :modifiers modifiers}])) (when show-frame-outline? (let [outlined-frame-id diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 5a9601502d..1967662aa1 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -747,8 +747,8 @@ :canvas-ref canvas-ref :ref text-editor-ref}] - :else [:> editor-v1/text-editor-svg* {:shape editing-shape - :ref text-editor-ref}])) + :else [:& editor-v1/text-editor-svg {:shape editing-shape + :ref text-editor-ref}])) (when show-frame-outline? (let [outlined-frame-id (->> @hover-ids diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 6a0f514018..5995a51264 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -244,6 +244,16 @@ height (.-clientHeight scroll-node)] (/ distance height))) +(defn scroll-to-row + [node index] + (when (and (some? node) (number? index)) + (.scrollToRow ^js node index))) + +(defn scroll-to-position + [node offset] + (when (and (some? node) (number? offset)) + (.scrollToPosition ^js node offset))) + (def get-target-val (comp get-value get-target)) (def get-target-scroll (comp get-scroll-position get-target)) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 5822b571cf..ebb315b3f6 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -379,14 +379,14 @@ "float letter-spacing is normalised to 2-decimal string"))))))) ;; --------------------------------------------------------------------------- -;; Tests: save-font must not persist typography refs into the global default font +;; Tests: save-default-font must not persist typography refs into the global default font ;; ;; Root cause of #10925: typography assets are file-specific references, but -;; save-font used to write :typography-ref-id / :typography-ref-file into the +;; save-default-font used to write :typography-ref-id / :typography-ref-file into the ;; session-global [:workspace-global :default-font]. That state survives a file ;; switch, and v2-default-text-content bakes it into brand-new text shapes in ;; the other file, so they got a non-existent typography asset instead of the -;; default Penpot font. save-font now strips those two keys. +;; default Penpot font. save-default-font now strips those two keys. ;; --------------------------------------------------------------------------- (t/deftest save-font-strips-typography-refs-from-default-font @@ -405,7 +405,7 @@ :typography-ref-id (uuid/next) :typography-ref-file (:id file)}] (ths/run-store - store done [(dwt/save-font attrs)] + store done [(dwt/save-default-font attrs)] (fn [new-state] (let [default-font (get-in new-state [:workspace-global :default-font])] (t/is (some? default-font)) @@ -425,7 +425,7 @@ :letter-spacing "0" :typography-ref-id (uuid/next) :typography-ref-file (uuid/next)}] - (ths/run-store store done [(dwt/save-font attrs)] + (ths/run-store store done [(dwt/save-default-font attrs)] (fn [new-state] (let [default-font (get-in new-state [:workspace-global :default-font])] (t/is (= "Open Sans" (:font-family default-font))) From 11224940ab6e54a00a8f063f28325efe7a27e160 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Thu, 27 Aug 2026 18:32:22 +0200 Subject: [PATCH 214/298] :bug: Fix wrong selection color on inputs (#11400) --- frontend/resources/styles/common/base.scss | 5 +++++ .../src/app/main/ui/ds/controls/utilities/input_field.scss | 1 + 2 files changed, 6 insertions(+) diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 41d79a0bf4..6407bb5969 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -41,6 +41,11 @@ body { scrollbar-width: thin; } +::selection { + background: var(--color-accent-background-select); + color: var(--color-static-white); +} + img { height: auto; width: 100%; diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index 9aede7e748..df04187129 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -97,6 +97,7 @@ &::selection { background: var(--color-accent-background-select); + color: var(--color-static-white); } &::placeholder { From 1baa8bb29f6ffff54d283d11c44b97630ec40cb6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 18:33:48 +0200 Subject: [PATCH 215/298] :bug: Center canvas and select layer when navigating layer search results (#10422) (#11391) When using previous/next buttons in the layer search panel, the canvas now centers on the matched layer and selects it in the layers panel. Previously only the internal search cursor was advanced without any visible canvas or selection update. Co-authored-by: Sumit Ridhal --- frontend/src/app/main/data/workspace.cljs | 1 + .../src/app/main/data/workspace/zoom.cljs | 26 +++++++++++++++++++ .../app/main/ui/workspace/sidebar/layers.cljs | 8 +++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 518729d806..da6a9bb076 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1698,6 +1698,7 @@ (dm/export dwgu/set-hover-guide) ;; Zoom +(dm/export dwz/center-on-shape) (dm/export dwz/reset-zoom) (dm/export dwz/zoom-to-selected-shape) (dm/export dwz/start-zooming) diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 1a5afc7d0b..4be06e5e28 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -135,6 +135,32 @@ (effect [_ state _] (dwvw/maybe-sync-workspace-local-viewport! state)))) +(defn center-on-shape + "Pan the viewport to center on the shape with the given id without changing zoom." + [id] + (ptk/reify ::center-on-shape + ptk/UpdateEvent + (update [_ state] + (if (dwvw/render-context-lost? state) + state + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shape (get objects id) + srect (:selrect shape)] + (if (nil? srect) + state + (update state :workspace-local + (fn [{:keys [vbox] :as local}] + (let [cx (+ (:x srect) (/ (:width srect) 2)) + cy (+ (:y srect) (/ (:height srect) 2)) + new-x (- cx (/ (:width vbox) 2)) + new-y (- cy (/ (:height vbox) 2))] + (update local :vbox assoc :x new-x :y new-y)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) + (def zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape ptk/UpdateEvent diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index e9db4a2343..844fbb4efd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -438,7 +438,7 @@ navigate-next (mf/use-fn - (mf/deps text-match-count) + (mf/deps text-match-ids text-match-count) (fn [_] (when (pos? text-match-count) (let [ids (mf/ref-val text-match-ids-ref) @@ -447,11 +447,11 @@ (mf/set-ref-val! match-idx-ref next-idx) (swap! state* assoc :current-match-idx next-idx) (st/emit! (dw/select-shape id) - dw/zoom-to-selected-shape))))) + (dw/center-on-shape id)))))) navigate-prev (mf/use-fn - (mf/deps text-match-count) + (mf/deps text-match-ids text-match-count) (fn [_] (when (pos? text-match-count) (let [ids (mf/ref-val text-match-ids-ref) @@ -460,7 +460,7 @@ (mf/set-ref-val! match-idx-ref prev-idx) (swap! state* assoc :current-match-idx prev-idx) (st/emit! (dw/select-shape id) - dw/zoom-to-selected-shape))))) + (dw/center-on-shape id)))))) handle-replace (mf/use-fn From 38004e6bb2bcf33935aa42af2ba6e1e0f5637a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero-Cantero?= <807608+alvorithm@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:54:56 +0200 Subject: [PATCH 216/298] :sparkles: Add the graph subsystem and graph visualization console to the backend (#11101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :tada: Basic lbug connection for ingestion * :sparkles: Add Penpot-to-Ladybug graph ingest vertical slice * :sparkles: Use embedded Ladybug Java API instead of CLI * :recycle: Share Ladybug connection across ingest and stats * :sparkles: Validate graph ingest projections with Malli * :sparkles: Project nested shapes recursively into the graph * :zap: Load graph ingest via Ladybug COPY bulk import * :bug: Fix graph COPY ingest for multiline text names * :sparkles: Add Ladybug graph export to debug UI * :sparkles: Add debug graph console for in-memory Cypher queries * :sparkles: Add live file-change feed to debug graph console * :sparkles: Incrementally sync debug graph from Penpot file changes * :sparkles: Handle mov-objects in debug graph sync * :bug: Fix batch delete sync and keep graph console feed alive * :recycle: Derive graph node schema from Malli registry * :sparkles: Add G6 graph view to debug graph console POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages. Signed-off-by: Álvaro Tejero Cantero * :bug: Fix list-column CSV ingest and serialize graph session access COPY failed on any file with container shapes: list-typed DDL columns (shapes UUID[], points STRING[], strokes JSON[], ...) were JSON-encoded in staging CSVs, which Ladybug's list parser rejects. Write Kuzu list literals instead, typed per column. Also: value->clj no longer crashes on LIST/STRUCT values (binding lacks value_get_value support; fall back to string), and the debug session Connection is now guarded by a per-session lock — it was shared unsynchronized between the msgbus sync loop and HTTP query/export handlers, and one lost DETACH DELETE was observed under concurrent refetch load. Signed-off-by: Álvaro Tejero Cantero * :sparkles: Split graph console in two columns; add file tree and fullscreen Graph view moves to its own sticky right column (overrides .widget max-width). New /dbg/actions/graph-files endpoint lists teams -> projects -> files for the profile; the console renders it as a collapsible tree where clicking a file loads it. Maximize button fullscreens the graph panel and resizes G6 on fullscreenchange. Signed-off-by: Álvaro Tejero Cantero * :recycle: Replace fullscreen with in-page expand for graph view Fullscreen API took over the whole output and broke window-manager splits (and is denied in some environments). The Expand button now toggles a fixed-position overlay covering the page while keeping browser chrome; Esc restores. Column positioning moved from inline style to the stylesheet so the expanded class can override it. Signed-off-by: Álvaro Tejero Cantero * :sparkles: Fold containers as collapsible combos in graph view Non-empty containers (Page, Frame, Group, Boolean, SVGRaw) render as nested G6 rect combos holding their own node plus direct children; Document stays a plain node. Double-click folds/expands (collapse-expand behavior); collapsed combos show a member count and re-route child edges. Fold state is read back from getComboData and re-marked on every refetch, so it survives live redraws. Layout gains sortByCombo to keep same-rank nodes grouped by box. Signed-off-by: Álvaro Tejero Cantero * :zap: Fix graph view freeze on large files; add fold toggle and root rule Root cause of the tab freeze on ~1700-node files was G6's default entrance animation: measured 1700 nodes at >2 min animated vs 1.5 s with animation: false. Secondary cost was antv-dagre (~7 s at that size); since IsChildOf is a tree, an O(n) tidy layout (depth = rank, post-order leaf slots, parents centered) computed client-side replaces it and renders the same file in ~1.4 s. A guard skips auto-render above 4000 nodes with an explicit Render-anyway button, so opening the console with a huge session loaded stays responsive. Folding is now switchable ('fold containers' checkbox, persisted in localStorage) and generalized: any node with children folds except the IsChildOf root of the loaded graph, so Documents (and later Projects/Teams) fold automatically once they gain a parent node. Signed-off-by: Álvaro Tejero Cantero * :sparkles: Add layout dropdown to graph view Adds a layout + +
    + + + {% endif %}
    Import binfile: Import penpot file in binary format. diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl new file mode 100644 index 0000000000..8746e65ec3 --- /dev/null +++ b/backend/resources/app/templates/graph-console.tmpl @@ -0,0 +1,1758 @@ +{% extends "app/templates/base.tmpl" %} + +{% block title %} +Graph Console +{% endblock %} + +{% block content %} + +
    + +
    +

    ← Back to debug

    + +
    +
    + +
    + Load graph from Penpot + + Click file or paste UUID to load Penpot file into an in-memory + Ladybug database. Loading a new file replaces the previous one. + +
    Loading…
    +
    +
    + + {% if session %} + + + {% else %} + + {% endif %} +
    +
    + {% if session %} +
    + {% endif %} +
    + + {% if session %} +
    + Loaded session ({{session.loaded-at}}) + +

    + File: {{session.name}} +
    + + Revisions: ingested at {{session.revn}} · graph now + {% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %} +
    + Graph size:
    + Schema: {{session.schema-version}} +

    +

    + Feed: connecting… + +

    +
    +
    + + + +
    + Query graph, read-only (LadybugDB Cypher) +
    +
    + +
    +
    + +
    +
    +
    + +
    + {% if error %} +
    + Error +
    {{error}}
    +
    + {% endif %} + + {% if query-result %} +
    + Results ({{query-result.row-count}} rows{% if query-result.truncated? %}, truncated{% endif %}) +
    + + + + {% for column in query-result.columns %} + + {% endfor %} + + + + {% for row in query-result.rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
    {{column}}
    {{cell}}
    +
    +
    + {% endif %} +
    + {% endif %} + +
    + + {% if session %} +
    +
    + Graph view + + + + + + + + + Live view of the in-memory Ladybug graph (AntV G6). Double-click + folds containers when folding is on. + +
    +
    +
    + + + +
    +
    + {% endif %} + +
    +
    +
    + + + + + +{% if session %} + + +{% endif %} +{% endblock %} diff --git a/backend/scripts/_env b/backend/scripts/_env index 724b55f05b..5e4b02b80a 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -93,7 +93,8 @@ export JAVA_OPTS="\ -XX:-OmitStackTraceInFastThrow \ --sun-misc-unsafe-memory-access=allow \ --enable-preview \ - --enable-native-access=ALL-UNNAMED"; + --enable-native-access=ALL-UNNAMED \ + --add-opens=java.base/java.nio=ALL-UNNAMED"; function setup_minio() { if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then diff --git a/backend/scripts/run.template.sh b/backend/scripts/run.template.sh index cff4afc870..19f47e6c0a 100644 --- a/backend/scripts/run.template.sh +++ b/backend/scripts/run.template.sh @@ -18,7 +18,7 @@ if [ -f ./environ ]; then source ./environ fi -export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS" +export JAVA_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --sun-misc-unsafe-memory-access=allow --enable-native-access=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --enable-preview $JVM_OPTS $JAVA_OPTS" ENTRYPOINT=${1:-app.main}; diff --git a/backend/src/app/graph/arrow.clj b/backend/src/app/graph/arrow.clj new file mode 100644 index 0000000000..55be1b8b64 --- /dev/null +++ b/backend/src/app/graph/arrow.clj @@ -0,0 +1,370 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.arrow + "Bulk Ladybug ingest through in-memory Arrow. + + Rows are built as Arrow `VectorSchemaRoot`s in the JVM's off-heap memory, + handed to Ladybug as a virtual table, and `COPY`d into the real one. No file + is written and no value is rendered as text for the engine to re-parse, so + nothing in this path needs escaping. Arrow carries MAP, STRUCT, fixed-size + arrays and multi-line strings natively. + + The type language is Ladybug's, read recursively by `app.graph.schema.values`; + this namespace adds the matching Arrow `Field` and a writer for each shape. + `values/coerce` shapes a value first — a matrix into six doubles, a colour + into a packed integer — exactly as it does for the Cypher path, so the two + writers cannot disagree. + + Engine facts this file depends on, each verified against lbug 0.19.1: + + - An Arrow table is **not** a `COPY` source identifier, but it *is* a + MATCH-able node label: `COPY T FROM (MATCH (n:stg) RETURN n.a AS a, …)`. + - A MAP vector's `entries` child struct must be non-nullable, and + `MapVector/getWriter` silently promotes it to a sparse union — so map + vectors are built from an explicit `Field` and filled child-first. + - Ladybug quotes the column and table names it interpolates into the staged + table's DDL, and does not quote a STRUCT member name. So a top-level field + arrives plain and a struct member whose name is a reserved word (`column`) + arrives backticked. + - `createArrowRelTable` resolves a UUID-keyed endpoint only from a + `FixedSizeBinary(16)` column carrying the `arrow.uuid` extension, so edges + are staged as a node table and joined by the `COPY` subquery instead." + (:require + [app.common.json :as json] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.schema.values :as values] + [clojure.string :as str]) + (:import + com.ladybugdb.Connection + com.ladybugdb.QueryResult + java.nio.charset.StandardCharsets + java.util.ArrayList + java.util.List + org.apache.arrow.memory.BufferAllocator + org.apache.arrow.memory.RootAllocator + org.apache.arrow.vector.BigIntVector + org.apache.arrow.vector.BitVector + org.apache.arrow.vector.complex.ListVector + org.apache.arrow.vector.complex.MapVector + org.apache.arrow.vector.complex.StructVector + org.apache.arrow.vector.FieldVector + org.apache.arrow.vector.Float8Vector + org.apache.arrow.vector.TimeStampMicroVector + org.apache.arrow.vector.types.FloatingPointPrecision + org.apache.arrow.vector.types.pojo.ArrowType$Bool + org.apache.arrow.vector.types.pojo.ArrowType$FloatingPoint + org.apache.arrow.vector.types.pojo.ArrowType$Int + org.apache.arrow.vector.types.pojo.ArrowType$List + org.apache.arrow.vector.types.pojo.ArrowType$Map + org.apache.arrow.vector.types.pojo.ArrowType$Struct + org.apache.arrow.vector.types.pojo.ArrowType$Timestamp + org.apache.arrow.vector.types.pojo.ArrowType$Utf8 + org.apache.arrow.vector.types.pojo.Field + org.apache.arrow.vector.types.pojo.FieldType + org.apache.arrow.vector.types.pojo.Schema + org.apache.arrow.vector.types.TimeUnit + org.apache.arrow.vector.UInt4Vector + org.apache.arrow.vector.VarCharVector + org.apache.arrow.vector.VectorSchemaRoot)) + +(set! *warn-on-reflection* true) + +;; --------------------------------------------------------------- allocator + +(defn with-allocator! + "Invoke `(f allocator)` with a fresh Arrow `RootAllocator`. + + The allocator must outlive the Ladybug connection, because Ladybug releases + its references to the staged buffers only when the Arrow tables are dropped — + which happens on connection close at the latest. Closing it first surfaces as + `IllegalStateException: Memory was leaked`, *thrown while unwinding*, which + hides whatever actually failed. Any diagnostic here must catch inside this + scope." + [f] + (with-open [allocator (RootAllocator.)] + (f allocator))) + +;; ------------------------------------------------------ Ladybug type → Field + +(def ^:private scalar-arrow-type + "Ladybug scalar → Arrow type. `UUID` and `JSON` ride as UTF-8: Ladybug + accepts a string into either column and does the conversion itself, which is + cheaper than teaching this side two more binary layouts." + {"STRING" #(ArrowType$Utf8.) + "UUID" #(ArrowType$Utf8.) + "JSON" #(ArrowType$Utf8.) + "INT64" #(ArrowType$Int. 64 true) + "UINT32" #(ArrowType$Int. 32 false) + "DOUBLE" #(ArrowType$FloatingPoint. FloatingPointPrecision/DOUBLE) + "BOOLEAN" #(ArrowType$Bool.) + "TIMESTAMP" #(ArrowType$Timestamp. TimeUnit/MICROSECOND nil)}) + +(defn column-field + "Arrow `Field` for a column of `ladybug-type`, recursively. + + `nullable?` is false only where Arrow's own invariants demand it — a MAP's + `entries` struct and its key." + (^Field [^String field-name ladybug-type] + (column-field field-name ladybug-type true)) + (^Field [^String field-name ladybug-type nullable?] + (cond + ;; A list first: `STRUCT(…)[]` starts with `STRUCT(` but is a list of them. + (ladybug/list-type? ladybug-type) + (Field. field-name (FieldType. nullable? (ArrowType$List.) nil) + [(column-field "item" (values/list-element ladybug-type))]) + + (ladybug/map-type? ladybug-type) + (let [[key-type value-type] (values/map-types ladybug-type)] + (Field. field-name (FieldType. nullable? (ArrowType$Map. false) nil) + [(Field. "entries" (FieldType. false (ArrowType$Struct.) nil) + [(column-field "key" key-type false) + (column-field "value" value-type)])])) + + (ladybug/struct-type? ladybug-type) + (Field. field-name (FieldType. nullable? (ArrowType$Struct.) nil) + ;; Backticks kept: Ladybug quotes none of these when it names the + ;; staged struct's fields, so `column` has to arrive quoted. + (mapv (fn [[field field-type]] (column-field field field-type)) + (values/struct-fields-quoted ladybug-type))) + + :else + (if-let [mk (get scalar-arrow-type ladybug-type)] + (Field. field-name (FieldType. nullable? (mk) nil) nil) + (throw (ex-info (str "no Arrow mapping for Ladybug type: " ladybug-type) + {:ladybug-type ladybug-type})))))) + +;; ------------------------------------------------------------------- writer + +(defn- utf8 + ^bytes [v] + (.getBytes (if (keyword? v) (name v) (str v)) StandardCharsets/UTF_8)) + +(defn- epoch-micros + ^long [v] + (let [^java.time.Instant inst + (cond + (instance? java.time.Instant v) v + (instance? java.util.Date v) (.toInstant ^java.util.Date v) + :else (java.time.Instant/parse (str v)))] + (+ (* (.getEpochSecond inst) 1000000) (long (quot (.getNano inst) 1000))))) + +(defn- write-scalar! + [^FieldVector fv ladybug-type ^long idx v] + (case ladybug-type + ("STRING" "UUID") (.setSafe ^VarCharVector fv idx (utf8 v)) + ;; A JSON column holds JSON, not a Clojure value's print form: `str` on a + ;; map yields `{:fill-color "#000000"}`, which is EDN and which every + ;; consumer of `fills`, `content` or `position_data` would fail to parse. + ;; Same encoder the Cypher path uses (`app.graph.ladybug/format-json`). + "JSON" (.setSafe ^VarCharVector fv idx + (.getBytes ^String (json/encode v) + StandardCharsets/UTF_8)) + "INT64" (.setSafe ^BigIntVector fv idx (long v)) + "UINT32" (.setSafe ^UInt4Vector fv idx (unchecked-int (long v))) + "DOUBLE" (.setSafe ^Float8Vector fv idx (double v)) + "BOOLEAN" (.setSafe ^BitVector fv idx (if v 1 0)) + "TIMESTAMP" (.setSafe ^TimeStampMicroVector fv idx (epoch-micros v)) + (throw (ex-info (str "no Arrow writer for Ladybug type: " ladybug-type) + {:ladybug-type ladybug-type})))) + +(defn write-value! + "Write already-coerced `v` into `fv` at `idx`, per `ladybug-type`. + + `map-key-fn` renders the keys of a `MAP(STRING, …)`, for the same reason + `app.graph.ladybug/format-typed-value` takes one: the right spelling is a + property of the column, not of the writer." + ;; `idx` is deliberately unhinted: Clojure only accepts primitive args on fns + ;; of four or fewer, and the map-key renderer has to travel with the value. + [^FieldVector fv ladybug-type idx v map-key-fn] + (if (nil? v) + (.setNull fv (int idx)) + (cond + (ladybug/list-type? ladybug-type) + (let [^ListVector lv fv + child (.getDataVector lv) + element-type (values/list-element ladybug-type) + elements (vec (if (or (sequential? v) (set? v)) v [v])) + start (.startNewValue lv (int idx))] + (dotimes [i (count elements)] + (write-value! child element-type (+ start i) (nth elements i) map-key-fn)) + (.endValue lv (int idx) (count elements))) + + (ladybug/map-type? ladybug-type) + (let [^MapVector mv fv + ^StructVector entries (.getDataVector mv) + [key-type value-type] (values/map-types ladybug-type) + key-vec (.getChild entries "key") + value-vec (.getChild entries "value") + render-key (if (and map-key-fn (= "STRING" key-type)) map-key-fn identity) + pairs (vec (seq v)) + start (.startNewValue mv (int idx))] + (dotimes [i (count pairs)] + (let [[k mv'] (nth pairs i) + at (+ start i)] + ;; The entries struct is non-nullable: every slot must be defined. + (.setIndexDefined entries (int at)) + (write-value! key-vec key-type at (render-key k) nil) + (write-value! value-vec value-type at mv' map-key-fn))) + (.endValue mv (int idx) (count pairs))) + + (ladybug/struct-type? ladybug-type) + (let [^StructVector sv fv] + (.setIndexDefined sv (int idx)) + (doseq [[quoted-field field-type] (values/struct-fields-quoted ladybug-type)] + ;; The child is named with its backticks; the coerced value is keyed + ;; without them. + (write-value! (.getChild sv quoted-field) field-type idx + (get v (str/replace quoted-field "`" "")) map-key-fn))) + + :else + (write-scalar! fv ladybug-type (long idx) v)))) + +;; ------------------------------------------------------------------ batches + +(defn- fill-vector! + [^VectorSchemaRoot root ^String field-name ladybug-type rows value-fn map-key-fn] + (let [^FieldVector fv (.getVector root field-name)] + (.allocateNew fv) + (dotimes [i (count rows)] + (write-value! fv ladybug-type i + (values/coerce ladybug-type (value-fn (nth rows i))) + map-key-fn)) + (.setValueCount fv (count rows)))) + +(defn- node-batch + "One `VectorSchemaRoot` holding every projected row of `table`. + + Fields carry the plain column name. Ladybug quotes every identifier it + interpolates into the staged table's DDL, so a name that is a reserved word + (`Page.index`, `Document.options`) arrives unquoted and a name arriving + pre-quoted comes out doubly backticked and fails to parse. The `COPY` + projection below is Cypher, not DDL, so it quotes the same names itself." + ^VectorSchemaRoot [^BufferAllocator allocator table rows] + (let [columns (nodes/column-keys table) + fields (mapv (fn [k] (column-field (nodes/column-name table k) + (nodes/column-ladybug-type table k))) + columns) + root (VectorSchemaRoot/create (Schema. ^List fields) allocator)] + (doseq [k columns] + (fill-vector! root (nodes/column-name table k) + (nodes/column-ladybug-type table k) + rows #(get % k) (nodes/column-map-key-fn table k))) + (.setRowCount root (count rows)) + root)) + +(def ^:private edge-fields + "Edge staging columns. `id` is the staging table's own key — Ladybug wants a + first column to key the virtual table on — and `from`/`to` land as STRING, + hence the cast in the join." + [(Field. "id" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "from" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "to" (FieldType. true (ArrowType$Utf8.) nil) nil) + (Field. "position" (FieldType. true (ArrowType$Int. 64 true) nil) nil)]) + +(defn- edge-batch + ^VectorSchemaRoot [^BufferAllocator allocator edges] + (let [root (VectorSchemaRoot/create (Schema. ^List edge-fields) allocator) + ^VarCharVector iv (.getVector root "id") + ^VarCharVector fv (.getVector root "from") + ^VarCharVector tv (.getVector root "to") + ^BigIntVector pv (.getVector root "position") + n (count edges)] + (doseq [^FieldVector v [iv fv tv pv]] (.allocateNew v)) + (dotimes [i n] + (let [{:keys [from-id to-id position]} (nth edges i)] + (.setSafe iv i (utf8 i)) + (.setSafe fv i (utf8 from-id)) + (.setSafe tv i (utf8 to-id)) + (if (nil? position) (.setNull pv i) (.setSafe pv i (long position))))) + (doseq [^FieldVector v [iv fv tv pv]] (.setValueCount v n)) + (.setRowCount root n) + root)) + +;; ------------------------------------------------------------------ staging + +(defn- batches + ^List [^VectorSchemaRoot root] + (doto (ArrayList.) (.add root))) + +(defn- check! + [^QueryResult result hint data] + (when-not (.isSuccess result) + (throw (ex-info (str hint ": " (.getErrorMessage result)) + (assoc data :err (.getErrorMessage result)))))) + +(defn- with-staged-table! + "Create Arrow table `staging-name` from `root`, run `(f)`, always drop it." + [^Connection conn ^BufferAllocator allocator ^String staging-name + ^VectorSchemaRoot root data f] + (try + (with-open [^QueryResult r (.createArrowTable conn staging-name (batches root) allocator)] + (check! r "createArrowTable failed" data)) + (f) + (finally + ;; Dropped even on failure: the staged buffers stay referenced by Ladybug + ;; until it is, and the allocator's leak check fires on close otherwise. + (try (.close ^QueryResult (.dropArrowTable conn staging-name)) + (catch Throwable _ nil))))) + +(defn- copy-node-table! + [^Connection conn table ^String staging-name] + (let [projection (str/join ", " (for [k (nodes/column-keys table) + :let [c (nodes/cypher-property-key table k)]] + (str "n." c " AS " c))) + statement (str "COPY `" table "` FROM (MATCH (n:" staging-name ") " + "RETURN " projection ");")] + (with-open [^QueryResult r (.query conn statement)] + (check! r (str "COPY node table failed: " table) + {:table table :statement statement})))) + +(defn- copy-edge-group! + "Load one FROM/TO pair of `IsChildOf`. + + `createArrowRelTable` is unusable here — it cannot resolve endpoints against a + UUID-keyed node table — so the edge list is staged as a node table and the + endpoints are resolved by the subquery. The `WHERE` is clause-level because + this dialect prohibits an inline pattern `WHERE`, and both sides are pinned by + label so the join cannot reach outside the pair." + [^Connection conn from-table to-table ^String staging-name] + (let [statement (str "COPY `IsChildOf` FROM (" + "MATCH (e:" staging-name "), " + "(a:" (nodes/match-label from-table) "), " + "(b:" (nodes/match-label to-table) ") " + "WHERE a.id = cast(e.from AS UUID) " + "AND b.id = cast(e.to AS UUID) " + "RETURN a.id, b.id, e.position) " + "(from='" from-table "', to='" to-table "');")] + (with-open [^QueryResult r (.query conn statement)] + (check! r (str "COPY edge group failed: " from-table " -> " to-table) + {:from-table from-table :to-table to-table :statement statement})))) + +(defn- staging-name + [prefix & parts] + (str/replace (str/join "_" (cons (str "stg_" prefix) parts)) #"[^A-Za-z0-9_]" "_")) + +;; --------------------------------------------------------------------- load + +(defn load-projection! + "Load projected nodes and edges into an open Ladybug connection. + + `allocator` must outlive `conn` — see `with-allocator!`." + [^Connection conn {:keys [nodes edges]} ^BufferAllocator allocator] + (doseq [[table rows] (sort-by key nodes) + :when (seq rows)] + (let [name (staging-name "node" table)] + (with-open [root (node-batch allocator table rows)] + (with-staged-table! conn allocator name root {:table table} + #(copy-node-table! conn table name))))) + (doseq [[[from-table to-table] group] + (sort-by key (group-by (juxt :from-table :to-table) edges)) + :when (seq group)] + (let [name (staging-name "edge" from-table to-table)] + (with-open [root (edge-batch allocator group)] + (with-staged-table! conn allocator name root + {:from-table from-table :to-table to-table} + #(copy-edge-group! conn from-table to-table name)))))) diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj new file mode 100644 index 0000000000..277b1ae177 --- /dev/null +++ b/backend/src/app/graph/debug.clj @@ -0,0 +1,383 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.debug + "In-memory Ladybug sessions for the debug graph console." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.time :as ct] + [app.graph.ingest :as graph.ingest] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.sync :as graph.sync] + [app.msgbus :as mbus] + [clojure.java.io :as io] + [clojure.string :as str] + [promesa.exec.csp :as sp]) + (:import + com.ladybugdb.Connection + com.ladybugdb.Database)) + +(set! *warn-on-reflection* true) + +(def default-query + "Default console query, written to be self-explanatory in the textarea. + The `filter_*` columns carry node ids for the graph-view result filter; + the results table hides them (see `hide-filter-columns` and the + template's `renderQueryOutput`)." + (str "MATCH (s)-[r]->(t)\n" + "// WHERE some condition\n" + "RETURN label(s) AS src, s.name,\n" + " label(r) AS rel,\n" + " t.name, label(t) AS tgt,\n" + "\n" + "// filter_* columns omitted from table; these needed for graph view\n" + "s.id AS filter_src_id, t.id AS filter_tgt_id;")) + +(defonce ^:private sessions + (atom {})) + +(defn- session-key + [profile-id] + (str profile-id)) + +(defn- destroy-session! + [{:keys [conn db sync-ch msgbus]}] + (when sync-ch + (sp/close! sync-ch) + (when msgbus + (mbus/purge! msgbus [sync-ch]))) + (when conn + (ex/ignoring (.close ^Connection conn))) + (when db + (ex/ignoring (.close ^Database db)))) + +(defn- slim-ingest-meta + "Drop full projection rows from session meta. + + `build-index` needs `:nodes`/`:edges` once; keeping them in the session + duplicates the entire graph on the JVM heap for every Load." + [meta] + (update meta :projection #(select-keys % [:stats]))) + +(defn- format-cell + [value] + (cond + (nil? value) "NULL" + (string? value) value + :else (str value))) + +(defn- format-query-result + [{:keys [columns rows truncated?]}] + {:columns (mapv str columns) + :rows (mapv (fn [row] + (mapv format-cell row)) + rows) + :truncated? truncated? + :row-count (count rows)}) + +(defn- apply-file-change! + [conn profile-id {:keys [changes revn file-id]}] + (try + (some-> (get @sessions (session-key profile-id)) + (as-> current + (when (= file-id (:file-id current)) + (let [lock (:lock current) + result (locking lock + (graph.sync/apply-changes! + conn (:index current) changes revn)) + sync-at (ct/now)] + (swap! sessions assoc-in [(session-key profile-id) :index] + (:index result)) + (swap! sessions update-in [(session-key profile-id) :meta] + (fn [meta] + (cond-> (-> meta + (update :sync dissoc :error) + (assoc-in [:sync :last-at] sync-at) + (assoc-in [:sync :last-applied] (:applied result)) + (assoc-in [:sync :last-skipped] (:skipped result))) + (seq (:applied result)) + (assoc :revn (:revn result))))) + (when (seq (:skipped result)) + (l/dbg :hint "graph sync skipped changes" + :file-id (str file-id) + :revn revn + :skipped (:skipped result))))))) + (catch Throwable cause + (l/wrn :hint "graph sync failed" + :file-id (str file-id) + :cause cause) + (swap! sessions assoc-in [(session-key profile-id) :meta :sync :error] + (ex-message cause))))) + +(defn- start-sync-loop! + [{:keys [conn profile-id file-id] :as session}] + (if-let [msgbus (:msgbus session)] + (let [sync-ch (sp/chan :buf (sp/dropping-buffer 64))] + (mbus/sub! msgbus :topic file-id :chan sync-ch) + ;; Recur ONLY while the channel is open. A bare `(recur)` after + ;; `take!` returns nil would spin forever and pin this Connection + ;; (and its Ladybug Database native memory) across every Load. + (sp/go-loop [] + (when-let [message (sp/take! sync-ch)] + (when (= :file-change (:type message)) + (apply-file-change! conn profile-id message)) + (recur))) + (assoc session :sync-ch sync-ch)) + session)) + +(defn session-info + "Return a public view of the current session for `profile-id`, if any." + [profile-id] + (when-let [{:keys [file-id meta loaded-at index]} (get @sessions (session-key profile-id))] + {:file-id file-id + :name (:name meta) + :revn (:revn meta) + :graph-revn (:revn index) + :schema-version (:schema-version meta) + :projection (:projection meta) + :sync (:sync meta) + :loaded-at (ct/format-inst loaded-at :iso)})) + +(defn sync-status + "Return incremental sync status for the active session." + [profile-id] + (when-let [session (get @sessions (session-key profile-id))] + (let [{:keys [file-id meta index loaded-at]} session] + {:file-id file-id + :revn (:revn meta) + :graph-revn (:revn index) + :sync (:sync meta) + :loaded-at (ct/format-inst loaded-at :iso)}))) + +(defn unload-session! + "Close and discard the in-memory graph for `profile-id`." + [profile-id] + (when-let [session (get @sessions (session-key profile-id))] + (destroy-session! session)) + (swap! sessions dissoc (session-key profile-id))) + +(defn load-session! + "Ingest `file-id` into a new in-memory Ladybug database for `profile-id`." + [cfg profile-id file-id] + (unload-session! profile-id) + (let [^Database db (Database.) + ^Connection conn (Connection. db) + msgbus (::mbus/msgbus cfg)] + (.setQueryTimeout conn 0) + (ladybug/ensure-extensions! conn) + (try + (let [meta (graph.ingest/ingest-on-connection! cfg conn file-id + :db-path ":memory:" + :skip-stats? true + :skip-validation? true) + index (graph.sync/build-index file-id (:revn meta) (:projection meta)) + ;; Discard projection rows after indexing — they are only needed + ;; to seed the sync index and would otherwise leak heap on each Load. + meta (slim-ingest-meta meta) + session + ;; :lock serializes access to the shared Connection between the + ;; msgbus sync loop (writes) and HTTP handlers (reads); the Java + ;; binding gives no thread-safety guarantee for one Connection. + (-> {:db db + :conn conn + :lock (Object.) + :file-id file-id + :meta meta + :index index + :msgbus msgbus + :profile-id profile-id + :loaded-at (ct/now)} + start-sync-loop!)] + (swap! sessions assoc (session-key profile-id) session) + meta) + (catch Throwable cause + (destroy-session! {:conn conn :db db :msgbus msgbus}) + (throw cause))))) + +(defn query-session! + "Run a read-only `statement` against the in-memory graph for `profile-id`. + + The statement is bound against the live schema before it runs, so a query + naming a table or a property that does not exist reports the binder's own + message and executes nothing. The engine's read/write analysis then decides + whether it may run at all: the console is an inspection surface, and a + session graph is rebuilt from the file by Reload, so a mutation from here + would produce a graph no rebuild reproduces." + [profile-id statement] + (when (str/blank? statement) + (ex/raise :type :validation + :code :missing-query + :hint "cypher query is required")) + (if-let [{:keys [conn lock]} (get @sessions (session-key profile-id))] + (locking lock + (let [{:keys [ok? error read-only?]} (ladybug/validate-on-connection! conn statement)] + (when-not ok? + (ex/raise :type :validation + :code :graph-query-invalid + :hint error)) + (when-not read-only? + (ex/raise :type :validation + :code :graph-query-not-read-only + :hint "the graph console runs read-only queries")) + (-> (ladybug/query-on-connection! conn statement) + format-query-result))) + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "load a file graph before running queries"))) + +(def ^:private export-max-rows + "Row cap for graph-view export queries; far above expected per-file node + and edge counts. `:truncated` in the export signals when it was hit." + 100000) + +(defn- export-nodes + [conn] + (reduce + (fn [acc {:keys [table]}] + (let [stmt (str "MATCH (n:" (nodes/match-label table) + ") RETURN n.id AS id, n.name AS name;") + {:keys [rows truncated?]} + (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)] + (-> acc + (update :nodes into + (map (fn [[id label]] + {:id (str id) :label (str label) :table table})) + rows) + (update :truncated? #(or % truncated?))))) + {:nodes [] :truncated? false} + nodes/node-types)) + +(defn rel-tables + "Every relationship table in the open database, with whether it carries a + `position` property. + + Read from the catalog rather than listed here, so a newly ported transform's + rel table appears in the graph view without the console being told about it." + [conn] + (for [[table] (:rows (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" + :max-rows 1000)) + :let [props (->> (ladybug/query-on-connection! + conn (str "CALL table_info('" table "') RETURN *;") + :max-rows 1000) + :rows + (into #{} (map (comp str second))))]] + {:table table :position? (contains? props "position")})) + +(defn- export-edges + [conn] + (reduce + (fn [acc {:keys [table position?]}] + (let [stmt (str "MATCH (a)-[r:`" table "`]->(b) " + "RETURN a.id AS source, b.id AS target, " + (if position? "r.position" "NULL") " AS position, " + "'" table "' AS rel;") + {:keys [rows truncated?]} + (ladybug/query-on-connection! conn stmt :max-rows export-max-rows)] + (-> acc + (update :edges into + (map (fn [[source target position rel]] + (cond-> {:source (str source) + :target (str target) + :rel (str rel)} + (some? position) (assoc :position position)))) + rows) + (update :truncated? #(or % truncated?))))) + {:edges [] :truncated? false} + (rel-tables conn))) + +(defn- bm-usage-bytes + "Buffer-manager memory in use by this session's in-memory database + (`CALL bm_info()` → [mem_limit mem_usage]); nil if the call fails." + [conn] + (ex/ignoring + (-> (ladybug/query-on-connection! conn "CALL bm_info() RETURN *;" :max-rows 1) + :rows first second))) + +(defn export-graph-data! + "Export the node/edge inventory of the in-memory graph for `profile-id` + as plain data for the debug graph view. Returns nil when no session is + loaded. Queries the Ladybug database (not the sync index) so the view + reflects actual DB state, including drift." + [profile-id] + (when-let [{:keys [conn lock file-id index]} (get @sessions (session-key profile-id))] + (locking lock + (let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn) + {:keys [edges] edges-truncated? :truncated?} (export-edges conn)] + {:file-id (str file-id) + :revn (:revn index) + :truncated (boolean (or nodes-truncated? edges-truncated?)) + :bm-bytes (bm-usage-bytes conn) + :nodes nodes + :edges edges})))) + +(defn- delete-tree! + [^java.io.File file] + (when (.exists file) + (doseq [f (reverse (file-seq file))] + (.delete ^java.io.File f)))) + +(defn export-session-database! + "Materialize the in-memory session graph of `profile-id` as a `.lbug` file. + + The console's graph is in-memory and live-synced, so it can differ from a + fresh projection of the same file — which is exactly when someone wants to + take it away and query it elsewhere. There is no \"save this database\" + primitive, so the transfer goes through Ladybug's `EXPORT DATABASE` (Parquet + per table) into a fresh on-disk database via `IMPORT DATABASE`. + + Note the round trip drops table comments. Nothing in the graph is addressed + by a table comment: every table is resolved by name, so the loss costs + nothing. + + Returns the path of the written database, or nil when no session is loaded. + The caller owns the file and must delete it once streamed." + [profile-id] + (when-let [{:keys [conn lock file-id]} (get @sessions (session-key profile-id))] + (let [stamp (System/nanoTime) + staging (io/file (System/getProperty "java.io.tmpdir") + (str "penpot-graph-session-" file-id "-" stamp)) + db-path (str (io/file (System/getProperty "java.io.tmpdir") + (str file-id "-session-" stamp ".lbug")))] + (try + (locking lock + (ladybug/exec-on-connection! + conn [(str "EXPORT DATABASE '" (.getAbsolutePath staging) + "' (format='parquet');")])) + (ladybug/with-connection! db-path + (fn [target] + (ladybug/exec-on-connection! + target [(str "IMPORT DATABASE '" (.getAbsolutePath staging) "';") + "CHECKPOINT;"]))) + db-path + (finally + (delete-tree! staging)))))) + +(defn- hide-filter-columns + "Drop `filter_*` columns from a query result before HTML table render; + they exist to feed node ids to the graph-view filter, not for reading. + The JSON response path keeps the full result." + [{:keys [columns rows] :as result}] + (let [idxs (vec (keep-indexed + (fn [i c] (when-not (str/starts-with? (str c) "filter_") i)) + columns))] + (if (or (empty? idxs) (= (count idxs) (count columns))) + result + (assoc result + :columns (mapv (vec columns) idxs) + :rows (mapv (fn [row] (mapv (vec row) idxs)) rows))))) + +(defn console-context + "Build template data for the graph debug console page." + [profile-id & {:keys [query query-result error message]}] + {:session (session-info profile-id) + :query (or query default-query) + :query-result (some-> query-result hide-filter-columns) + :error error + :message message + :default-query default-query}) diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj new file mode 100644 index 0000000000..af0644ee4d --- /dev/null +++ b/backend/src/app/graph/ingest.clj @@ -0,0 +1,106 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.ingest + "Penpot file -> Ladybug graph projection." + (:require + [app.binfile.common :as bfc] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.types.file :as ctf] + [app.db :as db] + [app.graph.arrow :as graph.arrow] + [app.graph.ladybug :as ladybug] + [app.graph.meta :as graph.meta] + [app.graph.projection.document :as projection.document] + [app.graph.projection.transforms :as projection.transforms] + [app.graph.schema :as schema] + [app.graph.stats :as stats] + [app.srepl.helpers :as h]) + (:import + com.ladybugdb.Connection + org.apache.arrow.memory.BufferAllocator)) + +(defn- fetch-file! + [system file-id] + (let [file-id (h/parse-uuid file-id) + file (db/run! system #(bfc/get-file % file-id :realize? true))] + (when-not file + (ex/raise :type :not-found + :code :file-not-found + :file-id (str file-id))) + (when-not (:data file) + (ex/raise :type :validation + :code :file-without-data + :hint "file has no data to project" + :file-id (str file-id))) + [file-id file])) + +(defn- ingest-on-connection*! + [system ^Connection conn file-id ^BufferAllocator allocator + {:keys [db-path skip-stats? skip-validation?] :or {skip-stats? true}}] + (let [[file-id file] (fetch-file! system file-id) + db-path (or db-path (ladybug/db-path-for-file file-id)) + data (:data file)] + (when-not skip-validation? + (ctf/check-file-data data)) + (l/inf :hint "graph ingest" + :file-id (str file-id) + :revn (:revn file) + :db-path db-path + :schema schema/schema-version) + (let [ddl (schema/ddl-statements) + {:keys [nodes edges stats]} + (projection.document/projection-data data file)] + (ladybug/exec-on-connection! conn ddl) + (graph.arrow/load-projection! conn {:nodes nodes :edges edges} allocator) + (ladybug/exec-on-connection! conn ["CHECKPOINT;"]) + (let [transforms (projection.transforms/apply-transforms! system conn data file)] + ;; Written last: its presence doubles as the build-complete marker. + (graph.meta/write! conn {:file-id file-id + :revn (:revn file)}) + {:file-id file-id + :revn (:revn file) + :name (or (:name data) (:name file)) + :db-path db-path + :schema-version schema/schema-version + :projection {:stats stats + :nodes nodes + :edges edges} + :transforms transforms + :stats (when-not skip-stats? + (stats/summarize-connection conn))})))) + +(defn ingest-on-connection! + "Project `file-id` into an already open Ladybug `conn`. + + Takes an `:arrow-alloc` when the caller already owns one; otherwise it makes + a short-lived allocator around this call. A caller that opened the connection + itself should pass its own, because the allocator has to be closed *after* + the connection — see `app.graph.arrow/with-allocator!`." + [system ^Connection conn file-id & {:keys [arrow-alloc] :as opts}] + (if arrow-alloc + (ingest-on-connection*! system conn file-id arrow-alloc opts) + (graph.arrow/with-allocator! + (fn [allocator] (ingest-on-connection*! system conn file-id allocator opts))))) + +(defn ingest-file! + [system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?] + :or {reset-db? true}}] + (let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))] + (when reset-db? + (ladybug/reset-db-path! db-path)) + ;; Allocator outermost: Ladybug holds the staged Arrow buffers until its + ;; tables are dropped, which is no later than connection close, so the + ;; allocator must be closed after the connection and the database. + (graph.arrow/with-allocator! + (fn [allocator] + (ladybug/with-connection! db-path + (fn [conn] + (ingest-on-connection*! system conn file-id allocator + {:db-path db-path + :skip-stats? skip-stats? + :skip-validation? skip-validation?}))))))) diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj new file mode 100644 index 0000000000..81d117c26e --- /dev/null +++ b/backend/src/app/graph/ladybug.clj @@ -0,0 +1,504 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.ladybug + "Ladybug access layer for graph-backed Penpot. + + Uses the embedded Java API (`com.ladybugdb/lbug`)." + (:require + [app.common.exceptions :as ex] + [app.common.json :as json] + [app.graph.schema.values :as values] + [clojure.string :as str] + [datoteka.fs :as fs]) + (:import + com.ladybugdb.Connection + com.ladybugdb.Database + com.ladybugdb.FlatTuple + com.ladybugdb.PreparedStatement + com.ladybugdb.QueryResult + com.ladybugdb.Value)) + +(set! *warn-on-reflection* true) + +(defn default-graph-dir + [] + (or (System/getenv "PENPOT_GRAPH_DIR") "/tmp/penpot-graph")) + +(defn db-path-for-file + [file-id] + (str (fs/path (default-graph-dir) (str file-id ".lbug")))) + +(defn- memory-db-path? + [db-path] + (= db-path ":memory:")) + +(defn reset-db-path! + [db-path] + (when-not (memory-db-path? db-path) + (when (fs/exists? db-path) + (fs/delete db-path)))) + +(defn escape-cypher-string + [s] + (-> (str s) + (str/replace "\\" "\\\\") + (str/replace "'" "\\'"))) + +(defn format-uuid + [id] + (str "uuid('" (str id) "')")) + +(defn format-string + [s] + (str "'" (escape-cypher-string s) "'")) + +(defn format-int + [n] + (str (long n))) + +(defn format-number + [n] + (if (== n (long n)) + (format-int n) + (str (double n)))) + +(defn format-json + [v] + (str "json('" (escape-cypher-string (json/encode v)) "')")) + +(defn format-timestamp + "Ladybug TIMESTAMP literal of the form `timestamp('')`." + [v] + (let [s (cond + (instance? java.time.Instant v) + (.toString ^java.time.Instant v) + + (instance? java.util.Date v) + (.toString (.toInstant ^java.util.Date v)) + + (string? v) + v + + :else + (str v))] + (str "timestamp('" (escape-cypher-string s) "')"))) + +(defn format-value + [v] + (cond + (nil? v) "NULL" + (uuid? v) (format-uuid v) + (instance? java.time.Instant v) (format-timestamp v) + (instance? java.util.Date v) (format-timestamp v) + (string? v) (format-string v) + (number? v) (format-number v) + (boolean? v) (if v "true" "false") + (keyword? v) (format-string (name v)) + (map? v) (format-json v) + (coll? v) (format-json v) + :else (format-string (str v)))) + +(defn map-type? + "Is `ladybug-type` a MAP column?" + [ladybug-type] + (and (string? ladybug-type) + (str/starts-with? ladybug-type "MAP(") + (not (str/ends-with? ladybug-type "]")))) + +(defn list-type? + "Is this a list or fixed-size array type? Checked before MAP and STRUCT, + since `STRUCT(…)[]` starts with `STRUCT(` but is a list of them." + [ladybug-type] + (and (string? ladybug-type) + (some? (re-matches #".+\[\d*\]$" ladybug-type)))) + +(defn struct-type? + [ladybug-type] + (and (string? ladybug-type) + (str/starts-with? ladybug-type "STRUCT(") + (not (list-type? ladybug-type)))) + +(declare format-typed-value) + +(defn- format-typed-list + "Cypher LIST literal, elements formatted by the element type. + + Handles `T[]` and the fixed-size `T[n]` alike: the size constrains the column, + not the literal." + [ladybug-type v] + (let [element (second (re-matches #"(.+?)\[\d*\]$" ladybug-type)) + elems (if (or (sequential? v) (set? v)) (seq v) [v])] + (str "[" (str/join ", " (map #(format-typed-value element %) elems)) "]"))) + +(defn- format-struct + "Cypher STRUCT literal, `{field: value, …}`. + + *Every* declared field is emitted, NULL where the value has none: a struct + literal's type is its field list, so omitting a field yields a different type + and Ladybug refuses the implicit cast (`STRUCT(m2 DOUBLE, m4 DOUBLE)` cannot + be assigned to `STRUCT(m1 …, m2 …, m3 …, m4 …)`). Penpot's layout margins are + exactly that case — a shape sets only the sides it overrides." + [ladybug-type v] + (let [fields (values/struct-fields ladybug-type)] + (str "{" + (str/join ", " + (for [[field field-type] fields + :let [fv (get v field)]] + ;; Backticked for the same reason as in the DDL: a field + ;; named `column` is a keyword and will not parse bare. + ;; A bare NULL is typed STRING, which changes the struct's + ;; type as surely as omitting the field would, so absent + ;; fields get a NULL cast to their declared type. + (str "`" field "`: " + (if (nil? fv) + (str "cast(NULL, '" field-type "')") + (format-typed-value field-type fv))))) + "}"))) + +(defn format-typed-value + "Cypher literal for `v` in a column of `ladybug-type`. + + Recursive over the type language, because the types are: a + `MAP(UUID, STRUCT(…))` needs its keys, its fields and each field's own type + honoured. `app.graph.schema.values/coerce` shapes the value first — turning a + matrix record into six doubles, a hex colour into a packed integer — so this + function only has to escape plain data. + + `map-key-fn` renders the keys of a `MAP(STRING, …)`; the caller supplies it + because the right form is a property of the column, not of this function + (`app.graph.schema.contract/map-key-fn`)." + ([ladybug-type v] (format-typed-value ladybug-type v nil)) + ([ladybug-type v map-key-fn] + (let [v (values/coerce ladybug-type v)] + (cond + (nil? v) + "NULL" + + (list-type? ladybug-type) + (format-typed-list ladybug-type v) + + (map-type? ladybug-type) + (let [[key-type value-type] (values/map-types ladybug-type) + entries (seq v) + format-key (if (and map-key-fn (= "STRING" key-type)) + #(format-string (map-key-fn (key %))) + #(format-typed-value key-type (key %)))] + (str "map([" (str/join ", " (map format-key entries)) + "], [" + (str/join ", " (map #(format-typed-value value-type (val %)) entries)) + "])")) + + (struct-type? ladybug-type) + (format-struct ladybug-type v) + + (= ladybug-type "JSON") + (format-json v) + + ;; Coerce string ids from transit edge-cases into UUID literals. + (= ladybug-type "UUID") + (format-uuid v) + + (= ladybug-type "TIMESTAMP") + (format-timestamp v) + + :else + (format-value v))))) + +(defn- ensure-semicolon + [statement] + (let [s (str/trim (str statement))] + (if (str/ends-with? s ";") s (str s ";")))) + +(defn- value->clj + [^Value value] + (when-not (.isNull value) + (let [v (try + (.getValue value) + (catch Exception _ + ;; LIST/STRUCT values are not supported by the binding's + ;; getValue (\"value_get_value\"); fall back to the textual + ;; representation so console queries do not crash. + (.toString value)))] + (cond + (instance? Long v) v + (instance? Integer v) (long v) + (instance? Double v) v + :else v)))) + +(defn- check-success! + [^QueryResult result statement] + (when-not (.isSuccess result) + (let [err (.getErrorMessage result)] + (ex/raise :type :internal + :code :ladybug-query-failed + :hint (str "Ladybug query failed: " err) + :statement statement + :err err)))) + +(defn- query-columns + [^QueryResult result] + (let [ncols (.getNumColumns result)] + (vec (for [i (range ncols)] + (.getColumnName result (long i)))))) + +(defn- query-row + [^FlatTuple tuple ncols] + (vec (for [i (range ncols)] + (with-open [^Value value (.getValue tuple (long i))] + (value->clj value))))) + +(def ^:private default-query-max-rows 200) + +(defn- read-query-rows + [^QueryResult result ncols max-rows] + (loop [rows [] n 0] + (if (and (< n max-rows) (.hasNext result)) + (let [row (with-open [^FlatTuple tuple (.getNext result)] + (query-row tuple ncols))] + (recur (conj rows row) (inc n))) + rows))) + +(defn query-on-connection! + "Execute a Cypher query on `conn` and return tabular results. + + Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`." + [^Connection conn statement & {:keys [max-rows] + :or {max-rows default-query-max-rows}}] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher) + (let [ncols (long (.getNumColumns result)) + columns (query-columns result) + rows (read-query-rows result ncols max-rows) + total (long (.getNumTuples result))] + {:columns columns + :rows rows + :truncated? (and (pos? total) (> total (count rows)))})))) + +(def ^:private default-query-timeout-ms + "0 disables query timeout (recommended for bulk COPY ingest)." + 0) + +(defn- scalar-value + [^Connection conn statement] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher) + (when (.hasNext result) + (with-open [^FlatTuple tuple (.getNext result)] + (with-open [^Value value (.getValue tuple 0)] + (value->clj value))))))) + +(defn- extension-statement-ok? + [err-msg] + (let [err (str/lower-case (or err-msg ""))] + (or (str/includes? err "already loaded") + (str/includes? err "already installed")))) + +(defn- run-extension-statement! + [^Connection conn statement] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (when-not (.isSuccess result) + (let [err (.getErrorMessage result)] + (when-not (extension-statement-ok? err) + (check-success! result cypher))))))) + +(defn ensure-extensions! + "Install and load Ladybug extensions required by graph ingest and sync." + [^Connection conn] + (run-extension-statement! conn "INSTALL json;") + (run-extension-statement! conn "LOAD json;")) + +(defn- run-statements! + [^Connection conn statements] + (doseq [statement statements] + (let [cypher (ensure-semicolon statement)] + (with-open [^QueryResult result (.query conn cypher)] + (check-success! result cypher))))) + +(defn- ensure-db-path! + [db-path] + (when-not (memory-db-path? db-path) + (fs/create-dir (fs/parent db-path)))) + +(defn with-connection! + "Open a Ladybug connection for `db-path` and invoke `(f conn)`. + + Options: + - `:query-timeout-ms` query timeout in milliseconds (default 0, disabled) + + For `:memory:`, the database only lives for the duration of this call; + all reads and writes must happen inside `f`." + [db-path f & {:keys [query-timeout-ms] + :or {query-timeout-ms default-query-timeout-ms}}] + (ensure-db-path! db-path) + (let [^Database db (if (memory-db-path? db-path) + (Database.) + (Database. (str db-path)))] + (try + (let [^Connection conn (Connection. db)] + (try + (.setQueryTimeout conn (long query-timeout-ms)) + (ensure-extensions! conn) + (f conn) + (finally + (.close conn)))) + (finally + (.close db))))) + +(defn exec-on-connection! + "Execute Cypher statements on an open Ladybug connection." + [^Connection conn statements] + (assert (sequential? statements) "statements should be a sequential collection") + (run-statements! conn statements)) + +;; --- prepared statements + +(defn- ->param-value + "Clojure scalar → `Value` for prepared-statement binding. + + This is the only `Value` constructor on the write path, so every parameter + is wrapped here. Parameters are scalars: the `Value` constructor takes no + list or map, so `MAP`, `STRUCT` and `T[]` columns stay literal-rendered + (`format-typed-value`) and the `:else` raise below means a caller tried to + bind one." + ^Value [v] + (cond + (nil? v) (Value/createNull) ; no explicit type needed + (uuid? v) (Value. ^Object v) ; native UUID + (string? v) (Value. ^Object v) + (boolean? v) (Value. ^Object v) + (integer? v) (Value. ^Object (long v)) + (number? v) (Value. ^Object (double v)) + (keyword? v) (Value. ^Object (name v)) + + (instance? java.time.Instant v) ; native TIMESTAMP + (Value. ^Object v) + + (instance? java.util.Date v) + (Value. ^Object (.toInstant ^java.util.Date v)) + + :else + (ex/raise :type :internal + :code :ladybug-unsupported-param + :hint (str "cannot bind a " (type v) " as a Ladybug parameter; " + "compound columns must be literal-rendered") + :value v))) + +(defn- as-statement + "Normalize a statement to `{:cypher … :params …}`. + + A bare string binds nothing, so the sync builders can convert to bound + parameters one family at a time." + [stmt] + (if (map? stmt) + (update stmt :params #(or % {})) + {:cypher stmt :params {}})) + +(defn prepare-on-connection! + "Parse and bind `statement` on `conn` without executing it. + + The returned `PreparedStatement` is a JNI resource: the caller closes it." + ^PreparedStatement [^Connection conn statement] + (let [cypher (ensure-semicolon statement) + ps (.prepare conn cypher)] + (when-not (.isSuccess ps) + (let [err (.getErrorMessage ps)] + (.close ps) + (ex/raise :type :internal + :code :ladybug-prepare-failed + :hint (str "Ladybug prepare failed: " err) + :statement cypher + :err err))) + ps)) + +(defn execute-prepared! + "Bind `params` into `ps` and execute it on `conn`. + + `params` keys are parameter names without the `$` (keyword or string); + values are scalars. Every bound `Value` is closed, including the ones built + before a later parameter is rejected." + [^Connection conn ^PreparedStatement ps params] + (let [vmap (java.util.HashMap.)] + (try + (doseq [[k v] params] + (.put vmap (name k) (->param-value v))) + (with-open [^QueryResult result (.execute conn ps vmap)] + (check-success! result "")) + (finally + (run! #(.close ^Value %) (.values vmap)))))) + +(defn exec-prepared-on-connection! + "Prepare all statements, then execute all of them. + + A parse or bind failure in *any* statement aborts the batch before the first + mutation runs — the bind-level batch gate. Statements are + `{:cypher … :params {…}}` maps or bare strings." + [^Connection conn stmts] + (assert (sequential? stmts) "statements should be a sequential collection") + (let [prepared (volatile! [])] + (try + (doseq [stmt stmts] + (let [{:keys [cypher params]} (as-statement stmt)] + (vswap! prepared conj {:ps (prepare-on-connection! conn cypher) + :params params}))) + (doseq [{:keys [ps params]} @prepared] + (execute-prepared! conn ps params)) + (finally + (run! #(.close ^PreparedStatement (:ps %)) @prepared))))) + +(defn validate-on-connection! + "Binder gate: parse and semantic-check `statement` against the live schema, + without executing it. + + Returns `{:ok? … :error … :read-only? …}`. Unlike `prepare-on-connection!` + a failure is a return value rather than a raise: the callers are gates (the + CI binder gate, the console read-only gate) that report it. `:read-only?` is + the engine's own read/write analysis." + [^Connection conn statement] + (with-open [^PreparedStatement ps (.prepare conn (ensure-semicolon statement))] + (let [ok? (.isSuccess ps)] + {:ok? ok? + :error (when-not ok? (.getErrorMessage ps)) + :read-only? (when ok? (.isReadOnly ps))}))) + +(defn query-scalar-on-connection! + "Execute a query expected to return a single scalar value on `conn`." + [^Connection conn statement] + (scalar-value conn statement)) + +(defn exec! + "Execute Cypher statements against a Ladybug database. + + `db-path` is either `:memory:` or a filesystem path to a `.lbug` database." + [db-path statements] + (with-connection! db-path + (fn [conn] + (exec-on-connection! conn statements)))) + +(defn query-scalar! + "Execute a query expected to return a single scalar value." + [db-path statement] + (with-connection! db-path + (fn [conn] + (query-scalar-on-connection! conn statement)))) + +(defn smoke-test! + "Run a minimal CREATE + count against Ladybug." + [& {:keys [db-path] :or {db-path ":memory:"}}] + (when-not (memory-db-path? db-path) + (reset-db-path! db-path)) + (with-connection! db-path + (fn [^Connection conn] + (run-statements! conn + ["CREATE NODE TABLE Person(name STRING, age INT64, PRIMARY KEY(name));" + "CREATE (:Person {name: 'Alice', age: 25});" + "CREATE (:Person {name: 'Bob', age: 30});"]) + {:db-path db-path + :person-count (scalar-value conn + "MATCH (a:Person) RETURN count(a) AS c;")}))) diff --git a/backend/src/app/graph/meta.clj b/backend/src/app/graph/meta.clj new file mode 100644 index 0000000000..129babd1fa --- /dev/null +++ b/backend/src/app/graph/meta.clj @@ -0,0 +1,59 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.meta + "`GraphMeta`: the graph's own account of who built it and from what. + + A projected graph is a cache of a file at a revision, built by a known + schema. The row records both, so a reader can decide whether to reuse the + database or rebuild it: a `schema_version` that no longer matches the + registry, or a `source_revn` behind the file's, means the cache is stale. + + The row is written *last* in a build, so its presence also marks the build + complete. + + Keyed by `source_file_id` rather than holding a single row: a closure graph + is a union of per-file builds, and each contributing file keeps its own + provenance." + (:require + [app.common.time :as ct] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(def table + "GraphMeta") + +(def producer + "penpot") + +(def ddl + "DDL for the provenance table." + (str "CREATE NODE TABLE `" table "` (" + "`source_file_id` UUID, " + "`producer` STRING, " + "`producer_version` STRING, " + "`schema_version` STRING, " + "`source_revn` INT64, " + "`built_at` TIMESTAMP, " + "PRIMARY KEY (`source_file_id`));")) + +(defn write! + "Record what this build produced for `file-id`." + [^Connection conn {:keys [file-id revn]}] + (ladybug/exec-on-connection! conn [ddl]) + (ladybug/exec-on-connection! + conn + [(str "MERGE (m:`" table "` {source_file_id: " (ladybug/format-uuid file-id) "}) " + "SET m.producer = " (ladybug/format-string producer) ", " + "m.producer_version = " (ladybug/format-string (or (System/getenv "PENPOT_BUILD") "devenv")) ", " + "m.schema_version = " (ladybug/format-string nodes/schema-version) ", " + "m.source_revn = " (ladybug/format-int (or revn 0)) ", " + "m.built_at = " (ladybug/format-timestamp (ct/now)) ";")])) + diff --git a/backend/src/app/graph/projection/document.clj b/backend/src/app/graph/projection/document.clj new file mode 100644 index 0000000000..9d0eec6851 --- /dev/null +++ b/backend/src/app/graph/projection/document.clj @@ -0,0 +1,214 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.projection.document + "Project a Penpot file-data map into Ladybug nodes and structural edges. + + Projects Document, Page, Component, the full shape tree (skipping the root + frame), and `IsChildOf` edges from shapes/pages/components to their parent. + + Two denormalizations happen here rather than in a later pass, because the + walk already has both answers in hand and a post-ingest statement would have + to rediscover them: + + - `page-id` on every shape, from the page the walk is currently in; + - `component-id` propagated from an instance head down to its descendants, + from the head context the walk carries." + (:require + [app.common.logging :as l] + [app.common.uuid :as uuid] + [app.graph.schema.nodes :as nodes])) + +(def root-frame-id + uuid/zero) + +(defn- document-attrs + "The Document node's attrs: the file row, minus its data blob. + + `:options` is lifted out of the blob before it goes: it is file-level + configuration a consumer wants without opening `:data`." + [file data] + (-> file + (assoc :id (or (:id data) (:id file))) + (cond-> (:options data) (assoc :options (:options data))) + (dissoc :data))) + +(defn- page-attrs + [page index] + (-> page + (dissoc :objects) + (cond-> (some? index) (assoc :index (long index))))) + +(defn- component-attrs + [component] + (-> component + (dissoc :objects) + ;; schema:component requires :path; some legacy rows omit it + (update :path #(or % "")))) + +(defn- shape-table + [shape] + (nodes/table-for-type (:type shape))) + +(defn denormalized-shape + "`shape` with `page-id` set and an inherited `component-id` filled in. + + A shape that carries its own `component-id` keeps it; `component-ctx` only + fills the gap for descendants (see `descend-component-ctx`)." + [shape page-id component-ctx] + (cond-> (assoc shape :page-id page-id) + (and (uuid? component-ctx) (nil? (:component-id shape))) + (assoc :component-id component-ctx))) + +(defn- shape-node-attrs + [table shape page-id component-ctx] + (nodes/project-attrs table (denormalized-shape shape page-id component-ctx))) + +(defn descend-component-ctx + "The component context to pass to `shape`'s children. + + Inheritance stops at the nearest ancestor Frame carrying a `component-id`, + and any intermediate shape that carries one is a barrier: + + - a Frame with its own `component-id` becomes the new context (it is an + instance head, and its descendants belong to *it*, not to an outer head); + - any other shape carrying a `component-id` blocks inheritance below it + without being able to supply one, since only Frames are heads; + - otherwise the context passes through unchanged." + [table shape ctx] + (let [own (:component-id shape)] + (cond + (and (some? own) (= table "Frame")) own + (some? own) ::blocked + :else ctx))) + +(defn- container-table? + [table] + (contains? nodes/container-tables table)) + +(defn- child-shape-ids + "Child ids in Penpot z-order (reversed from the stored :shapes list)." + [parent] + (when-let [shapes (:shapes parent)] + (vec (reverse shapes)))) + +(defn- initial-acc + [] + {:nodes {} + :edges [] + :stats {:documents 0 :pages 0 :components 0 :shapes 0}}) + +(declare project-shape-ids) + +(defn- project-shape + [objects acc table shape parent-table parent-id position page-id component-ctx] + (let [shape-id (:id shape) + acc' (-> acc + (update-in [:nodes table] (fnil conj []) + (shape-node-attrs table shape page-id component-ctx)) + (update :edges conj {:from-table table + :from-id shape-id + :to-table parent-table + :to-id parent-id + :position position}) + (update-in [:stats :shapes] inc))] + (if-let [child-ids (when (container-table? table) + (child-shape-ids shape))] + (project-shape-ids objects acc' table shape-id child-ids page-id + (descend-component-ctx table shape component-ctx)) + acc'))) + +(defn- project-shape-ids + [objects acc parent-table parent-id child-ids page-id component-ctx] + (reduce + (fn [acc [position shape-id]] + (if-let [shape (get objects shape-id)] + (if-let [table (shape-table shape)] + (project-shape objects acc table shape parent-table parent-id position + page-id component-ctx) + (do + (l/wrn :hint "unsupported shape type for graph slice" + :shape-id (str shape-id) + :type (:type shape)) + acc)) + (do + (l/wrn :hint "missing shape in page objects" + :shape-id (str shape-id)) + acc))) + acc + (map-indexed vector child-ids))) + +(defn- project-page + [acc doc-id page position] + (let [page-id (:id page) + objects (:objects page) + root (get objects root-frame-id) + page-node (nodes/project-attrs "Page" (page-attrs page position)) + acc' (-> acc + (update-in [:nodes "Page"] (fnil conj []) page-node) + (update :edges conj {:from-table "Page" + :from-id page-id + :to-table "Document" + :to-id doc-id + :position position}) + (update-in [:stats :pages] inc))] + (if-let [top-level-ids (child-shape-ids root)] + (project-shape-ids objects acc' "Page" page-id top-level-ids page-id nil) + acc'))) + +(defn- project-component + [acc doc-id component position] + (if (:deleted component) + acc + (let [comp-id (:id component) + node (nodes/project-attrs "Component" (component-attrs component))] + (-> acc + (update-in [:nodes "Component"] (fnil conj []) node) + (update :edges conj {:from-table "Component" + :from-id comp-id + :to-table "Document" + :to-id doc-id + :position position}) + (update-in [:stats :components] inc))))) + +(defn- project-components + [acc doc-id components] + (reduce (fn [acc [position [_id component]]] + (project-component acc doc-id component position)) + acc + (map-indexed vector components))) + +(defn projection-data + "Build node/edge rows for projecting `data` into Ladybug. + + Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`." + [data file] + (let [doc-id (or (:id data) (:id file)) + doc-node (nodes/project-attrs "Document" (document-attrs file data)) + ;; `:pages` is the tab order the user sees, and `Page.index` and the + ;; page's `IsChildOf.position` are that order. Child shapes are + ;; reversed on the way in (`child-shape-ids`) because their stored + ;; list runs bottom to top; pages have no such second ordering. + pages (seq (:pages data)) + comps (seq (:components data)) + acc0 (-> (initial-acc) + (update-in [:nodes "Document"] (fnil conj []) doc-node) + (assoc-in [:stats :documents] 1)) + acc (cond-> acc0 + (seq comps) + (project-components doc-id comps)) + acc (if (empty? pages) + acc + (reduce (fn [acc [position page-id]] + (if-let [page (get-in data [:pages-index page-id])] + (project-page acc doc-id page position) + (do + (l/wrn :hint "missing page in pages-index" + :page-id (str page-id)) + acc))) + acc + (map-indexed vector pages)))] + (select-keys acc [:nodes :edges :stats]))) diff --git a/backend/src/app/graph/projection/transforms.clj b/backend/src/app/graph/projection/transforms.clj new file mode 100644 index 0000000000..dc87392983 --- /dev/null +++ b/backend/src/app/graph/projection/transforms.clj @@ -0,0 +1,149 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.projection.transforms + "Derived graph links: edges a reader could compute from the projected + columns, materialized once at build time so a query does not have to. + + Each entry in `registry` names the transform, the relationship it produces, + and the function that produces it, so adding one is a single entry and + nothing else has to be told about it." + (:require + [app.common.logging :as l] + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(defn- run-scalar! + [^Connection conn statement] + (or (ladybug/query-scalar-on-connection! conn statement) 0)) + +(defn- link-component-instances! + "`IsInstanceOf` from Frame instance heads to their Component. + + Every head is linked, the main instance and any copy root alike. + + `component-file` is what makes a head a head here, not `component-id` alone. + `app.common.types.component/instance-of?` requires both, and the projection + denormalizes `component-id` down the shape tree + (`app.graph.projection.document`), so on its own it no longer distinguishes a + head from a shape that merely lives inside one. `component-file` is not + denormalized and remains the head marker Penpot itself uses." + [^Connection conn] + (run-scalar! conn + (str "MATCH (f:Frame), (c:Component) " + "WHERE f.component_id = c.id " + "AND f.component_file IS NOT NULL " + "AND NOT COALESCE(c.deleted, false) " + "MERGE (f)-[:IsInstanceOf]->(c) " + "RETURN count(*);"))) + +(defn- shape-pair-statements + "One statement per (from, to) shape-table pair. + + Ladybug cannot create a relationship bound by multiple node labels in a + single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream + issue kuzudb/kuzu#5841). The loop over label pairs is that dialect + constraint, not a modelling choice." + [f] + (for [from nodes/shape-tables + to nodes/shape-tables] + (f from to))) + +(defn- link-shape-refs! + "`RefersTo` from an instance shape to its homologue in the main instance, + driven by `shape-ref`." + [^Connection conn] + (reduce + (fn [total statement] (+ total (run-scalar! conn statement))) + 0 + (shape-pair-statements + (fn [from to] + (str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") " + "WHERE s.shape_ref = t.id " + "MERGE (s)-[:RefersTo]->(t) " + "RETURN count(*);"))))) + +(def ^:private swap-slot-prefix "swap-slot-") + +(def ^:private slot-uuid-expr + ;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length. + (str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)")) + +(defn- link-swap-slots! + "`FillsSwapSlot` from a swapped-in shape to the slot it replaces. + + Penpot records a component sub-shape swap as a `swap-slot-` entry in + the *replacing* shape's `touched` set, where `` names the replaced + slot shape in the main instance. The entries are then stripped from + `touched`, as `app.common.types.component/normal-touched-groups` does, so a + reader of `touched` sees design edits rather than swap bookkeeping. + + Stripping makes this the one transform that writes a column another + transform could read. Anything reading `touched` has to run before it." + [^Connection conn] + (let [linked + (reduce + (fn [total statement] (+ total (run-scalar! conn statement))) + 0 + (shape-pair-statements + (fn [from to] + (str "MATCH (s:" (nodes/match-label from) ") " + "WHERE size(s.touched) > 0 " + "UNWIND s.touched AS touched_key " + "WITH s, touched_key " + "WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') " + "WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id " + "MATCH (t:" (nodes/match-label to) ") " + "WHERE t.id = slot_id AND s.id <> t.id " + "MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) " + "RETURN count(r);"))))] + ;; Strip unconditionally: an entry may name a slot that was garbage + ;; collected, so "no edge created" does not mean "nothing to strip". + (doseq [table nodes/shape-tables] + (ladybug/exec-on-connection! + conn + [(str "MATCH (s:" (nodes/match-label table) ") " + "WHERE size(s.touched) > 0 " + "SET s.touched = list_filter(s.touched, x -> " + "NOT STARTS_WITH(x, '" swap-slot-prefix "'));")])) + linked)) + +(def registry + "Every transform this backend applies. + + `:id` names the transform in the ingest report and the log. `:rel` names + the relationship it produces. The three registered here read disjoint + columns, so the vector order is not load-bearing. The one ordering + constraint that exists is stated on `link-swap-slots!`." + [{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!} + {:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!} + {:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}]) + +(defn apply-transforms! + "Apply every registered transform to an already loaded graph. + + Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what + ran and `:counts` gives the edges each one produced." + [_system ^Connection conn _data _file] + (reduce + (fn [acc {:keys [id rel run]}] + (let [n (run conn)] + (l/inf :hint "graph transform" :transform id :edges n) + (-> acc + (update :ids conj id) + (update :counts assoc rel n) + (assoc rel n)))) + {:ids [] :counts {} :transforms (count registry)} + registry)) + +(defn transform-ids + "Ids of every transform in the registry." + [] + (mapv :id registry)) diff --git a/backend/src/app/graph/report.clj b/backend/src/app/graph/report.clj new file mode 100644 index 0000000000..f026ff3a52 --- /dev/null +++ b/backend/src/app/graph/report.clj @@ -0,0 +1,65 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.report + (:require + [clojure.core :as c] + [clojure.string :as str])) + +(defn- println! + [& lines] + (doseq [line lines] + (println line))) + +(defn- section-title + [title] + (println! (str "\n" title) + (str (apply str (repeat (count title) "─"))))) + +(defn- kv-line + [k v] + (format " %-14s %s" (str k ":") v)) + +(defn- print-node-counts + [nodes] + (doseq [[table count] (sort-by first nodes) + :when (pos? (long count))] + (println! (kv-line table count)))) + +(defn print-ingest! + "Pretty-print the result map returned by `app.graph.ingest/ingest-file!`." + [{:keys [file-id revn name db-path schema-version projection transforms stats]}] + (section-title "Graph ingest") + (println! (kv-line "File" (str name " (" file-id ")")) + (kv-line "Revision" revn) + (kv-line "Schema" schema-version) + (kv-line "Database" db-path)) + + (when-let [pstats (:stats projection)] + (section-title "Projection") + (doseq [[k v] (sort-by key pstats)] + (println! (kv-line (c/name k) v)))) + + (section-title "Transforms") + (println! (kv-line "Applied" (or (:transforms transforms) 0))) + (doseq [[rel count] (sort-by key (:counts transforms))] + (println! (kv-line (c/name rel) count))) + (when-let [ids (seq (:ids transforms))] + (println! (kv-line "Recorded" (str/join ", " ids)))) + + (when stats + (section-title "Graph counts") + (when-let [nodes (:nodes stats)] + (println! " Nodes") + (print-node-counts nodes)) + (when-let [edges (:edges stats)] + (println! " Edges") + (doseq [[rel count] (sort-by key edges) + :when (pos? (long count))] + (println! (kv-line (c/name rel) count))))) + + (println!) + nil) diff --git a/backend/src/app/graph/schema.clj b/backend/src/app/graph/schema.clj new file mode 100644 index 0000000000..0aace14bea --- /dev/null +++ b/backend/src/app/graph/schema.clj @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema + "Ladybug DDL facade for the graph-backed Penpot vertical slice. + + Node metadata and DDL generation live in `app.graph.schema.nodes`." + (:require + [app.graph.schema.nodes :as nodes])) + +(def schema-version + nodes/schema-version) + +(def container-node-tables + nodes/container-tables) + +(def shape-node-tables + nodes/shape-tables) + +(def node-tables + (mapv (fn [{:keys [table schema]}] + {:name table :schema schema}) + nodes/node-types)) + +(defn ddl-statements + [] + (nodes/ddl-statements)) diff --git a/backend/src/app/graph/schema/contract.clj b/backend/src/app/graph/schema/contract.clj new file mode 100644 index 0000000000..684a12dd2e --- /dev/null +++ b/backend/src/app/graph/schema/contract.clj @@ -0,0 +1,150 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.contract + "Deliberate choices in Penpot's graph schema, recorded as data. + + Penpot must pick a spelling and a type for every graph column. A Ladybug + column gets both once, at table creation, and neither widens afterwards. The + choices are therefore worth making deliberately and worth recording. + + Three of them live here: + + - `column-name` maps a Penpot key to its column. The rule is snake_case of + the key, and `renames` records every exception. + - `dropped-keys` and `per-table-dropped` name Penpot keys that deliberately + get no column. + - `type-overrides` pins the Ladybug type where the Malli-derived one + (`app.graph.schema.types`) is coarser than the column deserves. + + Each entry carries its reason. A divergence from the default rule is then a + diff to review rather than a silent rename." + (:require + [app.common.json :as json] + [clojure.string :as str])) + +(def ^:private renames + "Penpot key to column name, where the column is not snake_case of the key. + + Keyed by the Penpot key alone: no shape type gives one of these a second + meaning, so a per-table map would only add ceremony." + {;; `bool` collides with the Ladybug type name, so the column is named after + ;; the table (`Boolean`) rather than after Penpot's `:bool` shape type. + :bool-type "boolean_type" + + ;; The column records what the file saved, which can lag what the shape + ;; tree implies. The `saved_` prefix marks it as the stored value rather + ;; than a derivation. + :component-root "saved_component_root" + + ;; The value is a list, so the plural is accurate. + :shadow "shadows" + + ;; The column spells the revision number out. + :revn "revision"}) + +(def dropped-keys + "Penpot keys projected by the Malli registry that get no column. + + Dropping is right only when the column would be dead weight for every reader + of the graph. A key a reader might learn from belongs in `unprojected-keys` + instead." + {:deleted-at + "Only non-nil for a soft-deleted file, and a deleted file is never ingested." + + :pixel-grid-color + "Viewer chrome: the color of the editor's pixel grid, not design content." + + :pixel-grid-opacity + "Viewer chrome, as above."}) + +(def unprojected-keys + "Penpot keys that should become graph columns and do not have one yet. + + Distinct from `dropped-keys` on purpose: these are a debt the projection + owes, not a decision to discard data. Keeping the two apart means a new + upstream attribute cannot be quietly buried in the drop list." + {:background-blur + "Landed upstream behind a default-on flag. No column for it yet."}) + +(def ^:private per-table-dropped + "Keys dropped only on certain tables. + + `:grids` is the standing case: Penpot's shape schema admits it on every + shape, but only a Frame ever carries one. Emitting an always-null column on + ten other tables would widen every multi-table scan for nothing." + {:grids #{"Boolean" "Circle" "Group" "Image" "Path" "Rectangle" "SVGRaw" "Text"}}) + +(def type-overrides + "Ladybug column type per column name, where the derived type is too coarse. + + `app.graph.schema.types` derives a type from the Malli schema, which is the + right default but coarser than the column deserves in places: a Malli `:map` + becomes `JSON`, where a native Ladybug MAP or a fixed-size array lets a + consumer read a tensor row without parsing. + + Only load-bearing divergences are pinned here, in the order they became + load-bearing." + {;; Must be a native MAP: a JSON blob cannot be indexed by key in Cypher, so + ;; `map_keys` and `map_extract` cannot reach a single token at all. + "applied_tokens" "MAP(STRING, STRING)" + + ;; `grc/schema:rect` is an inline `:and` over a map, not the registered + ;; `::grc/rect`, so `app.graph.schema.types` cannot recognize it by type. + ;; Four doubles rather than the eight-field struct: `x1`/`y1`/`x2`/`y2` are + ;; derivable from `x`/`y`/`width`/`height`, and a fixed-size array is a + ;; tensor row a consumer reads without parsing. + "selrect" "DOUBLE[4]" + + ;; The SVG provenance attributes are typed `:map` in the shape schema on + ;; purpose. Legacy files hold them as plain maps rather than as + ;; `::grc/rect` and `::gmt/matrix` records, and a tighter *schema* would + ;; reject those files + ;; (`app.common.types.shape/schema:shape-generic-attrs`). A tighter + ;; *column* is free: `app.graph.schema.values/coerce` reads either form. + "svg_viewbox" "DOUBLE[4]" + "svg_transform" "DOUBLE[6]" + + ;; `:fills` is an `:or` over the packed `app.common.types.fills` value and + ;; a plain vector of fill maps, so the schema alone cannot say it is a + ;; collection. It always is one, and a fill has enough optional shape + ;; (solid, gradient, image) that JSON per element is the honest element + ;; type. + "fills" "JSON[]"}) + +(def ^:private map-key-fns + "How to render the *keys* of a MAP column, per column. + + A column name is schema, so it is snake_case. The keys inside a MAP are + values, so they keep the spelling their producer used. `applied_tokens` is + keyed by shape attribute in the camelCase form + `app.common.json/write-camel-key` produces: `strokeWidth`, not + `stroke-width`." + {"applied_tokens" json/write-camel-key}) + +(defn map-key-fn + "Key renderer for a MAP column. `name` unless the column says otherwise." + [column] + (get map-key-fns column name)) + +(defn column-name + "The graph column name for Penpot key `k`. + + Default: snake_case of the key. `renames` overrides." + [k] + (or (get renames k) + (str/replace (name k) "-" "_"))) + +(defn drop-key? + "Should key `k` be omitted from `table`'s columns?" + [table k] + (or (contains? dropped-keys k) + (contains? (get per-table-dropped k #{}) table))) + +(defn ladybug-type + "The pinned Ladybug type for `column`, or `fallback` when nothing is pinned." + [column fallback] + (get type-overrides column fallback)) diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj new file mode 100644 index 0000000000..c81802415d --- /dev/null +++ b/backend/src/app/graph/schema/nodes.clj @@ -0,0 +1,343 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.nodes + "Single source of truth for graph node tables. + + Each registry entry declares Penpot Malli sources plus projection + options (`:drop`, optional `:extra`). Derived artifacts — Ladybug + DDL, Arrow fields, validation, type dispatch — all flow from that. + + This registry is the single source of the graph schema. A Ladybug column + gets its name and its type once, at table creation, and there is no + widening afterwards. Every divergence between a Penpot key and its column + is recorded in `app.graph.schema.contract`." + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.component :as ctk] + [app.common.types.file :as ctf] + [app.common.types.page :as ctp] + [app.graph.ladybug :as ladybug] + [app.graph.schema.contract :as contract] + [app.graph.schema.projection :as projection] + [app.graph.schema.types :as types] + [clojure.string :as str])) + +(def schema-version + "penpot-graph-slice-4") + +(def ^:private document-projection + {:source ctf/schema:file + :drop [:data] + ;; Attributes a file map carries that `ctf/schema:file` does not declare. + ;; + ;; They belong here rather than in that schema, even though the graph wants + ;; them, because `schema:file` is on the *write* path too: + ;; `app.binfile.common/update-file!` derives its UPDATE columns from a file + ;; map's keys, so declaring `:backend` there made it try to write a `backend` + ;; column, which the `file` table does not have — it is synthesized on read. + ;; A projection `:extra` is local to the graph and cannot reach a write. + ;; + ;; `:options` is lifted out of `:data` before the blob is dropped + ;; (`app.graph.projection.document/document-attrs`); the rest come off the file + ;; map as `get-file` returns it. + :extra [:map + [:options {:optional true} [:maybe :map]] + [:backend {:optional true} [:maybe :string]] + [:comment-thread-seqn {:optional true} [:maybe :int]] + [:ignore-sync-until {:optional true} [:maybe ::ct/inst]]]}) + +(def ^:private page-projection + {:source ctp/schema:page + :drop [:objects]}) + +(def ^:private component-projection + {:source ctk/schema:component + :drop [:objects] + ;; Soft-delete flag used at runtime; not in schema:component. + :extra [:map + [:deleted {:optional true} :boolean] + [:annotation {:optional true} :string]]}) + +(def ^:private shape-projection + {:drop [:type]}) + +(def ^:private shape-node-types + [{:table "Frame" :penpot-type :frame :container? true} + {:table "Group" :penpot-type :group :container? true} + {:table "Boolean" :penpot-type :bool :container? true} + {:table "SVGRaw" :penpot-type :svg-raw :container? true} + {:table "Rectangle" :penpot-type :rect} + {:table "Circle" :penpot-type :circle} + {:table "Path" :penpot-type :path} + {:table "Text" :penpot-type :text} + {:table "Image" :penpot-type :image}]) + +(defn- resolve-schema + [{:keys [schema source drop extra penpot-type]}] + (or schema + (when penpot-type + (projection/project-shape-schema penpot-type + {:drop drop + :extra extra})) + (projection/project-schema source + {:drop drop + :extra extra}))) + +(defn- shape-node-entry + [{:keys [table penpot-type container?] :as entry}] + (let [projection (-> shape-projection + (merge (:projection entry)) + (assoc :penpot-type penpot-type))] + {:table table + :pk :id + :penpot-type penpot-type + :container? container? + :projection projection + :schema (resolve-schema projection)})) + +(def node-types + "Ordered node registry." + (into [{:table "Document" + :pk :id + :projection document-projection + :schema (resolve-schema document-projection)} + {:table "Page" + :pk :id + :projection page-projection + :schema (resolve-schema page-projection)} + {:table "Component" + :pk :id + :projection component-projection + :schema (resolve-schema component-projection)}] + (map shape-node-entry shape-node-types))) + +(def ^:private by-table + (into {} (map (juxt :table identity) node-types))) + +(def ^:private by-penpot-type + (into {} (keep (fn [{:keys [penpot-type table]}] + (when penpot-type [penpot-type table])) + node-types))) + +(def container-tables + (into #{} (comp (filter :container?) (map :table)) node-types)) + +(def shape-tables + (into [] (comp (filter :penpot-type) (map :table)) node-types)) + +(defn table-for-type + "Map a Penpot shape `:type` keyword to a Ladybug node table name." + [penpot-type] + (get by-penpot-type (keyword penpot-type))) + +(defn node-entry + [table] + (get by-table table)) + +(defn projection-for + "Return the projection options map for `table`." + [table] + (:projection (node-entry table))) + +(defn- entry-child-schema + "Return the value schema from a Malli map entry (`[k s]` or `[k props s]`)." + [entry] + (if (> (count entry) 2) + (nth entry 2) + (nth entry 1))) + +(defn column-name + "Graph column name for projected key `k` on `table`." + [_table k] + (contract/column-name k)) + +(defn column-ladybug-type + "Ladybug column type for projected key `k` on `table`." + [table k] + (some (fn [entry] + (when (= k (first entry)) + (contract/ladybug-type (column-name table k) + (types/ladybug-type (entry-child-schema entry))))) + (projection/schema-map-entries (:schema (node-entry table))))) + +(defn column-keys + "Projected column keys for `table`, in registry order. + + Keys the contract drops on this table are omitted, so the column order, the + Arrow batch, and the DDL cannot disagree about what exists." + [table] + (into [] + (comp (map first) + (remove #(contract/drop-key? table %))) + (projection/schema-map-entries (:schema (node-entry table))))) + +(defn columns + "Projected column names for `table`, in registry order." + [table] + (mapv #(column-name table %) (column-keys table))) + +(def ^:private validate-node-fn + (memoize + (fn [table] + (let [{:keys [schema]} (node-entry table)] + (sm/check-fn schema + :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (str "invalid graph node projection for " table)))))) + +(defn- projection-error-hint + [table explain] + (str "invalid graph node projection for " table + (when explain + (str "\n" (sm/humanize-explain explain))))) + +(defn validate-node + "Validate and return projected node attrs for `table`." + [table value] + (let [{:keys [schema]} (node-entry table)] + (try + ((validate-node-fn table) value) + (catch clojure.lang.ExceptionInfo e + (let [data (ex-data e) + explain (or (::sm/explain data) + (sm/explain schema value))] + (ex/raise :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (projection-error-hint table explain) + :table table + ::sm/explain explain + :cause e)))))) + +(defn- get-projected-attr + "The attribute under `k`, keyword or string key. + + `if-some`, not `or`: `false` and `0` are values, and falling through on them + is how `opacity 0` became `nil` and then the column default." + [attrs k] + (if-some [v (get attrs k)] + v + (when (keyword? k) (get attrs (name k))))) + +(defn- raise-empty-projection! + [table attrs] + (ex/raise :type :validation + :code (keyword "graph-node-projection" (str/lower-case table)) + :hint (str "empty graph node projection for " table + "; columns=" (count (column-keys table)) + " shape-keys=" (vec (keys attrs))))) + +(defn project-attrs + "Select and validate the projected columns for `table` from `attrs`." + [table attrs] + ;; `some?`, not truthiness: `false` and `0` are values. Dropping them sent + ;; `opacity 0` to the column default of 1.0 — a fully transparent shape + ;; projected as opaque. + (let [projected (into {} + (keep (fn [k] + (let [v (get-projected-attr attrs k)] + (when (some? v) [k v]))) + (column-keys table)))] + (when (empty? projected) + (raise-empty-projection! table attrs)) + (validate-node table projected))) + +(defn match-label + "Cypher node label for MATCH; backtick-wrapped when required by Ladybug." + [table] + (if (#{"Group" "Boolean"} table) + (str "`" table "`") + table)) + +(defn cypher-property-key + "Backtick-wrapped column name for inline Cypher literals." + [table k] + (str "`" (column-name table k) "`")) + +(defn column-map-key-fn + "How a MAP column of `table` renders its keys. + + A MAP's keys are values, not schema, so they keep the spelling their consumer + parsed — `applied_tokens` is keyed in camelCase. Both writers need this, so it + lives next to the column's type rather than in either of them." + [table k] + (contract/map-key-fn (column-name table k))) + +(defn format-column-value + "Cypher literal for `v` in column `k` of `table`. + + The single place that knows both the column's Ladybug type and the contract + detail that a MAP column may render its keys differently from `name` — used + by the bulk loader's post-COPY fixups and by the incremental sync alike, so + the two cannot disagree about a value's shape." + [table k v] + (ladybug/format-typed-value (column-ladybug-type table k) + v + (column-map-key-fn table k))) + +(defn- create-node-table-ddl + [{:keys [table pk]}] + (let [cols (for [k (column-keys table)] + (str "`" (column-name table k) "` " (column-ladybug-type table k)))] + (str "CREATE NODE TABLE `" table "` (" + (str/join ", " (concat cols + [(str "PRIMARY KEY (`" (column-name table pk) "`)")])) + ");"))) + +(defn is-child-of-ddl + [] + (str "CREATE REL TABLE `IsChildOf` (" + "FROM `Page` TO `Document`, " + "FROM `Component` TO `Document`, " + (str/join ", " + (concat + (map (fn [shape] + (str "FROM `" shape "` TO `Page`")) + shape-tables) + (for [shape shape-tables + container container-tables] + (str "FROM `" shape "` TO `" container "`")))) + ", `position` INT64);")) + +(defn is-instance-of-ddl + "Frame instance heads → Component." + [] + "CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);") + +(defn- shape-to-shape-rel-ddl + "A rel table over the full shape × shape product. + + Created up-front rather than on demand: the bulk loader must never race on + lazy table creation, and a consumer can then tell \"this producer cannot + emit that pair\" from \"this document happens to have none\"." + [rel props] + (str "CREATE REL TABLE `" rel "` (" + (str/join ", " (for [from shape-tables + to shape-tables] + (str "FROM `" from "` TO `" to "`"))) + (when (seq props) (str ", " (str/join ", " props))) + ");")) + +(defn refers-to-ddl + "Instance shape → its homologue in the component main instance, resolved + from `shape-ref`." + [] + (shape-to-shape-rel-ddl "RefersTo" nil)) + +(defn fills-swap-slot-ddl + "Swapped-in shape → the slot shape it replaces." + [] + (shape-to-shape-rel-ddl "FillsSwapSlot" ["`slot_id` UUID"])) + +(defn ddl-statements + [] + (-> (mapv create-node-table-ddl node-types) + (conj (is-child-of-ddl)) + (conj (is-instance-of-ddl)) + (conj (refers-to-ddl)) + (conj (fills-swap-slot-ddl)))) \ No newline at end of file diff --git a/backend/src/app/graph/schema/projection.clj b/backend/src/app/graph/schema/projection.clj new file mode 100644 index 0000000000..4d0e969329 --- /dev/null +++ b/backend/src/app/graph/schema/projection.clj @@ -0,0 +1,85 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.projection + "Derive Ladybug node column schemas from Penpot Malli sources. + + Start from the canonical schema and remove the keys that must not become + graph columns." + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.types.shape :as cts] + [malli.core :as m])) + +(def ^:private malli-opts sm/default-options) + +(defn- coerce-schema + "Normalize Malli sources to a compiled schema, unwrapping `:val` nodes." + [schema] + (loop [s (cond + (sm/schema? schema) schema + :else (sm/schema schema))] + (if (= :malli.core/val (sm/type s)) + (recur (first (sm/children s))) + s))) + +(defn- unsupported-projection-schema! + [schema] + (ex/raise :type :internal + :code :unsupported-projection-schema + :hint (str "unsupported projection schema type: " + (sm/type (coerce-schema schema))))) + +(defn schema-map-entries + "Map entries for `schema`, flattening `:merge` composites." + [schema] + (let [s (coerce-schema schema)] + (or (seq (sm/entries s)) + (unsupported-projection-schema! schema)))) + +(defn- select-projected-keys + "Project `schema` to a flat map schema, optionally dropping keys." + [schema drop-keys] + (let [s (coerce-schema schema) + keys (if (seq drop-keys) + (remove (set drop-keys) (sm/keys s)) + (sm/keys s))] + (sm/select-keys s (vec keys)))) + +(defn shape-type-schema + "Return the compiled Penpot Malli branch for shape type `penpot-type`. + + `m/entries` on the shape `:multi` yields MapEntries whose values are + compiled branch schemas (wrapped in `:val`). `m/children` returns raw + entry forms and must not be used here." + [penpot-type] + (let [kw (keyword penpot-type) + multi (sm/schema cts/schema:shape-attrs)] + (or (some (fn [entry] + (when (= kw (key entry)) + (val entry))) + (m/entries multi malli-opts)) + (ex/raise :type :validation + :code :unknown-shape-type + :hint (str "unknown penpot shape type: " kw))))) + +(defn project-schema + "Build a graph node schema from canonical Malli `source`. + + Options: + - `:drop` - keys removed from the source + - `:extra` - optional extra `[:map ...]` merged on top" + [source {:keys [drop extra]}] + (let [projected (select-projected-keys source drop)] + (if extra + (sm/merge projected (coerce-schema extra)) + projected))) + +(defn project-shape-schema + "Project `:drop` from the Penpot schema for `penpot-type`." + [penpot-type opts] + (project-schema (shape-type-schema penpot-type) opts)) diff --git a/backend/src/app/graph/schema/types.clj b/backend/src/app/graph/schema/types.clj new file mode 100644 index 0000000000..a905d92dcc --- /dev/null +++ b/backend/src/app/graph/schema/types.clj @@ -0,0 +1,174 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.types + "Map Malli schemas to Ladybug column types. + + Ladybug is schema-first and strongly typed: every property key gets its type + at table-creation time, and there is no widening later. That makes this + mapping the whole of the graph's typing, and it is worth being tight — a + column typed `DOUBLE[4]` is four numbers a consumer reads as a tensor row, + where the same value as `JSON` is text somebody has to parse and trust. So + JSON is the fallback of last resort, taken only where the Malli schema + genuinely admits shapes no single column can hold. + + Three groups, in the order the mapping tries them: + + 1. **Scalars** (`base-type->ladybug`) — the leaf Malli types. + 2. **Registered composites** (`custom-type->ladybug`) — Penpot's own value + types whose *layout* is fixed even though Malli only sees a map or a + string: a matrix is six doubles, a point two, a rect four, a hex colour + one packed integer. These are named explicitly because the tight encoding + is a modelling decision, not something derivable from the schema. + 3. **Structure** — collections become `T[]`, `:map-of` becomes `MAP(k, v)`, + and a closed map of scalars becomes a `STRUCT`. Anything that could be + more than one shape (a `:multi`, an `:or`, an optional-keyed map) becomes + `JSON`, because a Ladybug column cannot be two types. + + Every encoding here has a matching value formatter in `app.graph.ladybug`. + The two must move together: a column type with no case there falls back to + guessing the literal from the runtime value." + (:require + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.time :as ct] + [clojure.string :as str] + [malli.core :as m])) + +(def ^:private malli-opts sm/default-options) + +(def ^:private base-type->ladybug + {::sm/uuid "UUID" + ::sm/safe-number "DOUBLE" + ::sm/safe-double "DOUBLE" + ::sm/safe-int "INT64" + ::sm/number "DOUBLE" + ::sm/boolean "BOOLEAN" + ::sm/int "INT64" + ::ct/inst "TIMESTAMP" + :uuid "UUID" + :string "STRING" + :int "INT64" + :double "DOUBLE" + :float "DOUBLE" + :boolean "BOOLEAN" + :keyword "STRING" + :inst "TIMESTAMP"}) + +(def ^:private custom-type->ladybug + "Penpot value types with a fixed layout Malli does not express. + + Fixed-size arrays are the point of each: they are dense, they need no + parsing, and a consumer can read a whole column as a tensor. + + - `::gmt/matrix` — the affine transform, `[a b c d e f]`. + - `::gpt/point` — `[x y]`. + - `::grc/rect` — `[x y width height]`. `x1`/`y1`/`x2`/`y2` are dropped: they + are derivable from those four, and carrying them would double the column. + - `::clr/hex-color` — `#RRGGBB` packed as `0xRRGGBBAA`, so colours compare + and group without string handling." + {:app.common.geom.matrix/matrix "DOUBLE[6]" + :app.common.geom.point/point "DOUBLE[2]" + :app.common.geom.rect/rect "DOUBLE[4]" + :app.common.types.color/hex-color "UINT32"}) + +(def ^:private collection-types + #{:vector :sequential :set ::sm/vec ::sm/set ::sm/coll}) + +(def ^:private string-collection-types + "Registered collection schemas whose element type is not in `children`." + {::sm/set-of-strings "STRING[]" + ::sm/set-of-keywords "STRING[]" + ::sm/set-of-uuid "UUID[]" + ::sm/vec-of-uuid "UUID[]"}) + +(defn- normalize-schema + "Resolve refs, but stop at a schema this namespace maps explicitly. + + Order matters: `::grc/rect` derefs to an `:and` over a map, and following + that would lose the fixed-size-array encoding." + [schema] + (let [s (sm/schema schema)] + (if (and (m/-ref-schema? s) + (not (contains? custom-type->ladybug (m/type s))) + (not (contains? string-collection-types (m/type s)))) + (recur (m/deref s malli-opts)) + s))) + +(declare ladybug-type) + +(defn- entry-child + "The value schema of a Malli map entry (`[k s]` or `[k props s]`)." + [entry] + (if (> (count entry) 2) (nth entry 2) (nth entry 1))) + +(defn- entry-optional? + [entry] + (and (> (count entry) 2) + (:optional (nth entry 1)))) + +(defn- struct-type + "`STRUCT(...)` for a closed map of scalars, or nil when JSON is the honest answer. + + A struct is a fixed layout: every field present, every field a single type. + An optional key would make the column's shape depend on the row, and a nested + collection or map makes it recursive — Ladybug allows nesting, but a consumer + reading such a column gains nothing over JSON, so the line is drawn at + scalars." + [s] + (let [entries (m/entries s malli-opts)] + (when (and (seq entries) + (not-any? entry-optional? entries)) + (let [fields (for [entry entries + :let [t (ladybug-type (entry-child entry))]] + (when (and t + (not= "JSON" t) + (not (str/includes? t "("))) + ;; snake_case like a column name, and always + ;; backtick-quoted: a grid cell has a field called + ;; `column`, which is a Ladybug keyword, and an unquoted + ;; one fails to parse in the DDL *and* in every literal. + ;; The catalog reports them unquoted. + (str "`" (str/replace (name (key entry)) "-" "_") "` " t)))] + (when (every? some? fields) + (str "STRUCT(" (str/join ", " fields) ")")))))) + +(defn ladybug-type + "Return the Ladybug column type for a Malli child schema." + [schema] + (let [s (normalize-schema schema) + t (m/type s)] + (or (base-type->ladybug t) + (custom-type->ladybug t) + (string-collection-types t) + (when (contains? collection-types t) + (when-let [child (first (m/children s malli-opts))] + (str (ladybug-type child) "[]"))) + (case t + (:maybe :and) (ladybug-type (first (m/children s malli-opts))) + + ;; `::sm/one-of` is how Penpot spells a closed set of keywords — + ;; `:blend-mode`, `:grow-type`, every `:layout-*`. One keyword, one + ;; string. + (:enum ::sm/one-of) "STRING" + + :map-of + (let [[key-schema value-schema] (m/children s malli-opts)] + (str "MAP(" (ladybug-type key-schema) ", " + (ladybug-type value-schema) ")")) + + :map (or (struct-type s) "JSON") + + ;; A schema we do not recognize. If it has no children it is a leaf — + ;; one of Penpot's registered keyword or enum schemas, say — and a + ;; string holds it exactly. If it has children it is a composite whose + ;; shape we cannot pin down, and JSON is the honest answer. + (if (empty? (m/children s malli-opts)) + "STRING" + (do + (l/wrn :hint "unmapped composite malli type, defaulting to JSON" + :malli-type t) + "JSON")))))) diff --git a/backend/src/app/graph/schema/values.clj b/backend/src/app/graph/schema/values.clj new file mode 100644 index 0000000000..e93b74f499 --- /dev/null +++ b/backend/src/app/graph/schema/values.clj @@ -0,0 +1,202 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.schema.values + "Shape a Penpot value into the plain data its Ladybug column type wants. + + Ladybug is strongly typed, and `app.graph.schema.types` maps Penpot's Malli + schemas onto types as tight as it can — a matrix is `DOUBLE[6]`, a rect + `DOUBLE[4]`, a colour `UINT32`, a closed map a `STRUCT`. A tight column is + only worth having if the writer actually fills it in that shape, which is + what this namespace does: it turns records and maps into the numbers, vectors + and plain maps the type names. + + It deliberately stops there. Serialization belongs to the writer — Cypher + literals in `app.graph.ladybug`, Arrow vectors in `app.graph.arrow` — so that + shaping a value and writing it are separate concerns and each has one home. + + The type language is the Ladybug one, read recursively: `T[]`, `T[n]`, + `MAP(k, v)`, `STRUCT(name t, …)`. Anything else is passed through." + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.types.color :as clr] + [clojure.string :as str])) + +(defn- split-args + "Split a comma-separated type argument list, respecting nesting. + + `\"UUID, STRUCT(a INT64, b INT64)\"` → `[\"UUID\" \"STRUCT(a INT64, b INT64)\"]`." + [s] + (loop [chars (seq s) depth 0 current (StringBuilder.) out []] + (if-let [c (first chars)] + (cond + (and (= c \,) (zero? depth)) + (recur (rest chars) depth (StringBuilder.) (conj out (str/trim (str current)))) + + (or (= c \() (= c \[)) + (recur (rest chars) (inc depth) (.append current c) out) + + (or (= c \)) (= c \])) + (recur (rest chars) (dec depth) (.append current c) out) + + :else + (recur (rest chars) depth (.append current c) out)) + (let [last-arg (str/trim (str current))] + (cond-> out (seq last-arg) (conj last-arg)))))) + +(defn- parse-list + "`[element-type]` when `t` is a list or fixed-size array type, else nil. + + `DOUBLE[]` and `DOUBLE[4]` are both lists of doubles as far as shaping goes; + the size only matters to the DDL." + [t] + (when-let [[_ element] (re-matches #"(.+?)\[\d*\]$" t)] + [element])) + +(defn- parse-map + "`[key-type value-type]` when `t` is a MAP type, else nil." + [t] + (when-let [[_ args] (re-matches #"MAP\((.*)\)$" t)] + (let [[k v] (split-args args)] + (when (and k v) [k v])))) + +(defn- parse-struct + "`[[field-name field-type] …]` when `t` is a STRUCT type, else nil. + + Field names arrive backtick-quoted (see `app.graph.schema.types`). The + quoting is syntax, so it is stripped by default and re-applied by the writer — + except for the Arrow writer, which needs it kept (`keep-quotes?`)." + [t keep-quotes?] + (when-let [[_ args] (re-matches #"STRUCT\((.*)\)$" t)] + (for [arg (split-args args) + :let [idx (str/index-of arg " ")] + :when idx] + [(cond-> (subs arg 0 idx) (not keep-quotes?) (str/replace "`" "")) + (str/trim (subs arg (inc idx)))]))) + +(def ^:private struct-field-keys + "Field name → the Penpot keys that may hold it. + + A STRUCT field name is the snake_case of the Penpot key, but a value arrives + with its original key, and some arrive from JSON with the string form. Both + are tried before giving up." + (memoize + (fn [field] + [(keyword (str/replace field "_" "-")) + (keyword field) + field + (str/replace field "_" "-")]))) + +(defn- struct-field + [value field] + (some (fn [k] (when (contains? value k) (get value k))) + (struct-field-keys field))) + +(defn- fixed-vector + "`v` as a plain vector of numbers, for a `DOUBLE[n]` column. + + Records come first because they are what a realized snapshot holds; the map + forms are what a JSON round-trip leaves behind." + [v] + (cond + (gmt/matrix? v) [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)] + (gpt/point? v) [(:x v) (:y v)] + + ;; A rect: four of the eight fields, the rest being derivable. + (and (map? v) (contains? v :width) (contains? v :height)) + [(:x v) (:y v) (:width v) (:height v)] + + (and (map? v) (contains? v :x) (contains? v :y)) + [(:x v) (:y v)] + + (and (map? v) (contains? v :a) (contains? v :f)) + [(:a v) (:b v) (:c v) (:d v) (:e v) (:f v)] + + (sequential? v) (vec v) + :else nil)) + +(defn- packed-color + "`#RRGGBB` as the packed integer `0xRRGGBBAA`. + + Alpha defaults to opaque: the column holds a colour, and any opacity Penpot + keeps alongside it is a separate attribute." + [v] + (cond + (integer? v) v + (and (string? v) (clr/valid-hex-color? v)) + (let [rgb (Long/parseLong (subs v 1) 16)] + (bit-or (bit-shift-left rgb 8) 0xFF)) + :else nil)) + +(def struct-fields + "`[[field-name field-type] …]` for a STRUCT type, memoized. + + Public because the writers need the same field list to emit a literal." + (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type false))))) + +(def struct-fields-quoted + "`struct-fields` with the DDL's backticks intact. + + Only the Arrow writer wants this: Ladybug names a staged struct's fields from + the Arrow child names and quotes none of them, so a field whose name is a + reserved word — a layout grid cell's `column` — has to arrive already quoted + or `createArrowTable` fails outright." + (memoize (fn [ladybug-type] (vec (parse-struct ladybug-type true))))) + +(def map-types + "`[key-type value-type]` for a MAP type, memoized." + (memoize (fn [ladybug-type] (parse-map ladybug-type)))) + +(def list-element + "Element type of a `T[]` / `T[n]` column, memoized; nil when not a list." + (memoize (fn [ladybug-type] (first (parse-list ladybug-type))))) + +(declare coerce) + +(defn- coerce-struct + [fields v] + (when (map? v) + (into {} + (keep (fn [[field field-type]] + (when-some [fv (struct-field v field)] + [field (coerce field-type fv)]))) + fields))) + +(defn coerce + "`v` as the plain data a column of `ladybug-type` holds. + + Returns `nil` when the value cannot be shaped that way, which callers treat + as \"write NULL\" — a wrong shape in a strongly typed column fails the whole + load, so declining is better than guessing." + [ladybug-type v] + (cond + (nil? v) nil + (not (string? ladybug-type)) v + + (= "UINT32" ladybug-type) (packed-color v) + + ;; Fixed-size numeric arrays are records: matrix, point, rect. + (re-matches #"DOUBLE\[\d+\]" ladybug-type) (fixed-vector v) + + :else + (if-let [[element] (parse-list ladybug-type)] + (when (or (sequential? v) (set? v)) + ;; A set has no order, so its column would otherwise vary between + ;; builds of the same file. Sorting makes it deterministic — which is + ;; what lets two builds be diffed at all, and what a stable golden + ;; needs. Sequential values keep their order: for `shapes` and + ;; `points`, the order *is* the content. + (let [elements (mapv #(coerce element %) v)] + (if (set? v) (vec (sort-by str elements)) elements))) + (if-let [[key-type value-type] (parse-map ladybug-type)] + (when (map? v) + (into {} + (map (fn [[k mv]] [(coerce key-type k) (coerce value-type mv)])) + v)) + (if-let [fields (seq (parse-struct ladybug-type false))] + (coerce-struct fields v) + v))))) diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj new file mode 100644 index 0000000000..06a4eb2330 --- /dev/null +++ b/backend/src/app/graph/stats.clj @@ -0,0 +1,48 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.stats + (:require + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes])) + +(defn- count-on-connection + [conn statement] + (or (ladybug/query-scalar-on-connection! conn statement) 0)) + +(defn- rel-table-names + "Relationship tables present in the open database. + + Read from the catalog so a newly ported transform's edges are counted + without this namespace being told about it." + [conn] + (->> (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" :max-rows 1000) + :rows + (map first))) + +(defn summarize-connection + "Return node/edge counts using an open Ladybug connection." + [conn] + {:nodes (into {} + (map (fn [table] + [table (count-on-connection + conn + (str "MATCH (n:" (nodes/match-label table) ") " + "RETURN count(n) AS " table "_c;"))]) + (map :table nodes/node-types))) + :edges (into {} + (map (fn [rel] + [(keyword rel) + (count-on-connection + conn + (str "MATCH ()-[e:`" rel "`]->() RETURN count(e) AS c;"))])) + (rel-table-names conn))}) + +(defn summarize + "Return node/edge counts from the graph database." + [db-path] + (ladybug/with-connection! db-path summarize-connection)) diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj new file mode 100644 index 0000000000..cb32fe2d47 --- /dev/null +++ b/backend/src/app/graph/sync.clj @@ -0,0 +1,900 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.graph.sync + "Incremental Ladybug graph updates from Penpot file-change events." + (:require + [app.common.logging :as l] + [app.common.uuid :as uuid] + [app.graph.ladybug :as ladybug] + [app.graph.projection.document :as projection.document] + [app.graph.schema.nodes :as nodes] + [clojure.string :as str]) + (:import + com.ladybugdb.Connection)) + +(set! *warn-on-reflection* true) + +(def ^:private supported-change-types + #{:add-obj :mod-obj :del-obj + :add-page :del-page :mod-page :mov-objects + :add-component :mod-component :del-component + :restore-component :purge-component}) + +(defn- shape-table + [shape] + (nodes/table-for-type (:type shape))) + +(defn- build-parent-map + [edges] + (into {} + (map (fn [{:keys [from-id to-id to-table]}] + [from-id {:parent-id to-id :parent-table to-table}])) + edges)) + +(defn- build-children-map + [edges] + (reduce (fn [acc {:keys [from-id to-id]}] + (update acc to-id (fnil conj #{}) from-id)) + {} + edges)) + +(defn- resolve-page-id + [shape-id parents pages] + (loop [id shape-id] + (cond + (contains? pages id) id + (get parents id) (recur (:parent-id (parents id))) + :else nil))) + +(defn- node-attrs-id + [attrs] + (cond + (map? attrs) (or (:id attrs) (get attrs "id")) + (and (vector? attrs) (= 2 (count attrs))) + (let [[k v] attrs] + (when (or (= k :id) (= k "id")) v)))) + +(defn- table-rows + "Normalize a projection table value to a vector of attribute maps." + [nodes table] + (let [rows (or (get nodes table) (get nodes (keyword table)))] + (cond + (nil? rows) [] + (map? rows) [rows] + (sequential? rows) (vec rows) + :else []))) + +(defn- document-id-from-nodes + [nodes file-id] + (or (some node-attrs-id (table-rows nodes "Document")) + file-id)) + +(defn- page-index-entry + [attrs] + (let [id (node-attrs-id attrs)] + [id {:id id + :name (:name attrs) + :index (long (:index attrs 0))}])) + +(defn- index-pages + [nodes] + (into {} (map page-index-entry (table-rows nodes "Page")))) + +(defn- component-index-entry + [attrs] + (let [id (node-attrs-id attrs)] + [id {:id id + :name (:name attrs) + :deleted (boolean (:deleted attrs))}])) + +(defn- index-components + [nodes] + (into {} (map component-index-entry (table-rows nodes "Component")))) + +(defn- shape-index-table? + [table] + (not (contains? #{"Document" "Page" "Component" + :Document :Page :Component} + table))) + +(defn- shape-index-entry + [table attrs parents pages edges] + (let [shape-id (node-attrs-id attrs) + {:keys [parent-id parent-table]} (parents shape-id) + edge (first (filter #(= shape-id (:from-id %)) edges))] + [shape-id {:id shape-id + :name (:name attrs) + :table table + :parent-id parent-id + :parent-table parent-table + :position (long (:position edge 0)) + :frame-id (:frame-id attrs) + ;; The projection already denormalized these; re-deriving + ;; page-id from the parent chain would only be a second way to + ;; get the same answer. `:component-ctx` is what later + ;; `:add-obj` children inherit — it is the shape's effective + ;; component-id, which loses the barrier case of a *non-Frame* + ;; carrying its own `component-id` (indistinguishable once + ;; denormalized). Cold projection keeps the distinction. Only a + ;; graph synced across such a shape can drift, and a Reload + ;; rebuilds it. + :component-ctx (:component-id attrs) + :page-id (or (:page-id attrs) + (resolve-page-id shape-id parents pages))}])) + +(defn- index-shapes + [nodes edges parents pages] + (reduce + (fn [acc [table _]] + (into acc (map #(shape-index-entry table % parents pages edges) + (table-rows nodes table)))) + {} + (filter (fn [[table _]] (shape-index-table? table)) nodes))) + +(defn build-index + "Build a sync index from a full graph projection." + [file-id revn {:keys [nodes edges]}] + (let [doc-id (document-id-from-nodes nodes file-id) + pages (index-pages nodes) + components (index-components nodes) + parents (build-parent-map edges) + children-index (build-children-map edges) + shapes (index-shapes nodes edges parents pages)] + {:file-id file-id + :doc-id doc-id + :revn (long revn) + :pages pages + :components components + :shapes shapes + :children children-index})) + + +(defn- format-node-value + [table k v] + (nodes/format-column-value table k v)) + +(defn- create-node-statement + [table attrs] + (let [label (nodes/match-label table) + pairs (for [k (nodes/column-keys table) + :let [v (get attrs k)] + :when (some? v)] + (str (nodes/cypher-property-key table k) ": " + (format-node-value table k v)))] + (str "CREATE (:" label " {" (str/join ", " pairs) "});"))) + +(defn- delete-node-statement + [table shape-id] + (str "MATCH (n:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "DETACH DELETE n;")) + +(defn- create-edge-statement + [{:keys [from-table from-id to-table to-id position]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "}), " + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);")) + +(defn- create-instance-of-statement + "Link a Frame instance head to its Component. + + No-op when the Component is absent (e.g. library component not ingested)." + [frame-id component-id] + (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "}), " + "(c:Component {id: " (ladybug/format-uuid component-id) "}) " + "WHERE NOT COALESCE(c.deleted, false) " + "MERGE (f)-[:IsInstanceOf]->(c);")) + +(defn- delete-instance-of-statement + [frame-id] + (str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "})" + "-[r:IsInstanceOf]->(:Component) " + "DELETE r;")) + +(defn- instance-of-statements + "Cypher to (re)link `IsInstanceOf` after add/mod of a Frame's component-id." + [table shape-id component-id] + (when (= table "Frame") + (cond-> [(delete-instance-of-statement shape-id)] + (some? component-id) + (conj (create-instance-of-statement shape-id component-id))))) + +(defn- delete-edge-statement + [{:keys [from-table from-id to-table to-id]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})" + "-[r:IsChildOf]->" + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "DELETE r;")) + +(defn- set-edge-position-statement + [{:keys [from-table from-id to-table to-id position]}] + (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})" + "-[r:IsChildOf]->" + "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) " + "SET r.position = " (ladybug/format-int position) ";")) + +(defn- set-node-attr-statement + [table shape-id attr value] + (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "SET s." (nodes/cypher-property-key table attr) " = " + (format-node-value table attr value) ";")) + +(defn- set-page-name-statement + [page-id name] + (str "MATCH (p:Page {id: " (ladybug/format-uuid page-id) "}) " + "SET p.name = " (ladybug/format-string name) ";")) + +(defn- remove-node-attr-statement + "Clear a property. Ladybug has no Neo4j-style REMOVE; SET to NULL." + [table shape-id attr] + (str "MATCH (s:" (nodes/match-label table) " {id: " (ladybug/format-uuid shape-id) "}) " + "SET s." (nodes/cypher-property-key table attr) " = NULL;")) + +(defn- index-add-component! + [index {:keys [id name doc-id]}] + (-> index + (assoc-in [:components id] {:id id :name name :deleted false}) + (update :children update doc-id (fnil conj #{}) id))) + +(defn- index-remove-component! + [index component-id] + (let [doc-id (:doc-id index)] + (-> index + (update :components dissoc component-id) + (update :children update doc-id #(disj (or % #{}) component-id))))) + + +(defn- set-document-revision-statement + "Set the Document's revision number. + + `app.graph.schema.contract` names the column `revision`, not `revn`. The name + is produced by `nodes/cypher-property-key`, so this statement and the DDL + cannot disagree." + [doc-id revn] + (str "MATCH (d:Document {id: " (ladybug/format-uuid doc-id) "}) " + "SET d." (nodes/cypher-property-key "Document" :revn) " = " + (ladybug/format-int revn) ";")) + +(defn- resolve-parent-for-add + [index {:keys [parent-id frame-id page-id]}] + (let [pid (or parent-id frame-id)] + (if (or (nil? pid) (uuid/zero? pid)) + (when page-id + {:parent-id page-id :parent-table "Page"}) + (if-let [shape (get-in index [:shapes pid])] + {:parent-id pid :parent-table (:table shape)} + (when (get-in index [:pages pid]) + {:parent-id pid :parent-table "Page"}))))) + +(defn- index-add-shape! + [index {:keys [id name table parent-id parent-table position page-id + frame-id component-ctx]}] + (-> index + (assoc-in [:shapes id] + {:id id + :name name + :table table + :parent-id parent-id + :parent-table parent-table + :position position + :frame-id frame-id + :component-ctx component-ctx + :page-id page-id}) + (update :children update parent-id (fnil conj #{}) id))) + +(defn- index-remove-shape! + [index shape-id] + (if-let [shape (get-in index [:shapes shape-id])] + (-> index + (update :shapes dissoc shape-id) + (update :children update (:parent-id shape) + #(disj (or % #{}) shape-id)) + (update :children dissoc shape-id)) + index)) + +(defn- index-add-page! + [index {:keys [id name doc-id] page-index :index}] + (-> index + (assoc-in [:pages id] {:id id :name name :index page-index}) + (update :children update doc-id (fnil conj #{}) id))) + +(defn- index-move-shape! + [index shape-id {:keys [parent-id parent-table position page-id frame-id]}] + (let [old-parent (get-in index [:shapes shape-id :parent-id])] + (-> index + (assoc-in [:shapes shape-id :parent-id] parent-id) + (assoc-in [:shapes shape-id :parent-table] parent-table) + (assoc-in [:shapes shape-id :position] position) + (assoc-in [:shapes shape-id :frame-id] frame-id) + (cond-> page-id (assoc-in [:shapes shape-id :page-id] page-id)) + (update :children update old-parent #(disj (or % #{}) shape-id)) + (update :children update parent-id (fnil conj #{}) shape-id)))) + +;; --- the columns that restate parenthood +;; +;; A shape carries `parent_id` and `frame_id`, and a container carries the +;; ordered `shapes` list. All three restate what `IsChildOf` already says, and +;; the cold projection writes them from the file, so this path has to keep +;; them in step or a synced graph stops matching a rebuilt one. + +(defn- shape-parent-id + "The `parent_id` a shape's own column holds. + + A top-level shape's parent in the file is the page's root frame, which the + graph does not materialize, so `IsChildOf` points at the Page while the + column holds `uuid/zero`." + [parent-id parent-table] + (if (= "Page" parent-table) uuid/zero parent-id)) + +(defn- frame-id-under + "The `frame_id` a shape gets when its parent is `parent-id`. + + Penpot's rule, from `app.common.files.changes` `:mov-objects`: the parent + itself when the parent is a Frame, the parent's own frame otherwise." + [index parent-id parent-table] + (cond + (= "Page" parent-table) uuid/zero + (= "Frame" parent-table) parent-id + :else (get-in index [:shapes parent-id :frame-id] uuid/zero))) + +(defn- frame-id-updates + "`[shape-id frame-id]` for a moved shape and everything that follows it. + + A Frame keeps its descendants pointing at itself, so the walk stops there. + Any other shape carries its subtree onto the new frame." + [index shape-id frame-id] + (into [[shape-id frame-id]] + (when (not= "Frame" (get-in index [:shapes shape-id :table])) + (mapcat #(frame-id-updates index % frame-id) + (get-in index [:children shape-id] #{}))))) + +(defn- child-shapes-value + "A container's stored `shapes` list, rebuilt from the index. + + `IsChildOf.position` counts from the last entry of that list + (`app.graph.projection.document/child-shape-ids` reverses it), so reversing the + children ordered by position gives the list back." + [index parent-id] + (->> (get-in index [:children parent-id] #{}) + (sort-by #(get-in index [:shapes % :position] 0)) + reverse + vec)) + +(defn- insert-position + "The graph position the lowest of `k` shapes takes when they are inserted + into a parent that already holds `n-before` children. + + A container's stored `:shapes` list runs bottom to top, and the graph + numbers children in Penpot z-order, so the two run opposite ways. An append + to the stored list, which is what `:add-obj` does without an `:index`, is + therefore position 0 and pushes every sibling up by one. The block occupies + the result and the `k - 1` positions above it, the first shape highest." + [n-before {:keys [index]} after-position] + (cond + (some? after-position) (long after-position) + (some? index) (max 0 (- n-before (long index))) + :else 0)) + +(defn- renumber-siblings + "Shift `parent-id`'s children at or above `from` by `delta`. + + Returns `[index statements]`. `except` names children the caller is placing + itself." + [index parent-id parent-table from delta except] + (reduce + (fn [[idx stmts] child-id] + (let [pos (get-in idx [:shapes child-id :position])] + (if (and (some? pos) (not (contains? except child-id)) (>= (long pos) (long from))) + (let [pos' (+ (long pos) (long delta))] + [(assoc-in idx [:shapes child-id :position] pos') + (conj stmts (set-edge-position-statement + {:from-table (get-in idx [:shapes child-id :table]) + :from-id child-id + :to-table parent-table + :to-id parent-id + :position pos'}))]) + [idx stmts]))) + [index []] + (vec (get-in index [:children parent-id] #{})))) + +(defn- set-children-statements + "Refresh the `shapes` column of every container in `parent-ids`. + + A Page has no such column: its top-level shapes hang off a root frame the + graph never materializes." + [index parent-ids] + (into [] + (comp (distinct) + (keep (fn [parent-id] + (let [table (get-in index [:shapes parent-id :table])] + (when (contains? nodes/container-tables table) + (set-node-attr-statement + table parent-id :shapes + (child-shapes-value index parent-id))))))) + parent-ids)) + +(defn- mov-object-ids + [shapes] + (let [coll (cond + (nil? shapes) [] + (sequential? shapes) shapes + (uuid? shapes) [shapes] + (map? shapes) (if-let [id (or (:id shapes) (get shapes "id"))] + [id] + []) + :else [])] + (into [] + (keep (fn [shape] + (when shape + (if (uuid? shape) shape (:id shape))))) + coll))) + +(defn- detach-shape + "Take `shape-id` out of its current parent and close the gap it leaves. + + Returns `[index statements]`. The edge itself is left alone: the caller + either replaces it or deletes it." + [index shape-id] + (let [{:keys [parent-id parent-table position]} (get-in index [:shapes shape-id]) + index (update-in index [:children parent-id] #(disj (or % #{}) shape-id)) + [index stmts] (renumber-siblings index parent-id parent-table + (inc (long (or position 0))) -1 #{})] + [(assoc-in index [:shapes shape-id :position] nil) stmts])) + +(defn- apply-mov-objects + [index {:keys [shapes page-id] :as change}] + (let [shape-ids (mov-object-ids shapes) + parent (resolve-parent-for-add index + (assoc change + :frame-id (:parent-id change) + :page-id page-id))] + (cond + (empty? shape-ids) + {:index index :statements [] :applied? true} + + (not parent) + {:index index :statements [] :applied? false :reason :missing-parent} + + :else + (let [parent-id (:parent-id parent) + parent-table (:parent-table parent) + page-id' (or page-id + (when (= parent-table "Page") parent-id) + (get-in index [:shapes (first shape-ids) :page-id])) + known (filterv #(get-in index [:shapes %]) shape-ids) + old-parents (mapv #(get-in index [:shapes % :parent-id]) known) + ;; Penpot removes the shapes from wherever they were, then inserts + ;; the block into the target, so the target's width is measured + ;; after the removals. + [index detach-stmts] + (reduce (fn [[idx stmts] shape-id] + (let [[idx' s] (detach-shape idx shape-id)] + [idx' (into stmts s)])) + [index []] + known) + n-before (count (get-in index [:children parent-id] #{})) + after-pos (get-in index [:shapes (:after-shape change) :position]) + lowest (insert-position n-before change after-pos) + k (count known) + [index shift-stmts] + (renumber-siblings index parent-id parent-table lowest k #{})] + (loop [index index + statements (into detach-stmts shift-stmts) + entries (map-indexed vector known)] + (if-let [[offset shape-id] (first entries)] + (let [shape (get-in index [:shapes shape-id]) + position (+ lowest (- k 1 (long offset))) + frame-id (frame-id-under index parent-id parent-table) + frame-writes (frame-id-updates index shape-id frame-id) + edge {:from-table (:table shape) + :from-id shape-id + :to-table parent-table + :to-id parent-id + :position position} + moved? (not= parent-id (:parent-id shape)) + statements (-> statements + (cond-> moved? + (conj (delete-edge-statement + {:from-table (:table shape) + :from-id shape-id + :to-table (:parent-table shape) + :to-id (:parent-id shape)}))) + (conj (if moved? + (create-edge-statement edge) + (set-edge-position-statement edge)))) + ;; The shape's own columns restate the edge, and the frame + ;; follows the whole subtree the shape carries with it. + statements (if-not moved? + statements + (into (conj statements + (set-node-attr-statement + (:table shape) shape-id :parent-id + (shape-parent-id parent-id parent-table))) + (map (fn [[sid fid]] + (set-node-attr-statement + (get-in index [:shapes sid :table]) + sid :frame-id fid))) + frame-writes)) + index (index-move-shape! index shape-id + {:parent-id parent-id + :parent-table parent-table + :position position + :frame-id frame-id + :page-id page-id'}) + index (reduce (fn [idx [sid fid]] + (assoc-in idx [:shapes sid :frame-id] fid)) + index + frame-writes)] + (recur index statements (rest entries))) + {:index index + :statements (into statements + (set-children-statements index (conj old-parents parent-id))) + :applied? true})))))) + +(defn- index-remove-page! + [index page-id] + (let [doc-id (:doc-id index)] + (-> index + (update :pages dissoc page-id) + (update :children update doc-id #(disj (or % #{}) page-id)) + (update :children dissoc page-id)))) + +(defn- mod-attrs-for-table + [table] + (disj (set (nodes/column-keys table)) :id)) + +(defn- apply-add-obj + [index change] + (let [{:keys [id obj page-id]} change + table (shape-table obj)] + (if-not table + {:index index :statements [] :applied? false :reason :unsupported-shape-type} + (let [parent (resolve-parent-for-add index change)] + (if-not parent + {:index index :statements [] :applied? false :reason :missing-parent} + (let [parent-id (:parent-id parent) + parent-table (:parent-table parent) + n-before (count (get-in index [:children parent-id] #{})) + position (insert-position n-before change nil) + [index shift-stmts] + (renumber-siblings index parent-id parent-table position 1 #{}) + ;; The same denormalizations the cold projection performs, so + ;; a live-synced graph and a rebuilt one carry equal columns. + resolved-page-id + (or page-id + (when (= parent-table "Page") parent-id) + (get-in index [:shapes parent-id :page-id])) + parent-ctx (get-in index [:shapes parent-id :component-ctx]) + shape (projection.document/denormalized-shape + (assoc obj :id id) resolved-page-id parent-ctx) + attrs (nodes/project-attrs table shape) + edge {:from-table table + :from-id id + :to-table parent-table + :to-id parent-id + :position position} + stmts (-> shift-stmts + (conj (create-node-statement table attrs)) + (conj (create-edge-statement edge)) + (into (instance-of-statements table id (:component-id attrs)))) + index' (index-add-shape! index + {:id id + :name (:name attrs) + :table table + :parent-id parent-id + :parent-table parent-table + :position position + :frame-id (:frame-id attrs) + :component-ctx (projection.document/descend-component-ctx + table shape parent-ctx) + :page-id resolved-page-id})] + {:index index' + :statements (into stmts (set-children-statements index' [parent-id])) + :applied? true})))))) + +(defn- apply-mod-obj + [index {:keys [id operations]}] + (if-let [shape (get-in index [:shapes id])] + (let [table (:table shape) + syncable (mod-attrs-for-table table) + set-ops (filter #(and (= :set (:type %)) + (contains? syncable (:attr %))) + operations)] + (if (empty? set-ops) + {:index index :statements [] :applied? false :reason :unsupported-operations} + (let [updates (into {} (map (juxt :attr :val) set-ops)) + statements + (into (vec (for [[attr value] updates] + (set-node-attr-statement table id attr value))) + ;; Relink when component-id is among the synced attrs. + (when (contains? updates :component-id) + (instance-of-statements table id (:component-id updates)))) + index' (reduce (fn [idx [attr value]] + (assoc-in idx [:shapes id attr] value)) + index + updates)] + {:index index' + :statements statements + :applied? true}))) + {:index index :statements [] :applied? false :reason :missing-shape})) + +(defn- delete-order-deepest-first + [children root-id] + (letfn [(post-order [id] + (into (mapcat post-order (get children id #{})) + [id]))] + (post-order root-id))) + +(defn- apply-del-obj + [index {:keys [id]}] + (if-let [root (get-in index [:shapes id])] + (let [to-delete (delete-order-deepest-first (:children index) id) + statements + (vec (mapcat (fn [shape-id] + (let [{:keys [table parent-id parent-table]} + (get-in index [:shapes shape-id])] + [(delete-edge-statement + {:from-table table + :from-id shape-id + :to-table parent-table + :to-id parent-id}) + (delete-node-statement table shape-id)])) + to-delete)) + index' (reduce index-remove-shape! index to-delete) + ;; Only the deleted subtree's own parent survives to be renumbered: + ;; every other parent in `to-delete` goes with it. + [index' shift-stmts] + (renumber-siblings index' (:parent-id root) (:parent-table root) + (inc (long (or (:position root) 0))) -1 #{})] + {:index index' + :statements (-> statements + (into shift-stmts) + (into (set-children-statements index' [(:parent-id root)]))) + :applied? true}) + ;; Penpot emits one :del-obj per selected shape; an earlier change in the + ;; same batch may have already removed this node (e.g. parent + child). + {:index index :statements [] :applied? true})) + +(defn- apply-add-page + [index {:keys [id name page]}] + (let [page-id (or id (:id page)) + page (or page {:id page-id :name name}) + page (nodes/project-attrs "Page" {:id page-id + :name (or (:name page) "Page") + :index (count (:pages index))}) + doc-id (:doc-id index) + position (count (:pages index)) + edge {:from-table "Page" + :from-id page-id + :to-table "Document" + :to-id doc-id + :position position}] + {:index (index-add-page! index + {:id page-id + :name (:name page) + :index (:index page) + :doc-id doc-id}) + :statements [(create-node-statement "Page" page) + (create-edge-statement edge)] + :applied? true})) + +(defn- apply-del-page + [index {:keys [id]}] + (if (get-in index [:pages id]) + (let [shape-ids (into #{} + (comp (filter #(= id (get-in index [:shapes % :page-id]))) + (filter #(= "Page" (get-in index [:shapes % :parent-table])))) + (keys (:shapes index))) + del-shapes + (reduce (fn [acc shape-id] + (let [result (apply-del-obj acc {:type :del-obj :id shape-id})] + (if (:applied? result) + (-> acc + (assoc :index (:index result)) + (update :statements into (:statements result))) + acc))) + {:index index :statements []} + shape-ids) + statements + (conj (:statements del-shapes) + (delete-edge-statement {:from-table "Page" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Page" id))] + {:index (-> (:index del-shapes) (index-remove-page! id)) + :statements statements + :applied? true}) + {:index index :statements [] :applied? false :reason :missing-page})) + +(defn- apply-mod-page + [index {:keys [id name]}] + (if (and (string? name) (get-in index [:pages id])) + {:index (assoc-in index [:pages id :name] name) + :statements [(set-page-name-statement id name)] + :applied? true} + {:index index :statements [] :applied? false :reason :unsupported-page-change})) + +(defn- component-syncable-attrs + "Projected Component columns that sync may SET (everything but :id)." + [] + (disj (set (nodes/column-keys "Component")) :id)) + +(defn- component-attrs-from-change + "Build CREATE attrs for `:add-component` (objects are not projected)." + [{:keys [id name path main-instance-id main-instance-page + annotation variant-id variant-properties]}] + (cond-> {:id id + :name (or name "Component") + :path (or path "") + :main-instance-id main-instance-id + :main-instance-page main-instance-page} + (some? annotation) (assoc :annotation annotation) + (some? variant-id) (assoc :variant-id variant-id) + (seq variant-properties) (assoc :variant-properties variant-properties))) + +(defn- apply-add-component + [index {:keys [id] :as change}] + (if (get-in index [:components id]) + {:index index :statements [] :applied? true} + (let [doc-id (:doc-id index) + position (count (:components index)) + attrs (nodes/project-attrs "Component" (component-attrs-from-change change)) + edge {:from-table "Component" + :from-id id + :to-table "Document" + :to-id doc-id + :position position}] + {:index (index-add-component! index + {:id id + :name (:name attrs) + :doc-id doc-id}) + :statements [(create-node-statement "Component" attrs) + (create-edge-statement edge)] + :applied? true}))) + +(defn- apply-mod-component + "Update projected Component attrs from a `:mod-component` change. + + Nil optional values clear the property (Penpot dissocs them). `:objects` + is never projected — shape trees live on pages." + [index {:keys [id] :as change}] + (let [syncable (component-syncable-attrs) + sets (into {} + (keep (fn [[k v]] + (when (and (contains? syncable k) (some? v)) + [k v]))) + (dissoc change :type :id :objects)) + removes (into [] + (keep (fn [[k v]] + (when (and (contains? syncable k) (nil? v)) + k))) + (dissoc change :type :id :objects)) + stmts (into (mapv (fn [[k v]] + (set-node-attr-statement "Component" id k v)) + sets) + (map #(remove-node-attr-statement "Component" id %) removes)) + index' (if (get-in index [:components id]) + (cond-> index + (contains? sets :name) + (assoc-in [:components id :name] (:name sets))) + (assoc-in index [:components id] + {:id id + :name (:name sets) + :deleted false}))] + (if (empty? stmts) + {:index index :statements [] :applied? true} + {:index index' :statements stmts :applied? true}))) + +(defn- apply-del-component + [index {:keys [id skip-undelete?]}] + (cond + (not (get-in index [:components id])) + {:index index :statements [] :applied? true} + + skip-undelete? + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + + :else + {:index (assoc-in index [:components id :deleted] true) + :statements [(set-node-attr-statement "Component" id :deleted true)] + :applied? true})) + +(defn- apply-restore-component + [index {:keys [id page-id]}] + (let [stmts (cond-> [(set-node-attr-statement "Component" id :deleted false)] + page-id + (conj (set-node-attr-statement "Component" id :main-instance-page page-id))) + index (if (get-in index [:components id]) + (-> index + (assoc-in [:components id :deleted] false) + (cond-> page-id + (assoc-in [:components id :main-instance-page] page-id))) + (assoc-in index [:components id] + {:id id :name nil :deleted false}))] + {:index index :statements stmts :applied? true})) + +(defn- apply-purge-component + [index {:keys [id]}] + (if-not (get-in index [:components id]) + ;; Still attempt delete in case the node exists but was not indexed. + {:index index + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true} + {:index (index-remove-component! index id) + :statements [(delete-edge-statement {:from-table "Component" + :from-id id + :to-table "Document" + :to-id (:doc-id index)}) + (delete-node-statement "Component" id)] + :applied? true})) + +(defn- apply-change + [index change] + (case (:type change) + :add-obj (apply-add-obj index change) + :mod-obj (apply-mod-obj index change) + :del-obj (apply-del-obj index change) + :add-page (apply-add-page index change) + :del-page (apply-del-page index change) + :mod-page (apply-mod-page index change) + :mov-objects (apply-mov-objects index change) + :add-component (apply-add-component index change) + :mod-component (apply-mod-component index change) + :del-component (apply-del-component index change) + :restore-component (apply-restore-component index change) + :purge-component (apply-purge-component index change) + {:index index :statements [] :applied? false :reason :unsupported-type})) + +(defn apply-changes! + "Apply Penpot `changes` to an open Ladybug `conn` and return the updated index. + + Returns `{:index ... :revn ... :applied [...] :skipped [...]}`." + [^Connection conn index changes revn] + (when (> (long revn) (:revn index)) + (l/wrn :hint "graph sync revn gap" + :file-id (str (:file-id index)) + :index-revn (:revn index) + :change-revn revn)) + (loop [index index + applied [] + skipped [] + stmts [] + changes (seq changes)] + (if-let [change (first changes)] + (let [{:keys [index statements applied? reason]} + (apply-change index change)] + (recur index + (cond-> applied applied? (conj (:type change))) + (cond-> skipped (not applied?) (conj {:type (:type change) :reason reason})) + (cond-> stmts applied? (into statements)) + (rest changes))) + (let [final-stmts (cond-> stmts + (and (seq applied) (:doc-id index)) + (conj (set-document-revision-statement (:doc-id index) revn))) + index' (if (seq applied) + (assoc index :revn (long revn)) + index)] + (when (seq final-stmts) + (ladybug/exec-on-connection! conn final-stmts)) + {:index index' + :revn (if (seq applied) (long revn) (:revn index')) + :applied applied + :skipped skipped})))) + +(defn supported-change? + [change] + (contains? supported-change-types (:type change))) diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 28687d6ac5..1bbf306b9c 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -16,6 +16,7 @@ [app.common.files.changes :as cfc] [app.common.files.repair :as cfr] [app.common.files.validate :as cfv] + [app.common.json :as json] [app.common.logging :as l] [app.common.pprint :as pp] [app.common.time :as ct] @@ -37,6 +38,7 @@ [app.storage.tmp :as tmp] [app.util.template :as tmpl] [cuerdas.core :as str] + [datoteka.fs :as fs] [datoteka.io :as io] [emoji.core :as emj] [integrant.core :as ig] @@ -61,6 +63,7 @@ ::yres/body (-> (io/resource "app/templates/debug.tmpl") (tmpl/render {:version (:full cf/version) :profile profile + :graph-enabled (contains? cf/flags :graph) :current-clock ct/*clock* :current-offset (if offset (ct/format-duration offset) @@ -334,6 +337,226 @@ "content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}})))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GRAPH (flag: :graph) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; `app.graph.*` resolves at call time, never at the top of this namespace. +;; `app.graph.ladybug` imports `com.ladybugdb.*`, so requiring it links the +;; Ladybug native library into the JVM, and this namespace loads on every +;; backend boot. The routes below are registered only under the `:graph` flag, +;; so with the flag off nothing resolves and no native code loads. + +(defn- graph-export-file + "Path of a freshly projected graph for `file-id`." + [cfg file-id] + (let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!) + {:keys [db-path]} (ingest-file! cfg file-id :skip-stats? true)] + (when-not (fs/exists? db-path) + (ex/raise :type :internal + :code :graph-file-not-found + :hint "graph database file missing after ingest" + :file-id (str file-id) + :db-path db-path)) + db-path)) + +(defn- graph-export-session + "Path of a snapshot of the caller's live in-memory graph for `file-id`." + [profile-id file-id] + (let [session-info (requiring-resolve 'app.graph.debug/session-info) + export-session-database! (requiring-resolve 'app.graph.debug/export-session-database!) + info (session-info profile-id)] + (when-not info + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "no in-memory graph is loaded; load one first, or use source=file")) + (when-not (= file-id (:file-id info)) + (ex/raise :type :validation + :code :graph-session-file-mismatch + :hint "the loaded session holds a different file" + :requested (str file-id) + :loaded (str (:file-id info)))) + (export-session-database! profile-id))) + +(defn graph-export-handler + "Stream a Ladybug `.lbug` database for a file. + + `source=file` (default) projects the file afresh from the database — the + reproducible artifact. `source=session` snapshots the caller's live + in-memory console graph instead, which live-sync may have moved away from a + fresh projection; taking that away to query it elsewhere is the whole point + of asking for it. Synchronous on each request." + [cfg {:keys [params] :as request}] + (let [file-id (some-> params :file-id parse-uuid) + source (or (some-> params :source str/lower) "file")] + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments + :hint "missing file-id")) + (when-not (contains? #{"file" "session"} source) + (ex/raise :type :validation + :code :invalid-arguments + :hint "source must be 'file' or 'session'" + :source source)) + + (let [session? (= "session" source) + db-path (if session? + (graph-export-session (::session/profile-id request) file-id) + (graph-export-file cfg file-id))] + {::yres/status 200 + ;; A session export is a temp file this request owns; deleting it on + ;; close would race the streaming body, so it is left for the OS temp + ;; sweep. A file export is the canonical per-file database and is meant + ;; to persist. + ::yres/body (io/input-stream db-path) + ::yres/headers {"content-type" "application/octet-stream" + "content-disposition" + (str "attachment; filename=" file-id + (when session? "-session") ".lbug")}}))) + +(defn- graph-console-response + [data] + {::yres/status 200 + ::yres/headers {"content-type" "text/html; charset=utf-8" + "x-robots-tag" "noindex"} + ::yres/body (-> (io/resource "app/templates/graph-console.tmpl") + (tmpl/render (assoc data :version (:full cf/version))))}) + +(defn graph-console-handler + [_cfg {:keys [::session/profile-id]}] + (let [console-context (requiring-resolve 'app.graph.debug/console-context)] + (graph-console-response (console-context profile-id)))) + +(defn graph-load-handler + [cfg {:keys [params ::session/profile-id]}] + (let [file-id (some-> (:file-id params) parse-uuid) + load-session! (requiring-resolve 'app.graph.debug/load-session!)] + (when-not file-id + (ex/raise :type :validation + :code :missing-arguments + :hint "missing file-id")) + (load-session! cfg profile-id file-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}})) + +(defn graph-unload-handler + [_cfg {:keys [::session/profile-id]}] + ((requiring-resolve 'app.graph.debug/unload-session!) profile-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}}) + +(defn graph-reload-handler + "Re-ingest the currently loaded file into the in-memory graph session." + [cfg {:keys [::session/profile-id]}] + (let [session-info (requiring-resolve 'app.graph.debug/session-info) + load-session! (requiring-resolve 'app.graph.debug/load-session!)] + (if-let [file-id (some-> (session-info profile-id) :file-id)] + (do + (load-session! cfg profile-id file-id) + {::yres/status 302 + ::yres/headers {"location" "/dbg/graph"}}) + (ex/raise :type :not-found + :code :graph-session-not-loaded + :hint "load a file graph before reloading")))) + +(defn graph-sync-status-handler + [_cfg {:keys [::session/profile-id]}] + (if-let [status ((requiring-resolve 'app.graph.debug/sync-status) profile-id)] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str status {:type :json-verbose})} + {::yres/status 404 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})})) + +(defn graph-data-handler + "Export the in-memory session graph as plain JSON (not transit) for the + G6 graph view embedded in the console page." + [_cfg {:keys [::session/profile-id]}] + (if-let [data ((requiring-resolve 'app.graph.debug/export-graph-data!) profile-id)] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode data)} + {::yres/status 404 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode {:error "no-session"})})) + +(def ^:private sql:graph-files + "select t.id as team_id, t.name as team_name, + p.id as project_id, p.name as project_name, + f.id as file_id, f.name as file_name + from team as t + join team_profile_rel as tpr on (tpr.team_id = t.id) + join project as p on (p.team_id = t.id) + join file as f on (f.project_id = p.id) + where tpr.profile_id = ? + and t.deleted_at is null + and p.deleted_at is null + and f.deleted_at is null + order by t.name, p.name, f.name + limit 500") + +(defn- graph-files-tree + [rows] + (->> (group-by (juxt :team-id :team-name) rows) + (mapv (fn [[[team-id team-name] team-rows]] + {:id (str team-id) + :name team-name + :projects + (->> (group-by (juxt :project-id :project-name) team-rows) + (mapv (fn [[[project-id project-name] project-rows]] + {:id (str project-id) + :name project-name + :files (mapv (fn [{:keys [file-id file-name]}] + {:id (str file-id) :name file-name}) + project-rows)})) + (sort-by :name) + (vec))})) + (sort-by :name) + (vec))) + +(defn graph-files-handler + "List teams -> projects -> files reachable by the current profile, as + plain JSON for the graph console file tree." + [{:keys [::db/pool]} {:keys [::session/profile-id]}] + (let [rows (db/exec! pool [sql:graph-files profile-id])] + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (json/encode {:teams (graph-files-tree rows)})})) + +(defn- json-request? + [request] + (some-> request + (yreq/get-header "accept") + (str/includes? "application/json"))) + +(defn graph-query-handler + [_cfg {:keys [params ::session/profile-id] :as request}] + (let [query (:query params) + query-session! (requiring-resolve 'app.graph.debug/query-session!) + console-context (requiring-resolve 'app.graph.debug/console-context)] + (try + (let [result (query-session! profile-id query)] + (if (json-request? request) + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:query query + :query-result result} + {:type :json-verbose})} + (graph-console-response (console-context profile-id + :query query + :query-result result)))) + (catch Throwable e + (let [error (or (:hint (ex-data e)) (ex-message e))] + (if (json-request? request) + {::yres/status 200 + ::yres/headers {"content-type" "application/json; charset=utf-8"} + ::yres/body (t/encode-str {:query query :error error} + {:type :json-verbose})} + (graph-console-response (console-context profile-id + :query query + :error error)))))))) + (defn import-handler [{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}] (when-not (contains? params :file) @@ -626,7 +849,12 @@ (letfn [(handle-error [cause] (when-let [data (ex-data cause)] (when (= :validation (:type data)) - (str "Error: " (or (:hint data) (ex-message cause)) "\n"))))] + (let [hint (or (:hint data) (ex-message cause)) + explain (ex/explain data)] + (str "Error: " hint + (when (and explain (not (str/includes? hint explain))) + (str "\n" explain)) + "\n")))))] {:name ::errors :compile (fn [& _params] @@ -646,26 +874,51 @@ (assert (db/pool? (::db/pool params)) "expected a valid database pool") (assert (session/manager? (::session/manager params)) "expected a valid session manager")) +(defn- graph-action-routes + [cfg] + [["/graph-export" {:handler (partial graph-export-handler cfg)}] + ["/graph-load" {:handler (partial graph-load-handler cfg)}] + ["/graph-query" {:handler (partial graph-query-handler cfg)}] + ["/graph-unload" {:handler (partial graph-unload-handler cfg)}] + ["/graph-reload" {:handler (partial graph-reload-handler cfg)}] + ["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}] + ["/graph-data" {:handler (partial graph-data-handler cfg)}] + ["/graph-files" {:handler (partial graph-files-handler cfg)}]]) + (defmethod ig/init-key ::routes [_ {:keys [::db/pool] :as cfg}] - [["/readyz" {:handler (partial health-handler cfg)}] - ["/dbg" {:middleware [[session/authz cfg] - [with-authorization pool]]} - ["" {:handler (partial index-handler cfg)}] - ["/health" {:handler (partial health-handler cfg)}] - ["/changelog" {:handler (partial changelog-handler cfg)}] - ["/error/:id" {:handler (partial error-handler cfg)}] - ["/error" {:handler (partial error-list-handler cfg)}] - ["/actions" {:middleware [[errors]]} - ["/set-virtual-clock" - {:handler (partial set-virtual-clock cfg)}] - ["/resend-email-verification" - {:handler (partial resend-email-notification cfg)}] - ["/handle-team-features" - {:handler (partial handle-team-features cfg)}] - ["/file-export" {:handler (partial export-handler cfg)}] - ["/file-import" {:handler (partial import-handler cfg)}] - ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}] - ["/file-validate" {:handler (partial validate-file cfg)}] - ["/file-repair" {:handler (partial repair-file cfg)}]]]]) + ;; The graph routes are registered only under the `:graph` flag. Left + ;; unregistered they 404, and nothing ever resolves `app.graph.*`. The `/dbg` + ;; admin gate is unchanged: it covers the graph routes exactly as before. + (let [graph? (contains? cf/flags :graph) + actions (cond-> ["/actions" {:middleware [[errors]]} + ["/set-virtual-clock" + {:handler (partial set-virtual-clock cfg)}] + ["/resend-email-verification" + {:handler (partial resend-email-notification cfg)}] + ["/handle-team-features" + {:handler (partial handle-team-features cfg)}] + ["/file-export" {:handler (partial export-handler cfg)}] + ["/file-import" {:handler (partial import-handler cfg)}] + ["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}] + ["/file-validate" {:handler (partial validate-file cfg)}] + ["/file-repair" {:handler (partial repair-file cfg)}]] + graph? (into (graph-action-routes cfg))) + dbg (cond-> ["/dbg" {:middleware [[session/authz cfg] + [with-authorization pool]]} + ["" {:handler (partial index-handler cfg)}] + ["/health" {:handler (partial health-handler cfg)}] + ["/changelog" {:handler (partial changelog-handler cfg)}] + ["/error/:id" {:handler (partial error-handler cfg)}] + ["/error" {:handler (partial error-list-handler cfg)}] + actions] + graph? (conj ["/graph" {:handler (partial graph-console-handler cfg)}]))] + (when graph? + ;; With the flag on, the Ladybug native library belongs to this process, + ;; so load it here. A missing or unusable library then fails the boot + ;; instead of the first console request. + (require 'app.graph.debug 'app.graph.ingest)) + + [["/readyz" {:handler (partial health-handler cfg)}] + dbg])) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index f1aadd0a2d..b75a3ee5b3 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -289,6 +289,7 @@ ::http.debug/routes {::db/pool (ig/ref ::db/pool) ::session/manager (ig/ref ::session/manager) + ::mbus/msgbus (ig/ref ::mbus/msgbus) ::sto/storage (ig/ref ::sto/storage) ::setup/props (ig/ref ::setup/props)} diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index c174f29f7a..4014ad8dec 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -398,6 +398,53 @@ (println (sm/humanize-explain explain)) (ex/print-throwable cause)))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; GRAPH / LADYBUG +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; The graph namespaces resolve at call time, never at the top of this +;; namespace. `app.graph.ladybug` imports `com.ladybugdb.*`, and this namespace +;; loads with the REPL server on every boot, so a top-level require would link +;; the Ladybug native library into every backend, graph or not. Calling one of +;; the functions below loads the library at that point: the operator has asked +;; for it explicitly. The `:graph` flag gates the request path +;; (`app.http.debug`), not the REPL. + +(defn graph-smoke-test! + "Execute a basic Ladybug smoke test (CREATE + count). + + Uses the embedded Ladybug Java API. Use :db-path \":memory:\" (default) + or a filesystem path such as /tmp/test.lbug." + [& {:keys [db-path] :or {db-path ":memory:"}}] + ((requiring-resolve 'app.graph.ladybug/smoke-test!) :db-path db-path)) + +(defn graph-query-test! + "Query Document count for a file's graph db (REPL diagnostic)." + [file-id & {:keys [db-path]}] + (let [file-id (h/parse-uuid file-id) + db-path (or db-path ((requiring-resolve 'app.graph.ladybug/db-path-for-file) file-id)) + query-scalar! (requiring-resolve 'app.graph.ladybug/query-scalar!) + stmt "MATCH (n:Document) RETURN count(n) AS Document_c;"] + (query-scalar! db-path stmt))) + +(defn ingest-file-to-graph! + "Project a Penpot file into a per-file Ladybug database. + + Loads and realizes the file from the database, ensures the slice schema, + projects Document/Page/shape nodes, and returns graph stats. + + Options: + - `:db-path` path or `:memory:` + - `:reset-db?` delete any existing db first (default true) + - `:skip-stats?` skip post-ingest MATCH count queries (default false)" + [file-id & opts] + (let [ingest-file! (requiring-resolve 'app.graph.ingest/ingest-file!) + print-ingest! (requiring-resolve 'app.graph.report/print-ingest!) + result (apply ingest-file! sys/system file-id opts)] + (print-ingest! result) + result)) + + (defn repair-file! "Repair the list of errors detected by validation." [file-id & {:keys [rollback?] :or {rollback? true} :as options}] diff --git a/backend/test/backend_tests/graph_binder_gate_test.clj b/backend/test/backend_tests/graph_binder_gate_test.clj new file mode 100644 index 0000000000..508bf9d60d --- /dev/null +++ b/backend/test/backend_tests/graph_binder_gate_test.clj @@ -0,0 +1,165 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.graph-binder-gate-test + "Binder gate for the incremental-sync statement templates. + + Every template `app.graph.sync` emits is *prepared* — parsed and bound by + the engine against the live DDL — and never executed. A parse or bind + failure (a renamed column, a reserved-word label emitted unquoted, a dropped + table) turns the gate red here, before the statement can reach a live + session. + + One instance per template is the gate; per-column type coverage belongs to + beadpot's schema diff, not here. The templates are `defn-`, so they are + reached through their vars." + (:require + [app.graph.ladybug :as ladybug] + [app.graph.schema.nodes :as nodes] + [app.graph.sync] + [clojure.test :as t])) + +(def ^:private create-node-statement #'app.graph.sync/create-node-statement) +(def ^:private delete-node-statement #'app.graph.sync/delete-node-statement) +(def ^:private create-edge-statement #'app.graph.sync/create-edge-statement) +(def ^:private delete-edge-statement #'app.graph.sync/delete-edge-statement) +(def ^:private set-edge-position-statement #'app.graph.sync/set-edge-position-statement) +(def ^:private create-instance-of-statement #'app.graph.sync/create-instance-of-statement) +(def ^:private delete-instance-of-statement #'app.graph.sync/delete-instance-of-statement) +(def ^:private set-node-attr-statement #'app.graph.sync/set-node-attr-statement) +(def ^:private set-page-name-statement #'app.graph.sync/set-page-name-statement) +(def ^:private remove-node-attr-statement #'app.graph.sync/remove-node-attr-statement) +(def ^:private set-document-revision-statement #'app.graph.sync/set-document-revision-statement) + +;; Dummy identities. Fixed rather than generated: a gate failure should read +;; the same on every run. +(def ^:private doc-id #uuid "00000000-0000-0000-0000-0000000000d0") +(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a0") +(def ^:private shape-id #uuid "00000000-0000-0000-0000-0000000000b0") +(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000c0") +(def ^:private component-id #uuid "00000000-0000-0000-0000-0000000000e0") + +(def ^:private child-edge + {:from-table "Rectangle" :from-id shape-id + :to-table "Page" :to-id page-id + :position 3}) + +(def ^:private ^:dynamic *conn* nil) + +(defn- with-graph-connection + "Open a `:memory:` database, create the live schema, run the tests on it. + + Nothing is executed against it — the gate only prepares — but the DDL has to + be there for the binder to resolve tables and columns against." + [next] + (ladybug/with-connection! ":memory:" + (fn [conn] + (ladybug/exec-on-connection! conn (nodes/ddl-statements)) + (binding [*conn* conn] + (next))))) + +(t/use-fixtures :once with-graph-connection) + +(defn- gate + "Assert `statement` binds, and that the engine agrees on read/write." + [label statement read-only?] + (let [result (ladybug/validate-on-connection! *conn* statement)] + (t/is (:ok? result) + (str label " does not bind: " (:error result) "\n " statement)) + (when (:ok? result) + (t/is (= read-only? (:read-only? result)) + (str label " read-only? " (:read-only? result) ", expected " read-only?))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the eleven sync templates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest create-node-binds + (gate "create-node-statement" + (create-node-statement "Rectangle" {:id shape-id + :name "a shape" + :opacity 1.0 + :hidden false}) + false)) + +(t/deftest delete-node-binds + (gate "delete-node-statement" + (delete-node-statement "Rectangle" shape-id) + false)) + +(t/deftest create-edge-binds + (gate "create-edge-statement" + (create-edge-statement child-edge) + false)) + +(t/deftest delete-edge-binds + (gate "delete-edge-statement" + (delete-edge-statement (dissoc child-edge :position)) + false)) + +(t/deftest set-edge-position-binds + (gate "set-edge-position-statement" + (set-edge-position-statement child-edge) + false)) + +(t/deftest create-instance-of-binds + (gate "create-instance-of-statement" + (create-instance-of-statement frame-id component-id) + false)) + +(t/deftest delete-instance-of-binds + (gate "delete-instance-of-statement" + (delete-instance-of-statement frame-id) + false)) + +(t/deftest set-node-attr-binds + (gate "set-node-attr-statement" + (set-node-attr-statement "Rectangle" shape-id :name "a shape") + false)) + +(t/deftest set-page-name-binds + (gate "set-page-name-statement" + (set-page-name-statement page-id "a page") + false)) + +(t/deftest remove-node-attr-binds + (gate "remove-node-attr-statement" + (remove-node-attr-statement "Rectangle" shape-id :name) + false)) + +(t/deftest set-document-revision-binds + (gate "set-document-revision-statement" + (set-document-revision-statement doc-id 42) + false)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; label quoting across the registry +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest every-node-label-binds + ;; `Group` and `Boolean` are reserved words: unquoted they do not parse. + ;; One MATCH per registered table is the cheapest way to keep `match-label` + ;; honest as tables come and go. + (doseq [table (map :table nodes/node-types)] + (gate (str "delete-node-statement on " table) + (delete-node-statement table shape-id) + false))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the gate itself +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest read-only-discriminates + ;; Without this the `read-only? false` assertions above would hold for a + ;; `validate-on-connection!` that always answered false. + (gate "a read query" "MATCH (n:Rectangle) RETURN count(n);" true)) + +(t/deftest bad-statement-is-reported-not-thrown + (let [result (ladybug/validate-on-connection! + *conn* "MATCH (n:Rectangle) SET n.no_such_column = 1;")] + (t/is (false? (:ok? result))) + (t/is (string? (:error result))) + (t/is (nil? (:read-only? result))))) diff --git a/backend/test/backend_tests/graph_sync_parity_test.clj b/backend/test/backend_tests/graph_sync_parity_test.clj new file mode 100644 index 0000000000..c7eff4c34e --- /dev/null +++ b/backend/test/backend_tests/graph_sync_parity_test.clj @@ -0,0 +1,280 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.graph-sync-parity-test + "Cold projection and incremental sync are two implementations of one mapping, + and this namespace holds them to it. + + `app.graph.projection.document/projection-data` reads a whole file and produces + the whole graph. `app.graph.sync/apply-changes!` takes the change vocabulary + the editor emits and mutates an already open graph. A graph the second one + maintained must equal a graph the first one would build from the same file, + or the console shows a graph no rebuild reproduces. + + The round trip: project a file cold into A, apply a change list to A and the + same list to the file data, project the resulting data cold into B, and diff + A against B. Two `:memory:` databases, no Postgres, no session." + (:require + [app.common.features :as ffeat] + [app.common.files.changes :as cfc] + [app.common.time :as ct] + [app.common.types.file :as ctf] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.graph.arrow :as arrow] + [app.graph.ladybug :as ladybug] + [app.graph.projection.document :as projection.document] + [app.graph.projection.transforms :as projection.transforms] + [app.graph.schema.nodes :as nodes] + [app.graph.sync :as sync] + [clojure.test :as t])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the fixture file +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Fixed ids: a failure should read the same on every run. +(def ^:private file-id #uuid "00000000-0000-0000-0000-00000000f11e") +(def ^:private page-id #uuid "00000000-0000-0000-0000-0000000000a1") +(def ^:private page2-id #uuid "00000000-0000-0000-0000-0000000000a2") +(def ^:private frame-id #uuid "00000000-0000-0000-0000-0000000000f1") +(def ^:private rect-id #uuid "00000000-0000-0000-0000-0000000000b1") +(def ^:private circ-id #uuid "00000000-0000-0000-0000-0000000000b2") +(def ^:private text-id #uuid "00000000-0000-0000-0000-0000000000b3") +(def ^:private rect2-id #uuid "00000000-0000-0000-0000-0000000000b4") + +(def ^:private base-revn 1) + +(defn- file-row + "The `file` map the projection reads, as `bfc/get-file` returns it minus the + data blob." + [revn] + {:id file-id + :name "graph sync parity fixture" + :revn revn + :version 70 + :features #{"components/v2"} + :created-at (ct/inst "2026-01-01T00:00:00Z") + :modified-at (ct/inst "2026-01-02T00:00:00Z")}) + +(defn- base-data + [] + (binding [ffeat/*current* #{"components/v2"}] + (ctf/make-file-data file-id page-id))) + +(defn- shape + [id type attrs] + (cts/setup-shape (merge {:id id + :type type + :frame-id uuid/zero + :parent-id uuid/zero} + attrs))) + +(def ^:private changes + "One change of every kind the sync path claims to support that this fixture + can exercise, in the order an editing session would emit them. + + Four siblings in one container, then a reorder, a reparent, and a delete: + sibling order is where the two paths are easiest to get wrong, because the + stored `:shapes` list and `IsChildOf.position` run opposite ways." + [{:type :add-obj :page-id page-id :id frame-id + :parent-id uuid/zero :frame-id uuid/zero + :obj (shape frame-id :frame {:name "Board" :width 400 :height 300})} + + {:type :add-obj :page-id page-id :id rect-id + :parent-id frame-id :frame-id frame-id + :obj (shape rect-id :rect {:name "Rect" :parent-id frame-id :frame-id frame-id + :width 100 :height 50})} + + {:type :add-obj :page-id page-id :id circ-id + :parent-id frame-id :frame-id frame-id + :obj (shape circ-id :circle {:name "Circle" :parent-id frame-id :frame-id frame-id + :width 40 :height 40})} + + {:type :add-obj :page-id page-id :id text-id + :parent-id frame-id :frame-id frame-id + :obj (shape text-id :text {:name "Label" :parent-id frame-id :frame-id frame-id})} + + {:type :add-obj :page-id page-id :id rect2-id + :parent-id frame-id :frame-id frame-id + :obj (shape rect2-id :rect {:name "Rect two" :parent-id frame-id :frame-id frame-id + :width 20 :height 20})} + + ;; A rename, and two attributes whose values are falsy: `blocked false` and + ;; `opacity 0` are values, not absences, on both paths. + {:type :mod-obj :page-id page-id :id rect-id + :operations [{:type :set :attr :name :val "Renamed rect"} + {:type :set :attr :blocked :val false} + {:type :set :attr :opacity :val 0}]} + + ;; Reorder inside the same container: the edge keeps its endpoints and + ;; every sibling it passes has to move. + {:type :mov-objects :page-id page-id :parent-id frame-id :index 0 :shapes [circ-id]} + + ;; Reparent to the page's root frame: the edge moves, and so do the + ;; shape's own `parent_id` and `frame_id`. + {:type :mov-objects :page-id page-id :parent-id uuid/zero :index 0 :shapes [text-id]} + + ;; Delete with survivors: the gap in the sibling numbering has to close. + {:type :del-obj :page-id page-id :id rect-id} + + {:type :add-page :id page2-id :name "Page two"} + {:type :mod-page :id page-id :name "Page one, renamed"}]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; projecting and reading back +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- load-graph! + "Create the schema on `conn`, project `data` into it, run the transforms. + + Returns the projection, which is also what the sync index is built from." + [conn data file] + (let [projection (projection.document/projection-data data file)] + (ladybug/exec-on-connection! conn (nodes/ddl-statements)) + (arrow/with-allocator! + (fn [allocator] (arrow/load-projection! conn projection allocator))) + (projection.transforms/apply-transforms! nil conn data file) + projection)) + +(defn- rel-tables + [conn] + (mapv first (:rows (ladybug/query-on-connection! + conn "CALL show_tables() WHERE type = 'REL' RETURN name;" + :max-rows 1000)))) + +(defn- rel-properties + "Property names on rel table `rel`, in catalog order." + [conn rel] + (mapv (comp str second) + (:rows (ladybug/query-on-connection! + conn (str "CALL table_info('" rel "') RETURN *;") + :max-rows 1000)))) + +(defn- node-rows + [conn table] + (:rows (ladybug/query-on-connection! + conn (str "MATCH (n:" (nodes/match-label table) ") RETURN n.* ORDER BY n.id;") + :max-rows 100000))) + +(defn- edge-rows + [conn rel props] + (let [returns (into ["a.id" "b.id"] (map #(str "r.`" % "`")) props)] + (:rows (ladybug/query-on-connection! + conn (str "MATCH (a)-[r:`" rel "`]->(b) " + "RETURN " (clojure.string/join ", " returns) " " + "ORDER BY a.id, b.id;") + :max-rows 100000)))) + +(defn- keyed-rows + "Rows as `{key {column value}}`, so a difference names a row and a column. + + Values are stringified: both connections hand a value back through the same + reader, so any difference in the strings is a difference in the graph." + [columns key-columns rows] + (into {} + (map (fn [row] + (let [cells (zipmap columns (map str row))] + [(mapv cells key-columns) cells]))) + rows)) + +(defn- snapshot + "Every node row and every edge row in the database, keyed by table." + [conn] + {:nodes (into {} + (map (fn [{:keys [table]}] + (let [columns (nodes/columns table)] + [table (keyed-rows columns ["id"] (node-rows conn table))]))) + nodes/node-types) + :edges (into {} + (map (fn [rel] + (let [columns (into ["from" "to"] (rel-properties conn rel))] + [rel (keyed-rows columns ["from" "to"] + (edge-rows conn rel (rel-properties conn rel)))]))) + (rel-tables conn))}) + +(defn- row-diff + [rows-a rows-b] + (into {} + (for [k (sort (into #{} (concat (keys rows-a) (keys rows-b)))) + :let [a (get rows-a k) + b (get rows-b k)] + :when (not= a b)] + [k (cond + (nil? a) {:only-in :rebuilt} + (nil? b) {:only-in :synced} + :else (into {} + (for [c (sort (into #{} (concat (keys a) (keys b)))) + :when (not= (get a c) (get b c))] + [c {:synced (get a c) :rebuilt (get b c)}])))]))) + +(defn- diff + "Where the two snapshots disagree, down to the row and the column." + [a b] + (into {} + (for [kind [:nodes :edges] + table (sort (into #{} (concat (keys (get a kind)) (keys (get b kind))))) + :let [d (row-diff (get-in a [kind table]) (get-in b [kind table]))] + :when (seq d)] + [[kind table] d]))) + +(defn- with-two-connections + [f] + (ladybug/with-connection! ":memory:" + (fn [conn-a] + (ladybug/with-connection! ":memory:" + (fn [conn-b] + (f conn-a conn-b)))))) + +(defn- round-trip + "Sync `change-list` into A, rebuild the same file into B, return the diff." + [change-list] + (let [data0 (base-data) + data1 (cfc/process-changes data0 change-list) + revn1 (inc base-revn)] + (with-two-connections + (fn [conn-a conn-b] + (let [projection (load-graph! conn-a data0 (file-row base-revn)) + index (sync/build-index file-id base-revn projection) + result (sync/apply-changes! conn-a index change-list revn1)] + (load-graph! conn-b data1 (file-row revn1)) + {:diff (diff (snapshot conn-a) (snapshot conn-b)) + :applied (:applied result) + :skipped (:skipped result)}))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; the tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest every-change-in-the-list-is-supported + (let [{:keys [applied skipped]} (round-trip changes)] + (t/is (empty? skipped) + (str "the fixture must exercise the sync path, not the skip path: " (pr-str skipped))) + (t/is (= (count changes) (count applied))))) + +(t/deftest synced-graph-equals-rebuilt-graph + (let [{:keys [diff]} (round-trip changes)] + (t/is (empty? diff) + (str "cold projection and sync replay disagree on " + (pr-str (keys diff)) "\n" (pr-str diff))))) + +(t/deftest the-diff-catches-an-injected-sync-bug + ;; The round trip is only worth running if it fails when sync is wrong. + ;; `apply-mov-objects` maintains `IsChildOf`; drop the change from the list + ;; sync sees, keep it in the list the file sees, and the edge must differ. + (let [data0 (base-data) + data1 (cfc/process-changes data0 changes) + crippled (remove #(= :mov-objects (:type %)) changes) + revn1 (inc base-revn) + result (with-two-connections + (fn [conn-a conn-b] + (let [projection (load-graph! conn-a data0 (file-row base-revn)) + index (sync/build-index file-id base-revn projection)] + (sync/apply-changes! conn-a index crippled revn1) + (load-graph! conn-b data1 (file-row revn1)) + (diff (snapshot conn-a) (snapshot conn-b)))))] + (t/is (contains? result [:edges "IsChildOf"]) + "a sync that skips a reparent must show up as an IsChildOf difference"))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 376a26df4f..7ef1c0c6df 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -100,6 +100,10 @@ :backend-svgo ;; If enabled, it makes the Google Fonts available. :google-fonts-provider + ;; Enables the Ladybug graph subsystem: the `/dbg` graph console and its + ;; actions. Off by default. With the flag off, `app.graph.*` never loads, + ;; so the Ladybug native library never enters the JVM. + :graph ;; Only for development. :nrepl-server ;; Interactive repl. Only for development. From cdfdbf9adc419093d6be14c2f2abd9683199a7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 28 Aug 2026 09:49:13 +0200 Subject: [PATCH 217/298] :bug: Fix labels for teams navigation and personal files section (#11398) --- frontend/src/app/main/ui/dashboard/sidebar.cljs | 10 +++++----- frontend/translations/en.po | 8 ++++---- frontend/translations/es.po | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index ed39edaaf1..3036dad42c 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -371,8 +371,8 @@ [:span {:class (stl/css :my-teams-icon)} [:> raw-svg* {:id penpot-logo-icon-subtle}]] [:span {:class (stl/css :team-text) - :title (tr "dashboard.my-teams")} - (tr "dashboard.my-teams")] + :title (tr "dashboard.other-teams")} + (tr "dashboard.other-teams")] (when (= default-team-id (:default-team-id organization)) tick-icon)] (when (seq organizations) @@ -438,7 +438,7 @@ (when-not (contains? cf/flags :admin-console) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.my-files") (tr "dashboard.your-penpot"))] + [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.personal-projects") (tr "dashboard.your-penpot"))] (when (= default-team-id (:id team)) tick-icon)] @@ -835,7 +835,7 @@ [:span {:class (stl/css :my-teams-icon-xxxl)} [:> raw-svg* {:id penpot-logo-icon-subtle}]] [:span {:class (stl/css :team-text)} - (tr "dashboard.my-teams")]] + (tr "dashboard.other-teams")]] [:* [:> organization-avatar* {:organization current-organization :size "xxxl"}] [:span {:class (stl/css :team-text)} @@ -972,7 +972,7 @@ :team-name-no-logo nitrate?)} (when-not nitrate? [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.my-files") (tr "dashboard.default-team-name"))]] + [:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.personal-projects") (tr "dashboard.default-team-name"))]] (and (contains? cf/flags :subscriptions) (not is-default?) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index d4b5f28d2c..50bf6b331e 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -875,8 +875,8 @@ msgid "dashboard.move-to-other-team" msgstr "Move to other team" #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 -msgid "dashboard.my-teams" -msgstr "My Teams" +msgid "dashboard.other-teams" +msgstr "Other teams" #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 msgid "dashboard.new-file" @@ -1440,8 +1440,8 @@ msgstr "Your name" msgid "dashboard.your-penpot" msgstr "Your Penpot" -msgid "dashboard.my-files" -msgstr "My Files" +msgid "dashboard.personal-projects" +msgstr "Personal Projects" #: src/app/main/ui/alert.cljs:36 msgid "ds.alert-ok" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index d9421628a4..4ff509285e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -879,8 +879,8 @@ msgid "dashboard.move-to-other-team" msgstr "Mover a otro equipo" #: src/app/main/ui/dashboard/sidebar.cljs:348, src/app/main/ui/dashboard/sidebar.cljs:349, src/app/main/ui/dashboard/sidebar.cljs:761 -msgid "dashboard.my-teams" -msgstr "Mis Equipos" +msgid "dashboard.other-teams" +msgstr "Otros equipos" #: src/app/main/ui/dashboard/files.cljs:106, src/app/main/ui/dashboard/projects.cljs:253, src/app/main/ui/dashboard/projects.cljs:254 msgid "dashboard.new-file" @@ -1447,8 +1447,8 @@ msgstr "Tu nombre" msgid "dashboard.your-penpot" msgstr "Tu Penpot" -msgid "dashboard.my-files" -msgstr "Mis Archivos" +msgid "dashboard.personal-projects" +msgstr "Proyectos Personales" #: src/app/main/ui/alert.cljs:36 msgid "ds.alert-ok" From 18ebf5a92d6eb2752b010b5e69861a82b9c3fad9 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 28 Aug 2026 10:40:25 +0200 Subject: [PATCH 218/298] :books: Add highlights file (#11406) --- .gitignore | 1 + HIGHLIGHTS.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 HIGHLIGHTS.md diff --git a/.gitignore b/.gitignore index 382b89c92f..ec067552ff 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ opencode.json !AGENTS.md !CODE_OF_CONDUCT.md !SECURITY.md +!HIGHLIGHTS.md /*.png /*.svg /*.sql diff --git a/HIGHLIGHTS.md b/HIGHLIGHTS.md new file mode 100644 index 0000000000..e66b4b4497 --- /dev/null +++ b/HIGHLIGHTS.md @@ -0,0 +1,26 @@ +# HIGHLIGHTS + +## 2.17.0 + +- Background blur is here +- WebGL rendering gets stronger +- MCP connection status and more +- Design tokens: more visible, more user-friendly + + +## 2.16.0 + +- Design tokens in the design panel +- Major community contributions +- WebGL rendering (beta) + + +## 2.15.0 + +- AI connected to real design context +- Multi-directional workflow +- Your stack, your model, your decision + + + + From eb0c51ed7440a8f55bb8531836db25070d242bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 28 Aug 2026 10:41:25 +0200 Subject: [PATCH 219/298] :books: Rename personal space label from Your Penpot to Personal Projects (#11399) --- docs/img/teams/team-selector-projects.webp | Bin 10182 -> 6850 bytes docs/img/teams/team-selector.webp | Bin 2484 -> 1256 bytes .../account-teams/projects-files.njk | 2 +- docs/user-guide/account-teams/teams.njk | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/img/teams/team-selector-projects.webp b/docs/img/teams/team-selector-projects.webp index e1696680ec94df5c4e26675fd21e40f2e50b1581..c7ba762604210316ba1bac82687a5a1f1206db3c 100644 GIT binary patch literal 6850 zcmV;z8a?GwNk&Gx8UO%SMM6+kP&gp28UO$gr~sV-D!T#Z0X~sLo=hd9BBG)4nEL8p&dbBv@0Dfl>0VX;d;L$-PpUsK57eHOK4E`ee_XxWy%T?X^#J~> z{a38F&@0Pl*)#V?_D9-pR$I^C-1}$$3;9*&|Kp!D{sa3b?XM-?vhzFnN7(<(|84fg z{Ga=;2YyTci|PycFZ6HnKX87i`z!XJ^&j@VK)r_h2mF5wPw#yk`xXBS+ynW4^?&-j zeBO!d4gClF*X+-`SNH$=|J?dT`m6tE`cHum*RT6Q;lJr}Hc?G(f8#0Xf2?v!9gBSP)C#$5#>)!hw+78TD1Bv{TZL&F-O5^ zATSg(XfS@NYQ}^IqMRsb1S&B3wrnXId4F%T!ZH@&wEZrX=_(o7PN#vy+r#2quOy6m zCMe!q7P@r^$KMrEj46ST8(e#?=*jBiiG)D`k)#%($9>^P3|tG}>C_rq4yY8hBLrc$ zVX079;a*dkd`&9wJG#-&!ko*%R~nX<1JrfsYx2T=8Zl9mr>3)!#>ibHe>JF0pW6sm zHR*f@nRnGNbtWJpu7F zqrsa?l1TVwruue8RC|koyWOaGtehLa%6ol8OECHAb-ZM!8FR_PBAD(8fk)`5jQ_NL zGs)6y1`U{|m2p&!$qiK;kSX_s(@TUquXzscUksy@q}5gT{VqnzDXq{)>2fv7HB?d@ z9zFXHq&PQy4ARqY4~eB*b&P+GjJpINd`BA@R?j&ABfmyAUGkc2@O_&c4z5>+1gN$j zOcW921q6Ap3Cm)>BMQ|JAA-kN$GnGi?;+oZAG4tyQ%)=hf}L(AEQ+Y{ZI!e>>)u1U z_mJs!_D|roSULuOezS;c`VV>!r!K~73Zr$BL=;Feqy~h{D3Y83-MRtR8(D)@!~5MI z?PjEQ)|bfHv}vUsMKDlDl$(#vbPFOF`&Y9b+E^jJIo{d!{+jS(9m8-v$@Fhjr>q&WK)7 z-Ykr4@S!`0^0i~$L%R2n?!E}pkd#m=gz2EmcD0KJB8{zoDX*mp;_s5ExM;d0@Is!J zG4CPWd&qx}FjKZkB_LqIxLe^3&wcES`O7-r0Ve?bPp$(IMDA7XV#fT?I8i!TjoW!&^|0x1#q4!`wA@q#b zpphWm-?y_rW@vraz~fsF0Iv@|CLX%W2s!eaAY9|K% z+2C$42+q;5Unv0Vy4)kGGj-W~fJ6H*6Mlq7*_ef2tr4Xe@iuWcK>d**QS~X}T6hbN z6s6Yyxq|~Z4F;zpoZ{tO7fkz9VujV~_F6CM&LRxO10v~tr|;|+DwfMZB9sDz`)@XiA>p@pLW&bV2j z;FS}A*6o>o<%d`;_k^-KT9#pTY_Z04^78)cIz zv|HmQJsC9SH@eijxU+?P7x&}TLfnj8$g-k0Y;WEUpYTQfn35bIM%?qEyVNb_1rglL zI#^#9Pn>6@z@LG(RN{>>>=3Mz{|xiyUiC~V zRPrOj?gY>Ag-;7v%c{ZXS5FDDcp5E@B@3o3>2OuF!MC3ljdsHa0)2-%5E$hF+lo)FM|MujMopU2K|ENAqA*>5xB9n!)5D03-fWmD zsfOFHmS0uDCYmWt1A&HReM^!p;6#W z-`0N8JtBA#r{EOhAC9FO_uETOTT7G|ECrCFB0InNSPYFA0(1u)Fq;m0;K7y|FP^>owVf>Ma z?nd4Zj7SpQ=XU-A7qxYYMZh1b!ZD#?b|S~6phs0lS?P&sDUSy6B*wbZgTyTZ{F0IV z?|(OO8v5K4jfpfh)oF+*5m6hT7Gb!aZgS_X7CN`)j_Y@3t4{1~Rg@S%f43HmRsPA= zWktnQW|E{|?P2S8)aFhP=nsRMjyx;(Ul(dX_UlZx1I)FCOFs&@bz#}IwnNPwjd*NU zBDwMH{bbKx!FLU|vjWXqc!J?7WNV6ez4MNztzc_i%U%NOEKJZO*w=>U&E${MCJu}i zSa38OHe*yOe~#AG`;$I+5)&}_kJbd0Yj1H}msjh(Y(=F}+>6gakn0TJ4&R`_HvFJU z>KXsP=4xrgCe~Yty_q%Lxmiap8^R1KpdS@&w|6u18@bCbKA|3CMLxmz6M8>XHIs)0 zS7FZl!BF<{ULm7tVi}$t2ksiHgF&P797B`%TFba8$oLx2Ot!U+%j!+=!JO1K3AblC zR)-!xnC1~ve9z7{(Ln8hI2$_F{cbf#)5;h|^wPcG)(TcWNBik!mh}#|EGU633E7sX z_D4TSK=ZF>_Vig-LpB!dJXnKq5(R{m9&3Iqm8RZd?5M3T{_Fi7 zoIQ_Ca*}35U59pOTt>e57>sJ!#lzBdUui{l3Nn1{chbaNfd^&+3K_%bt8&|Z)hX26 z3;O7#Q6M?~mQ?4Xjv%z%HbyJ_R4BQCfpo)m`4KN!0+W#&^Mf>_JT&5ydLY(oP!TAA zjbEKqJpz~-hy$tq_V%V`5ZuIQ7c)yV=3gr!A{PwH5#&vI9ow1@qX5Nqio^kwqTX+P zP4t=*0)3&hm%!ze#Wo<^ar}pnS#yAQhL9 z_Jw&Gd5u0NVIOzKv6xJX>z1Z%rL`L-Ecdzp40t#%P~p}aYoLv*(?{p}9lu@z(r@Oo zdQYdt(kHCUjFl`B(hNlV^zDmgtv_)^nM&meMINgr6XvN2wTg$=9NAfjnSj$2%fh=N znq&Bnw7I0Y?o%CzpTP<ftFBe8|~lfU$VMz2|d!8LIXrSO#VmbQle*z!1?jg&fSdQ`Bq?MhB6DD2V9VN`;6@ zwY<@qqB_^tJ6mPjI}56`WKD1_siG8-H^qp+ohF8}c>l*Ep7ARje>6AcZMuDhfbV}x z-qDVFKIgPsn(r6Ua2FPe(b56$@cH+IThJ_v?*-IxTrOf~!9wX|OM1Qh&3R=LJ{DPA zIp1UuA4-2QAoAm2s+0>{7b@1DlEIc&b#YG}JR?R@#|Qw5()QO&P(&^Ruh7HoviDZA za^>>}KSa=D{vOmB=S7OY@$;t+cUy6kZP~Rux@FnSmrqm^0jl(t#YR~bd&hg5ZBgA% z83k#SltXNH!A&<~Z+Vyr=~#HVa)|V~8Q%Thl5!nK!Yj~F25GKo_e0fy;xV>wP#bxF z`VoI&RC;oxH0q6}>u9{qqg@kWvp1#DWJM(`j$qFRCG1$68t->kld6@U{ufEO4qqhS`%)PnM5IphUc) zayp5P#dg=IhRA0t40%$Pf7NNbE#g6zf7$g_wUvTszg55uMa8`qVXt9ykH(Ny{!Tip zWW3?Qf}oieVpOn2tItC#UE5RI^uiC6e5ByNVxlQV56tVTM^jJ9a zpU3ZPnpc*AN1@j8p4*A4OL49sUHI+uS+Z^YW$bwZC^fve4u=86>}uSM+Gastyjobi z1}C%X$65%DA+~*9XX@t{DRsWsx($9+SIbGb^v!t(;wW zbzQa9^KJ25hKYb(7=7VX^%Gu{X=b~|nmoeQ6^fuY2LwbN4c3gcTbO_VYhL+j(UcyEH^e1*h?#_YG9Bw328mA6!+oRlUT= z(#toMi%ba1CYMw+HT36`NNZ)R1RLs)NdhC2O{i7{Nsm+`T?TUDVgIJ0@(VVwafCr) zD7d@dM2zB2E7aF3yF;oFl#GMsh*O?U(m*jN|+t3`TP z?femg(I!mNllQjZ=K|TLsDnpC7d>9Ga94SI|Jy|f zouwm3w#K{62B^bUow5@H9thW-wOejYHa@;bY8xBh&n!Ip6dW>?w@Uryz?yxHk*g(^ zivxig8^mN1I4ZZ3b-Y~2Ia9D^+$0G?IQtwdIBs|Qls`uxyn3_1o{}Cq3+Z_P4UK&k zvXo&7`FmfniZC&zMG{~|XqKh#$Y=@FjPKvkaWyfU-%$UqmFDD)ARw*Etd7i&f4_$6 zen)JbNGZ)Mor&hfQxa?uN`rbmxrMNPpYf}%!d#v`0*cZZk9mzI7n^8R)KX+iV;?0!6ZB?lJ8- zE1D>invM%VXNWpjL5(BixDk`%4`;w2O*mzcjD{~sLo20e(}vsD{Yn){rJ-wMkQ*30 zUIyiC*|B|6=UlN_<@i1IB(m!(IY#cit7ji&B8AfU(C}`Cy=FQZ1CAlm81eVg6+Mz3 z-!jA}7V%kA;TElOy{+TJ8MyjM-X}m-A3_pYd(GQ5s4Cu$N5{fg(xOAEL8gVRJLhma z{b{b`M2w0ZzzLgY+CK$3~@bx$oZEdzH|TXt||P$ zOmP6~H~{LoGlYrTYIpZV)haeZ+Tq|_piAx;0(VHA^l=l*-(_vUDzCtobNuSJ6y=75 zrt}9VzQYts2V}q~6A2(ug6uZF*p=vT8SBESZCFHY)`tkc9s)i2yiAfUNKwgh0*Jh$ z$x0rCFR%F(6MB<SGW%V z3iZ4UjK!QrupPsteVd}C%p=n?3Vu##0z(cm6wA1aRoW zt@Pz%p?%Q`DA;k)f8?^TW6X>SpXUx4E3*g|H(c&MN&!)ro>*QAO+3ey>Ud-hYbEeR z&aSSg@^x*&D_Potp0bAls~vHcX2Nu>vlIa|X5<)GAaXWyf@qwv5o=C?$f2IvhMCN) z39Iif05hX<*$*^V8BD^)LHqkKnfAC-DO`&xWs$C|!*OhT%0{eR67{BC-sX!4CNOD= zu`aFTyB5kMLVVKfq!`IWS{r|^g?xdz!k+!Hdz0FUqCBNJ?x}08nHjp;nwLlq$Hi-Z z1iOQJ(gz&;<1`pagSGJF_l$449_~$liqW`J;LIidQchR7YyKntI<#EsD#UP}e+p;* zE}Z;DO#t3y9jRMsQLlUvqb^!>Zt34?U~*8+$#ed`{KQE2;_j`x4vt-?rD{OI??ZMI zeY>z=z-4*f7Cjg9du<(oo}21Kb-c+DJ=G;*j=FF(GX3LnO82o1ks>@A#YZ}|+#-iMICfDv2 z!pcDW*03M3t0}2_-G8|Okj5e#{33~V8)!F#0scdTUgVOtlua&_Ugv+JYHptVc~&qq_o0yfvFoO79Q zS1d(=ECiKA48+g~k5zZ5HV?pvk}=Ik1afs)ADJ{6bQg#4;`v6}R4Y@6mZPXq{QQ8i zKgeA76Zg?%o2e8}3)*$laX4`r=ZAGhz6uDCRubi2J^@GdB)s#054oQBOEKaKhCs0L wo)h!vtw4MZOCfVKb3tCx%q2{SXqjc=eOLTokU~%@Cg_Ft5ognOq5uE@0Qvl0=l}o! literal 10182 zcmaKSb95%pw)Pv_oM2*OVrybg?1^pLw(W^++t$RkZ71)|@0_#Nxqsa6t6F>aQ@ftk zwfC;-?&{U8AR#K+MF9Y)iU`W7$Z@E_0ssKQFR2Fs?1BI!goNZqK)#v)kdc2hI0FD+ zW$kFMASOttrmjH$DMe=`36Mi@gQ2ZJxl^_P&^ ze+~ZS6Z#iMH~BZF{0Hm*8}s~wT^wy4zc_OLV0$G6p)YLsg(*$`7uNq@*ud8QAAaN) zhsVm&>7TLwDgQ(VZ)B~k_;rN-51_1gm!~|dfFap@VW|(k>3;>920%U)2YW}oq&bQ^w5`h);2&OttO-kU3Yo(X`Tdt2K+Yd*jyh)=~& zDIoB(+O7LNrws_rzr%m+z5F)p(+*rY2LT?!2^g#o*Upv34zVYkw z9{`v5Pkw)-YAA)aW4d+H;Q1SxMUw2+PWHY1c)_=1;DY4_`n0vrU8OwEz6jg=LcdW;7Dosos+llyI$^i_0mkoT$&NXtNv==*eV-RByxeMm@NjFYBn zW13R=N((&kK-Y68e1;1QXlY`GAEbe98BvRd9BVX=ud z#-QGYq6cwe+U(}Ff$a?R`((^{*Rr*mMP4< zKm@6iTo06d0Vg%>C3@g77*nTED6RRK9=}9TY4(E2)jogf(gFD%#DO1bU``!0*g;Xj zV}B1SM3GL8MT*Z|eYoQ3Z`szLySkEvBMJ6bpH3yoDM*0{2me_I^U|;n=C8U09+pIb zVz-sxBmO!eL9-3m^?YhtU00M#S9;0&ytYb3hn;9xT#GXM@?TL=;a84=7&AwVF2p&- zc3>>WZooXThS^u&_n{zhXKVJMg&lu8KuR`-O4Bl>wnTX@sUtsLF`mrbN9%6*`y`~? zCG@MZ7OX}&iDKc)8<#V-3Wik-gW<`fZe8D-a4OAzeEUiZ9Yft7hkj8dN!jqg`_WV(!%kfA<|oSijc3h87>96>wIDt7DQNrCBNbmGi#i|GYGppl9cF}JJ;Jic?K zE%ZG3a()NwyMo1SBZ~bCx=5`VX*@#&>7U1KCqu59-nT(vr7qJE{r%w%^SAezck(wP zKa&7p{8h(pB=4%$ABAzsN|_s`x6X*ku3|n^@ax%UulZvEpGqd2>SU1`sQs}OZLv=> zJ6O6YQTovtC2gCM{vycp2EQyxLJbm2w6N>#1j<~JIT@LZX${jMuoii1Kj=mEL%xXo zYSN}fPmHTFWh?a@P0W5k$f0{EXWu1LiZ{;}VprD1)tar+@vzaCK~6;q&1mtikltX( zZP5ZPfNfPrU}R}sr$iw2HP;|~EA{@svfmXQt0jKG>j6v$;8z%|Tp+RJF(i?dy`%cQ zd{Djl=q3fVaTHCjGXTZQ*S;M1G2)cZs2&OAJ0a+$Y;u0&%JE8E+FoXCLTqTFZ!-|E zE*}W)qc+*1?9S9;^{q4DR4&Z$dHo6bM|y=%?*VWDdv=~j>q`Ni~kSEBpk?V4N zNnrN%IJ=o@gKMV&ohdC2;Re3t5l8iFH)(gKo%byN!iDws<{1jMnnd>T<=yD~%fm*) z*Y@b)Gc~{1wU!uQnIG)GFI!9p%aBmf83qtTf$Th^pZR1=HUDE=8VF~Td}7m%@v)`W zTO==Ap+I((^jV_8<39gU|-U#hJSs6@-ix ziqhn#6dYg3>F_$(pAsno_Ek=C^yGu#t$1{jWhAwc$x=Vjo=jpH1HTkR?s*)oVDBqW zQ?yBuhCH)?n?{P{!I0Y5K1p(e+qFcs`D>%Dv*Xao;P`3V*=)Nck{(m^C`K`vk)YkNApfXjzYUqG8ihLqp-vyWZeOFasw1?Tf^gJg_lFsi@nJ2Hb?B z%d!FPYXzdMCB_s9lQQJ9ev0+M2UV#o zkX>0U;XupeGZo~cym=O1#6#a3))I#K(=6Tis2Fb!7BtOA$@zvh%_)_{?v3!i9 zos$OHX{jI%Db&SpS>(i$8rEA|;t<0wV+?ge*?JJSZz0s@RaP2Gl&AQFvlxCxvF zHCrjQwPrL^cNp!F$0B$=j10jd$R%12SJ>3bqKpz$3V{JnacmI6>>DD##VcGORN3+? z^r^87kdE;70c(mNqPeBe0*+hOaTRlX>32X*xgz7!f|Uva!EHBF#u3su`p)D%wTT&BSkEci&is+GDdI?Lw&-J6Y2(4H=zo=(=sM z*TKjurq>&p7l|~PDSTTW1SP+s7 z{n#A1v^zNtHa^R@uz2z3%pV;lJ44&0pY^R+oBEwpjyN}}zZB=O#J8Ty^~NL*`_B=; zO>m=F7}@n!@G%*;xAjRXbkWK^^{#{2jN(-OEdAFt&qu&on^&18c^S&ZlG&Xudb zL!KW{aC<4mx{MfPr)lSzks+{j3jY~5Kv@zU@g z6cF?sFcFDu=WZsI^AAMK!=79Fi|O9xdIuf%4%?$vn~G7uX?eZ~PcEPUl{7^X`E)c0 z#jA+d92sALzrZZVWq?uEcDa~*F*xuZ*5=_PgtqYV25}ucOmg#WAzCc`ly@u5wdfWD z$ot@>@SY;uxS>qNfzDb*dFUoKgV_|>alA$r-#N~S?&BN5oM47x8FIyWH|LL+Ras<@ z-<4lwO>v%3T!RQaUTORhCRJuWxkt*d>`_kckdlC6Kh3-a=SBdd6 zHv9SsM%-{c26mBHnHofq*%>nIAnbooUm^kyp_>jP3JuP8Xa6YmKypq*z(L%T2i*L2 z$C@x@mwk2>w}Td<#W*a-4ykjTT117@DS6wOsY;)paurz^9;XL)?ABG~M13WkNjON< zl9aUt$n6Az8yW~V_38nJ zVnM31DKC=#Nr-EovGkYj8h-?%oFCyYxK2YJ)?5fd(#CU<&u;P3=nXrTk@P&~7TR=U z@QO=*C3JIpaYo@KHI<${q=br|PFxrHq1ZA;oz!{E+ICpUmS`wv=P(QkVjQR0VGbO+ zG5AH|dr04R>QI&^7ry+*jpvg~ns%~b{@yIu?-5mDB-@-E2UcPX9mGhmC<>pb_#=aB ze3I1F!k^E3`^myfh|c$gs}ZV4V^t+!*_%m!ITN1m!E;IHn{O{+80}q7V`;UpN@898 zN&kLdKZBlUlpBz2f35iRcg6>6h{@apTPYlh>vG((@=MgcqD6B&$GZgSz7TR*kooPC zbWItx;V9~T$1z=3$51e}|F%89IV6DN&f{Nt^&LD=>P- z6rzXpx;;2?_tPBh)o(PFomWK+aAF^PQ13s{MwLd^7;TT*cm%+3i|!CRYI%^ajwePe zIKU{#|DeCztCsJxhBRJ3g-veGUaK*l8F*5Wu2;jY1&gK=>OybA1`m0RO_aF_?-Yly`ByHN!^FzR5}C8VN{j#oup-0gnXsT7^#!Oe1&MN-$jYECix78hc(hE zvfhN(oQ^(h8}9`!X)A{KCRcbj;KrEe;33n9QfQ*?Z1=04b- z6jR?4;lv00A(ba1rYD&3Qx^bFx2Z-5g6!9}kFo_TnnQC_Xe61> z-jC;{wyrb6Xxc=UCBju6!1d<_6AV>*C9AAIpGpWnTYiqrp|?&^`R%fu4C!{l7^T8< zot;<^Z>W4F8B)li3o`v+u@dG`aLI3j=2y^O>2CRC7by7=)N7)vP9&^-8}X4(r7qZ_ z{yIHp_Qa2&b-6PNJqw=kbE?zobRKL}@MJe!{Jq&eBDJ_GF9!sBSWRW4Hl~gb;#(qk zH_-1nv<-Rp*{AQanNsZ?V$JNz#syFYo}H134-z?^1kV%I-wlx{gC-*k8^yOZob#Yn zodt))p<^*TJmg}?P;r@VasnuCDwN*(#*i(K?oL`(v3{sj{A#&I^w!z zXn#}jwNIm6-LWVOWtarG+HjkqE3&Z@osxYju%JB0<<6pGHb@neDhp6&x3L6mBo@{iN}~Df(7%6-Ml?1p^$#!PSW-)D(|Vv6iwnFE&6&Z)2?7 ztsHSejzSvGw)gx?=TYa=C+gg}{O3l>clbxRT@yTM9x1wo8BCF;?sr*>OvlCdypqAY7UZE%mGvBCH7*ZOMM0uDhp2_Z~V^w4TNp{wU4NW?aY3=yavJkBA^)@M9kl_q*fv{D$I*1NioCL+_R?P;@cCbUoQ7 z$eSjP<}7>1^U$TVqwU_G2p-1elpABt@UMXioE#y5-20bZw1F*9l5(@x82n2=p8cq>bp0*Fg?_(uTX>;(fff zd5?YZ)X1YjXlc=-Z-`BAVdAPEPyowJyvb+N-bpjCXHZxa_*33}T7>NPVnz~14Oyac zw+RwB_H~nrEi=W4_X-@10sJLOUS>1tA6b)fur~@5%kl(-mi6b*>P*a~lGrypi9!@L zjgS&y5(8TauLp|SaZVbRZycH^a7PdGK7OT<)TCPTOPkG~o|YmfGH|_f^Dzq`qzyZd z=_y>gZwHYKh?99pPjC=eygwgsKc6LgvCaSZGn!#ZoB!D;qF^%kCD2m zRM|!MJ7qpW4G#yjG1#0FN*xTSJe>(sGMc6?Fw0w6y^#Mp0ll7OFCb+2%?QavqH&^F zQVa9?A%1$Up0f2^pIGvwJ?}_RZ2bGQ6}T0_7y4>d zg^{H!k&%_%Bv&7)s}khfQO2CFi+4t1jWJYm7#@b%Xh~XpNNOe+M1d-gssz4sNx%hW z2D_+8zBp`Zgr~~d{1}BaQolwn0#mLY+eRU#uKV;OWPnwX;3*w866hbG$ILemOZEB= zXU|c^4SpsiL+_UrEB%tBBYu`up}1(!Lh} zy62K2T9eCrqGaMNia7sMtI0x*YlX2W8hQ!dc!fbID8tv3V zSe~hjqHwEhCkw>S8`*he)l73|F9g+SyGs?D0F2KL4& z#KH=5e{V2CFx|{Zu^zYPoS?%=Bw85X*Y_*!ljZK$=1ew>=Q+G$M25`-c?=(Fy3%hc z$^7uAFC(kQWjjkDLJmo+y@%#j0goBx>TrGv(njEWvMZD}Ul(Bf#sRV`^=_&fVz3BnljbH8mH^X}|mfsiVy zlgLt=8X*TirNnc|By{8sA0AI*rbXSUHnGm>PdE!>b#S2d4L<=2J9q&4nhh|$1;SY= zPBD3Wp&i>t_nwNh z@{}K5MVG=@dWBJ`&qVOY`N_iFjI&bdnGmu3$}2PSJyc(JC0~Q_Pl7-qeuSKwC5#lH zx^M@*x`P7;3^#ySr39&s|>9A)Q}ZnR{exY4Lh%?KlWpdmb% zwmN5eMbN;y$lRLOQ|F{!R9X>z!xO68T4DVN|6q|tk8&3(_8>NAu1^Pyg4(3gFzH38 zycKqN*#3&LoS`%iiQ}mtuKcyt|Mc*pr9Nu)-bl|_Z>xuqXvWF>Tda9(W}Y4a z9F2TZ_H@TI+=O8OA>%A0fn%#E%;K@eL~Vg;0%6+C3;aAP%ouOox#2OPZSmjE|EV6TBuBEn(`|=NT>O7tHu{ot%=Zv85^8*G=?Dmc^ zk6e?+7d=J^P~tt8_m!%q_W6+x+1?%!^tKQn4>qE`p3-ZH)u$Jb%W4L*`tm2NOkkI_ zBNePgFwrN-4rnBXEvVYM{9c|yt}g%t+^<>Q;X3~Cay5;~Np0;$p$8EFKTg~r#Gjf| zP345C!`QsNcA@tN8YSI16!j+?NWvBN2q(y{b%6Tj!gxIf?q9X);KTO79}=nhK{dZ6 z)>7Q5t>#K8ob_l%Zwt*Tk@IKbDGEK=l0Ai7BU2@0%$>&?NtnOiZjX@AAfD;J9Q4MD zs^BjU-i-$kMpkfFNVNAT>XQf0!aK)FmdOzn@<7IbVySLxa}`Wg@Q$>dlX%4?70;<$ zvji@L?SD3l^tBmt%YXW#xx4GWhE)q zp_^o$Erd}3}@R*Q%;zx~=E`HtMUcI_mDsp9Bbi4E- zBtb1vPC?!Ucie-R3JaNo9X}?QBWKS2K!2JIA^?;dYa5{#udA(MR`Cg7+|-q)h|4F^AVuZRm1cxrxZ#1U%T-kQi-tX#Bt-RkO1zZDIikc=#HqJU* zF0*1{9mvE;hs3+ommJ&AnM>DLVu~8_Au=veFX{%u-SvCnjoe~P>X6VG{T!uWsfZ|q zWhcog?}OxDSXR)O#%-fVEGZeo8dksS-Q!SH*3o|X2$#L8g1sljuDH1Hvam|(mLwY1 z)N~~tEk(3P&%046vZIk2zjc;#4lmQFO5U=?nHo84FqujlAx)Py^MG2tRMx=8?`s8Z zvwcSis3GLgcZSL7l|KE1?&E0B!$&k=&&2UFwnTcjU!u6vgDBW@j0KTYUj22?ifFW9 z6bAT#bsh%y=Ih>s&|_?FAj;(MZ$EU?+u5#k(TWr3-O0JJ!@NfX=qxn(siWyTZr2sRmvZB}JIOB<$B z$pHnQ-K*;jwd_z%d|;-WnWuvPW1cW-J_=sW#UZv0HdFrV+j#3+ALxnFXJB^m$NBjA z;p#%|Ko~e$VE=2!T`RYVT8ud5Nv?g&xnnH9=nbCs8em^pOpw(YQH4*-E+%cFicT=t zw!0VZ71MioMc8HH{ykx8y|go!uA;BRJZc*i$}H z$+A(JM0=ZS6w(bRd?5RTpM4F>ykKwvb`%2>)C>h=O_B7HQ+k+C85Z6>{}1T#o*xjA z=;Zp*z@Q|W+2&TF-2<3NZEEJW6mYX9$E$Ds&6X_kp;qJ73o($^bStD%39B!$MlffQ zh3cg;MNEcNNNZ#@ElH9NrFIE9lI12f#@r2f`Hm8TVsD&ENRK5b2gKoO15ihBw>vg{ zGpX&pJFonte+DUbuq$3$RAAwhz_|+wF%>%G%<%4!0bSwnleooktEG!}`6hb~0PcAq z7buF4C!NFMBS$CRif=hPuAKxR2?mFWi|>i>HNWgrIlwW_M+yHZgS0`q@i222{8fGA@f5ZZgS8wmmR`!t?rA57`2 z<73m=%^>Mopyqsl1RJie-zb1;cPa~vCd1-4^N-%6E@RB52Yn<9sdOUv&L@+&AxpV58($dVVn9YZdR|@8uOsS;1zNXhE zdkG8)%<8mK!fEzQt7x#L61=@`p8Z_xXD>L`4j|JbYyV|o!H=WndOo{nXVauk+A})N zPPoZ`r45@pw~SA^!<^k_hDPH^`DnIXqd*3-#VU@z*4n%~ z(z1vT{YK}1G;Bhk&ZcqriWJzXk&=Z~n61-`gHV=RWu)^n;#?MyHECun@{@*T2kB0{ z{Lon7uWvAvVW;d2%o$sONV~gw-*@RU7HPm-B6xDGEqoN{}m`Jb^ z32AQOb^#2AkDLCE|3K}Jz8@!_@6>npo<}~-{*TztV=|>{W|SXY^_nONqA zs!{5$Lv3q*<*+|HjRC{LQ&E6%8!zE^+7#*B(qjQr20$d9BQv|D26o`rg;hBqjj%fn z;S4c$YiXz`7r5>qAs!{S}9N0cK4~>K4VEEWeZ~*?( zO{JMomL!B&MM!ChJ%5yR36myFnKER_lO{}=GGxhapv*Koh7+Yfg0w<+|jr zr4ZBv3Br_*C*P(@m)$zz>VVr!bdi1+Nu?4f^1|%T|8y&Om8Yx;8~w=ZXRf^vg^r&t z*^ElIWUax>vHJ-@$;*NnhpvI`_P+Abc3cME%%%&>?5cRr z&mwq-t)5aB&)TC#dHqysI%NyeFsz~!fn0zE>YR5YvThjOvH+KN4SPGDdaT8DQG~=U zgoO|}FNkQ)Z_+1eRb{B#h;wHdEhJD!S5#aJjT0f<1~vHNivA6Fl7DH~ua=w(ec!`h z>*@95utmUwYSVP^8Y;b%H4rDz)I-|pTWcidh#{9Wk)08(k?qf$_qu7&*wfFj>1{cf z+yCJu=1y7tU2>iow&sv?M?52D{T37rd9XsI);do6t-r_QmN^c?AIBm zb_X}GF-aj*Bs9OQ(NxoSvs{O#*etkL%`UdN&$}lsy*ALRS+JK>T#}k3S+eq{Rqebwz}qe+?BmR`6+eX?c@QyaiZn{#{C2eaXgUL(;^uD{68>P;aU?^ zphnG1iE2Ae#&=GF2<1%w#;2O8u;)>^Jb$k1P#A6F`)ilIuTF{Izmth7&jTY--v9T( zzZow2dbCSiI|_dCX!{IpcdaMWobz;#C)1?Bm<8HI@4jdXN%_X5gUd<S#La%cgWve$b=oM)|Rk7EA>5y{cm6r*u!>xkgCK_ SbkqQuaS(t20000000016eRK5y literal 2484 zcmaKuc{tQ-8^<3}3`#=Ts%BVH^`7^=pX&JBbB99+{sc&a-C?|? zxh}-+5F@<)#;$*3H-G$&J`U1o`d$v&*=xskPRHxvXJr9BxgZw@fxsM~fzHnPp*du@ zJOI?b0>Ga1E9VMzuPO!r;SaxZ(o_KWq5-Jv{+0XH6Mq-H%a7M_KyjC+CjhJW0oZE? zfWR97xG(-#gTnu)4Gt}dLv`Vx;RP-O4*&-k;0N4*B1FFdC2$g)hQ2Voo)Z9nHIU7A zoW*trX#1*AFa?r%5)ydBh={#pAHVS+EOUh4JT7T*^z6}ZBqc5Nt6;{HHM=pjh`yK4 zoGG(e>&EhphYxLZH8f5328mUoR!O2c*Sc^DjO4G%Yfk-J3rityDJjU6tcfx9732)+ zQ=M4&(>1ZWB}xv*GqA;+t6-v;NQH3IF!t~hEc&);S(%x7uAkRXCS#gZ^WQ*G{9NSL zrrEHkr7%%=bxZ6(x+WU)bjvST82zf~+?J5=#1Iz1f_nZ`LFc|-hCqJzXY zSR(skc#T{d$;EEf2Qf|}; z9L$*~Xe8V5v#|egENQvui02Yd4eeHssv3Wo_qCgbx&$WosgG*tVMNhsU1u4KMj(IkUHl-+bI%pab`9sw#IXGF zdyZ#}@%LRnCX`a|s8}#0NgiCwrXA_podQITW(iqBwafhcnNs-P2D`B&rXb-4%thA! zs?uxhTfvVQ`m8&HW?G#@1NRTzaCf}tcWO_R%;DWY#VFyfcP%R5{Z#z+?DyIIHLdTY z<{INmH)c50N55H(3%`zvz!{f!WH1o>j>nuZv#FrG%nTEA`RMT>^)5TzcG%-C~|3x1kAXaR8yELBXB!y z#EL%7mEg<%I%2U{kfcn`%1)7=c5LoL|I5hQ@HdxO``m$nty`A)19n-T2OCtmM%ysN zZ;2Rgc>0k1C+Aw<@X@tKELKPxc^mN8u4SP1Wzxn#>o83E5XlgCghTz>(8KmG^e58k zccKllNHG25IsuF`Ab07p{?f3~b${y#4cbs}>~%ezI26yZR1Hd~rW{Mr;f0loEn6>R zA~Yf{I2YrC^$WT@81;UHMK8-W4xYJ54!b7zOb{^tXmqM|eD?kDZO!w2R-uz;lZc8P zCIKcnbA22^08X>JbM>JVO-x0bev#lfi#wH`5B8f4{g5aIjJUghjm`>?aVI=%e88t{i9VIP9d;^r<=Ift9V388xnSs#juN9P+!e{cj|;2d%i z)^H46FCn=JjGKAC>QS$E)x(v)-)~{ogv9V}R;=@hf2o!`EG@Duhmfn~3L%#X5o;cN z5sdd(x%Z4;M;F#;x*dFjA{u;`mv(H`gdDPE+-$>hZgj=a*%e0r(Q!fDzC(fL`6 zyR2QUDR)vl)?ra^Mp&;br*AqO6Tv;v{p?p6cSho7)k)MBgL6ft7kyL{2nXkfe0I6A zY|g%|TGWdh+309ex65XZ3C1Utlz(lodY=?7?4mAhFRAt-Lp2@^$8-0j20R@zcg8ec zl-?lDZHzaHAZGS46$D$zZN&AQChvVzB{h;MDn(+6O?Nm*ad+v0ov7XK6Gp!8#mM6B zOwTzX50gYBQx>;iE!!e;x@)#cg`fV=Oy=2!=zNk(*UW1ZmFGg-dEipkae`pm1dheqS>KXLoryUD|bEqEM_l(K-`;Ol0-&IE})6{_PsWg zdXsKL(WP78DXVB=1cippZgJ?&Ug5lbc}&4>mFCY44|D26Wb04?;1qG5B`ic`)o&Q* ZX_1c9D?t~SAlV$r<^tUg{sPr(DY^gv diff --git a/docs/user-guide/account-teams/projects-files.njk b/docs/user-guide/account-teams/projects-files.njk index 9345cd4f45..02f1afd38c 100644 --- a/docs/user-guide/account-teams/projects-files.njk +++ b/docs/user-guide/account-teams/projects-files.njk @@ -10,7 +10,7 @@ desc: Learn how to organize your work in Penpot. Create, manage and organize pro

    Projects

    Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.

    -

    If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.

    +

    If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Personal Projects") remain private to you.

    Projects view in dashboard
    diff --git a/docs/user-guide/account-teams/teams.njk b/docs/user-guide/account-teams/teams.njk index 67fbc78187..550cf81390 100644 --- a/docs/user-guide/account-teams/teams.njk +++ b/docs/user-guide/account-teams/teams.njk @@ -16,7 +16,7 @@ member is allowed to do depends on their permissions.

    Select team

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

    -

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

    +

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

    Teams selector

    Create teams

    From 2ce202c7d85afc524829e3b666a44dffa8d51f13 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 28 Aug 2026 11:35:49 +0200 Subject: [PATCH 220/298] :paperclip: Add changelog entry for 2.19.0 (unreleased) --- CHANGES.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a414495e33..71664033a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,25 @@ # CHANGELOG +## 2.19.0 (Unreleased) + +### :bug: Bugs fixed + +- Fix copying text from Penpot to the clipboard not working on MS Windows [#11303](https://github.com/penpot/penpot/issues/11303) (PR: [#11305](https://github.com/penpot/penpot/pull/11305)) +- Fix performance issue with WebGL render [#11240](https://github.com/penpot/penpot/issues/11240) (PR: [#11259](https://github.com/penpot/penpot/pull/11259)) +- Fix comment bubbles rendering on top of workspace dropdown menus [#10283](https://github.com/penpot/penpot/issues/10283) (PR: [#11201](https://github.com/penpot/penpot/pull/11201)) +- Fix inconsistent Mixed label in blur options and numeric inputs across 24 locales (by @filipsajdak) [#11148](https://github.com/penpot/penpot/issues/11148) (PR: [#11151](https://github.com/penpot/penpot/pull/11151)) +- Fix overlay shifting left when shown with top-center alignment in viewer prototype (by @filipsajdak) [#9048](https://github.com/penpot/penpot/issues/9048) (PR: [#10454](https://github.com/penpot/penpot/pull/10454)) +- Fix internal error when clicking the Copy button on the Access Token page (by @0xTHAC0) [#8496](https://github.com/penpot/penpot/issues/8496) (PR: [#11156](https://github.com/penpot/penpot/pull/11156)) +- Fix `disable-registration` flag not preventing non-users from creating accounts in the share prototypes page (by @0xTHAC0) [#5164](https://github.com/penpot/penpot/issues/5164) (PR: [#11199](https://github.com/penpot/penpot/pull/11199)) + +### :sparkles: New features & Enhancements + +- Make backend storage resilient to interrupted writes, missing files and stalled cleanup [#11344](https://github.com/penpot/penpot/issues/11344) (PR: [#11345](https://github.com/penpot/penpot/pull/11345)) +- Implement RTL support in the text editor v3 [#11262](https://github.com/penpot/penpot/issues/11262) +- Improve path operations and edition in the path editor [#10889](https://github.com/penpot/penpot/issues/10889) (PR: [#10807](https://github.com/penpot/penpot/pull/10807)) +- Add configurable keyboard shortcuts [#9924](https://github.com/penpot/penpot/issues/9924) (PR: [#10237](https://github.com/penpot/penpot/pull/10237)) +- Add auto-linking of libraries during import based on slugified name [#9263](https://github.com/penpot/penpot/issues/9263) (PR: [#9958](https://github.com/penpot/penpot/pull/9958)) + ## 2.18.0 (Unreleased) ### :bug: Bugs fixed From f06d1df5bed8bc7ea5db2d12df10df139f98522e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Fri, 28 Aug 2026 11:54:02 +0200 Subject: [PATCH 221/298] :bug: Align management API with payments service (#11414) --- backend/src/app/http/management.clj | 2 +- backend/src/app/nitrate.clj | 2 +- backend/src/app/srepl/cli.clj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj index 507a518e5c..f72c4b6d14 100644 --- a/backend/src/app/http/management.clj +++ b/backend/src/app/http/management.clj @@ -209,7 +209,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 2d7c4e22f7..a189116458 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -245,7 +245,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index dc44047e32..c47043cbad 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -232,7 +232,7 @@ [:enum "customer_service" "low_quality" - "missing_feature" + "missing_features" "other" "switched_service" "too_complex" From 10e40e62a249f6f28d778244768eff5e5bf9c309 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 28 Aug 2026 13:58:18 +0200 Subject: [PATCH 222/298] :sparkles: Add airgapped check to nitrate (#11421) --- frontend/src/app/main/data/nitrate.cljs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 2addcc0429..4be62a2cbf 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -43,15 +43,25 @@ (swap! storage/storage dissoc nitrate-entry-pending-popup-key))) +(def ^:private offline-connectivity + {:licenses false}) + +(defn- air-gapped? + [] + (contains? cf/flags :air-gapped-conf)) + (defn show-nitrate-popup ([popup-type] (show-nitrate-popup popup-type {})) ([popup-type extra-props] (ptk/reify ::show-nitrate-popup ptk/WatchEvent (watch [_ _ _] - (->> (rp/cmd! ::get-nitrate-connectivity {}) - (rx/map (fn [connectivity] - (modal/show popup-type (merge (or connectivity {}) extra-props))))))))) + (if (air-gapped?) + (rx/of (modal/show popup-type (merge offline-connectivity extra-props))) + (->> (rp/cmd! ::get-nitrate-connectivity {}) + (rx/map (fn [connectivity] + (modal/show popup-type + (merge (or connectivity {}) extra-props)))))))))) (defn build-admin-console-url ([path] From c01306734a8433905e8e0cebabe9132adb63bc4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 28 Aug 2026 14:04:56 +0200 Subject: [PATCH 223/298] :bug: Fix default team and UI labels to use Personal Projects (#11418) * :bug: Fix default team and UI labels to use Personal Projects * :paperclip: Code review --- .../email/request-file-access-yourpenpot-view/en.html | 2 +- .../email/request-file-access-yourpenpot-view/en.txt | 2 +- .../app/email/request-file-access-yourpenpot/en.html | 2 +- .../app/email/request-file-access-yourpenpot/en.txt | 2 +- backend/src/app/email.clj | 4 ++-- backend/src/app/rpc/commands/nitrate.clj | 2 +- backend/src/app/rpc/commands/profile.clj | 2 +- backend/src/app/rpc/commands/teams.clj | 4 ++-- backend/src/app/rpc/management/nitrate.clj | 10 +++++----- .../test/backend_tests/rpc_management_nitrate_test.clj | 2 +- frontend/playwright/ui/pages/DashboardPage.js | 2 +- frontend/playwright/ui/pages/OnboardingPage.js | 2 +- frontend/src/app/main/ui/dashboard/file_menu.cljs | 2 +- frontend/src/app/main/ui/dashboard/fonts.cljs | 2 +- frontend/src/app/main/ui/dashboard/libraries.cljs | 2 +- frontend/src/app/main/ui/dashboard/projects.cljs | 2 +- frontend/src/app/main/ui/dashboard/search.cljs | 2 +- frontend/src/app/main/ui/dashboard/sidebar.cljs | 6 +++--- frontend/src/app/main/ui/dashboard/team.cljs | 8 ++++---- frontend/translations/en.po | 7 ++----- frontend/translations/es.po | 7 ++----- 21 files changed, 34 insertions(+), 40 deletions(-) diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html index 57f0bbb781..f26a4b65da 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.html +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.html @@ -191,7 +191,7 @@ file named “{{file-name|abbreviate:25}}”.

    - Since this file is in your Penpot team, you can provide access by sending a view-only link. + Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes.

    To proceed, please click the button below to generate and send the view-only link:

    diff --git a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt index 397f3c821d..c52649dd37 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt +++ b/backend/resources/app/email/request-file-access-yourpenpot-view/en.txt @@ -2,7 +2,7 @@ Hello! {{requested-by|abbreviate:25}} ({{requested-by-email}}) wants to have view-only access to the file named “{{file-name|abbreviate:25}}”. -Since this file is in your Penpot team, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. +Since this file is in your Personal Projects, you can provide access by sending a view-only link. This will allow {{requested-by|abbreviate:25}} to view the content without making any changes. To proceed, please click the link below to generate and send the view-only link: diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.html b/backend/resources/app/email/request-file-access-yourpenpot/en.html index ca0b00ee27..cf3161a6b6 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot/en.html +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.html @@ -191,7 +191,7 @@ “{{file-name|abbreviate:25}}”.

    - Please note that the file is currently in Your Penpot 's team, so direct access cannot be + Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access:

      diff --git a/backend/resources/app/email/request-file-access-yourpenpot/en.txt b/backend/resources/app/email/request-file-access-yourpenpot/en.txt index 81f5d5c72e..e33a0bf80a 100644 --- a/backend/resources/app/email/request-file-access-yourpenpot/en.txt +++ b/backend/resources/app/email/request-file-access-yourpenpot/en.txt @@ -5,7 +5,7 @@ Hello! {{requested-by|abbreviate:25}} ({{requested-by-email}}) has requested access to the file named “{{file-name|abbreviate:25}}”. -Please note that the file is currently in Your Penpot 's team, so direct access cannot be granted. However, you have two options to provide the requested access: +Please note that the file is currently in Personal Projects, so direct access cannot be granted. However, you have two options to provide the requested access: - Move the File to Another Team: diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index e069b2908b..120b721781 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -505,13 +505,13 @@ :schema schema:request-file-access)) (def request-file-access-yourpenpot - "File access on Your Penpot request email." + "File access on Personal Projects request email." (template-factory :id ::request-file-access-yourpenpot :schema schema:request-file-access)) (def request-file-access-yourpenpot-view - "File access on Your Penpot view mode request email." + "File access on Personal Projects view mode request email." (template-factory :id ::request-file-access-yourpenpot-view :schema schema:request-file-access)) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 8714c5c841..2ca0032949 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -336,7 +336,7 @@ (doseq [{:keys [id reassign-to]} teams-to-leave] (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) - ;; Process organization "Your Penpot" team: keep with prefix if needed, otherwise delete. + ;; Process organization "Personal Projects" team: keep with prefix if needed, otherwise delete. (when default-team-id (if keep-default-team? (db/exec! conn [sql:prefix-team-name-and-unset-default organization-prefix default-team-id]) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 114f07be3e..4027efc6a4 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -524,7 +524,7 @@ ;; Penpot back through two paths: ::notify-user-organizations-deletion ;; (during delete-owned-organizations) and ::notify-organization-deletion. ;; Both preserve organization teams unchanged and only prefix or delete - ;; imported "Your Penpot" teams according to whether they still have files. + ;; imported "Personal Projects" teams according to whether they still have files. ;; Let Nitrate clean up the data associated with the deleted Penpot user: ;; owned organizations, remaining memberships, and subscription cancellation. (when (contains? cf/flags :admin-console) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index ab2a0628a5..17f23fcf9c 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -575,7 +575,7 @@ (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) params {:profile-id profile-id - :name "Your Penpot" + :name "Personal Projects" :features features :organization-id organization-id :is-default true} @@ -829,7 +829,7 @@ :code :only-owner-can-delete-team)) ;; Protect the user's personal default team from deletion. - ;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files. + ;; Organization-scoped default teams ("Personal Projects") are allowed to be deleted when they have no files. (when (and (:is-default team) (not in-organization?)) (ex/raise :type :validation :code :non-deletable-team diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 5df54b7e7f..9dc140ba4a 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -318,7 +318,7 @@ RETURNING id, deleted_at;") (defn manage-deleted-organization-teams "For a deleted organization, preserve organization teams unchanged and only prefix or - delete member Your Penpot teams depending on whether they still contain files." + delete member Personal Projects teams depending on whether they still contain files." [cfg {:keys [organization-id organization-name teams]}] (let [all-team-ids (->> teams (map :id) @@ -347,13 +347,13 @@ RETURNING id, deleted_at;") teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))] ;; Organization teams move to the fallback organization unchanged. Only imported - ;; Your Penpot teams keep the organization prefix when they still have files. + ;; Personal Projects teams keep the organization prefix when they still have files. (when (seq teams-to-prefix) (db/exec! conn [sql:prefix-teams-name-and-unset-default organization-prefix (db/create-array conn "uuid" teams-to-prefix)])) - ;; Empty imported Your Penpot teams disappear entirely. + ;; Empty imported Personal Projects teams disappear entirely. (soft-delete-teams! cfg teams-to-delete) (notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete) @@ -362,7 +362,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-organization-deletion "For a deleted organization, preserve organization teams and only prefix or delete - imported Your Penpot teams before notifying connected users." + imported Personal Projects before notifying connected users." {::doc/added "2.18" ::sm/params schema:notify-organization-deletion ::rpc/auth false} @@ -382,7 +382,7 @@ RETURNING id, deleted_at;") (sv/defmethod ::notify-user-organizations-deletion "For a given user, find all owned organizations and apply the deleted-organization - transfer rules to their imported Your Penpot teams." + transfer rules to their imported Personal Projects teams." {::doc/added "2.18" ::sm/params schema:notify-user-organizations-deletion ::nitrate/sso false} diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 455f875fd3..aabf58f31d 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -268,7 +268,7 @@ new-team (th/db-get :team {:id new-team-id})] (t/is (th/success? out)) (t/is (= 1 (count (set/difference after-teams before-teams)))) - (t/is (= "Your Penpot" (:name new-team))) + (t/is (= "Personal Projects" (:name new-team))) (t/is (true? (:is-default new-team)))))) (t/deftest get-managed-profiles-returns-unique-members-for-owned-teams diff --git a/frontend/playwright/ui/pages/DashboardPage.js b/frontend/playwright/ui/pages/DashboardPage.js index 4dee04f18e..eb4bea0b9f 100644 --- a/frontend/playwright/ui/pages/DashboardPage.js +++ b/frontend/playwright/ui/pages/DashboardPage.js @@ -86,7 +86,7 @@ export class DashboardPage extends BaseWebSocketPage { this.searchInput = page.getByPlaceholder("Search…"); this.teamDropdown = this.sidebar.getByRole("button", { - name: "Your Penpot", + name: "Personal Projects", }); this.userAccount = this.sidebar.getByRole("button", { name: /Princesa Leia/, diff --git a/frontend/playwright/ui/pages/OnboardingPage.js b/frontend/playwright/ui/pages/OnboardingPage.js index bbf59bbab6..0fe447463e 100644 --- a/frontend/playwright/ui/pages/OnboardingPage.js +++ b/frontend/playwright/ui/pages/OnboardingPage.js @@ -7,7 +7,7 @@ export class OnboardingPage extends BaseWebSocketPage { } async fillOnboardingInputsStep1() { - await this.page.getByText("Personal").click(); + await this.page.getByText("Personal", { exact: true }).click(); await this.page.getByText("Select option").click(); await this.page.getByText("Product Management").click(); diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index c3d466e3d3..bbd38efed2 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -37,7 +37,7 @@ (defn- get-team-name [team] (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))) (defn- group-by-team diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 6c248beb04..b5795a69ac 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -43,7 +43,7 @@ (mf/with-effect [team] (when team (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (case section :fonts (dom/set-html-title (tr "title.dashboard.fonts" tname)) diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs index f907d04b53..c5443b7bca 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.cljs +++ b/frontend/src/app/main/ui/dashboard/libraries.cljs @@ -52,7 +52,7 @@ (mf/with-effect [team] (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.shared-libraries" tname)))) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 441bf5cc5c..521a20cd55 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -357,7 +357,7 @@ (mf/with-effect [team] (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.projects" tname)))) diff --git a/frontend/src/app/main/ui/dashboard/search.cljs b/frontend/src/app/main/ui/dashboard/search.cljs index bd52a5fa57..01ad54548e 100644 --- a/frontend/src/app/main/ui/dashboard/search.cljs +++ b/frontend/src/app/main/ui/dashboard/search.cljs @@ -44,7 +44,7 @@ (mf/with-effect [team] (when team (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))] (dom/set-html-title (tr "title.dashboard.search" tname))))) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 3036dad42c..1767ce9e23 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -438,7 +438,7 @@ (when-not (contains? cf/flags :admin-console) [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if (contains? cf/flags :admin-console) (tr "dashboard.personal-projects") (tr "dashboard.your-penpot"))] + [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")] (when (= default-team-id (:id team)) tick-icon)] @@ -725,7 +725,7 @@ current-organization (dtm/team->organization team) - ;; Find the "your-penpot" teams, and transform them in organizations. When + ;; Find the "personal-projects" teams, and transform them in organizations. When ;; the selected team is directly accessible but not listed in ;; membership teams, include only its organization so the organization selector can ;; show the current selection without leaking the team into the @@ -972,7 +972,7 @@ :team-name-no-logo nitrate?)} (when-not nitrate? [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]) - [:span {:class (stl/css :team-text)} (if nitrate? (tr "dashboard.personal-projects") (tr "dashboard.default-team-name"))]] + [:span {:class (stl/css :team-text)} (tr "dashboard.personal-projects")]] (and (contains? cf/flags :subscriptions) (not is-default?) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index a39b5eb9be..df94f26837 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -644,7 +644,7 @@ (dom/set-html-title (tr "title.team-members" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [(:id team)] @@ -1248,7 +1248,7 @@ (dom/set-html-title (tr "title.team-invitations" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [(:id team)] @@ -1528,7 +1528,7 @@ (dom/set-html-title (tr "title.team-webhooks" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [] @@ -1635,7 +1635,7 @@ (mf/with-effect [team] (dom/set-html-title (tr "title.team-settings" (if (:is-default team) - (tr "dashboard.your-penpot") + (tr "dashboard.personal-projects") (:name team))))) (mf/with-effect [] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 50bf6b331e..3254e20103 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -413,7 +413,7 @@ msgstr "Pin this version" #: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:882 msgid "dashboard.default-team-name" -msgstr "Your Penpot" +msgstr "Personal Projects" #: src/app/main/ui/dashboard/deleted.cljs:265 msgid "dashboard.delete-all-forever-confirmation.description" @@ -1437,9 +1437,6 @@ msgid "dashboard.your-name" msgstr "Your name" #: src/app/main/ui/dashboard/file_menu.cljs:40, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:55, src/app/main/ui/dashboard/projects.cljs:352, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/sidebar.cljs:411, src/app/main/ui/dashboard/team.cljs:616, src/app/main/ui/dashboard/team.cljs:1191, src/app/main/ui/dashboard/team.cljs:1471, src/app/main/ui/dashboard/team.cljs:1577 -msgid "dashboard.your-penpot" -msgstr "Your Penpot" - msgid "dashboard.personal-projects" msgstr "Personal Projects" @@ -4631,7 +4628,7 @@ msgstr "You don't have access to this file." #: src/app/main/ui/static.cljs:62, src/app/main/ui/static.cljs:265, src/app/main/ui/static.cljs:271, src/app/main/ui/static.cljs:277, src/app/main/ui/static.cljs:283, src/app/main/ui/static.cljs:292, src/app/main/ui/static.cljs:301 msgid "not-found.no-permission.go-dashboard" -msgstr "Go to your Penpot" +msgstr "Go to Personal Projects" #: src/app/main/ui/static.cljs:289, src/app/main/ui/static.cljs:298 msgid "not-found.no-permission.if-approves" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 4ff509285e..146a4b72e3 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -420,7 +420,7 @@ msgstr "Guardar esta versión" #: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:882 msgid "dashboard.default-team-name" -msgstr "Tu Penpot" +msgstr "Proyectos Personales" #: src/app/main/ui/dashboard/deleted.cljs:265 msgid "dashboard.delete-all-forever-confirmation.description" @@ -1444,9 +1444,6 @@ msgid "dashboard.your-name" msgstr "Tu nombre" #: src/app/main/ui/dashboard/file_menu.cljs:40, src/app/main/ui/dashboard/fonts.cljs:46, src/app/main/ui/dashboard/libraries.cljs:55, src/app/main/ui/dashboard/projects.cljs:352, src/app/main/ui/dashboard/search.cljs:47, src/app/main/ui/dashboard/sidebar.cljs:411, src/app/main/ui/dashboard/team.cljs:616, src/app/main/ui/dashboard/team.cljs:1191, src/app/main/ui/dashboard/team.cljs:1471, src/app/main/ui/dashboard/team.cljs:1577 -msgid "dashboard.your-penpot" -msgstr "Tu Penpot" - msgid "dashboard.personal-projects" msgstr "Proyectos Personales" @@ -4503,7 +4500,7 @@ msgstr "No tienes permiso para acceder a este archivo." #: src/app/main/ui/static.cljs:62, src/app/main/ui/static.cljs:265, src/app/main/ui/static.cljs:271, src/app/main/ui/static.cljs:277, src/app/main/ui/static.cljs:283, src/app/main/ui/static.cljs:292, src/app/main/ui/static.cljs:301 msgid "not-found.no-permission.go-dashboard" -msgstr "Ir a tu Penpot" +msgstr "Ir a tus Proyectos Personales" #: src/app/main/ui/static.cljs:289, src/app/main/ui/static.cljs:298 msgid "not-found.no-permission.if-approves" From 4c9aeab65e10515e459c5032d68e8f52eef8d2ad Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 28 Aug 2026 14:55:30 +0000 Subject: [PATCH 224/298] :books: Add issue/PR CLI auto-trigger to agent guide When an issue or PR is mentioned in conversation, fetch details via gh CLI or scripts/gh.py instead of WebFetch. Use gh issue view for issues, gh pr view for single PRs, and scripts/gh.py prs for multiple PRs. AI-assisted-by: glm-5.3-flash --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d4d1e238e7..69d33e7eba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,15 @@ Skipping this step is the #1 cause of incorrect or incomplete work. `github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID from the URL and run `python3 scripts/gh.py advisories ` to fetch full advisory details before proceeding. +- **Issue or PR mentioned** — When the user mentions a penpot/penpot issue or + PR (URL like `github.com/penpot/penpot/issues/` / `.../pull/`, or a + bare `#` when context clearly refers to this repo), fetch details via CLI + instead of WebFetch: + - Issue → `gh issue view --repo penpot/penpot` (add `--comments` when + discussion context matters). + - Single PR → `gh pr view --repo penpot/penpot`. + - Multiple PRs (list, file, or milestone) → `python3 scripts/gh.py prs ...`. + Do this before proceeding. Only use WebFetch if the CLI fails. ## Writing Rules From 995a5460e5b37c74415bcc21f242f8fff8ea9d4d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 28 Aug 2026 21:20:17 +0200 Subject: [PATCH 225/298] :paperclip: Update agents and opencode on devenv --- AGENTS.md | 10 +++++++++- docker/devenv/Dockerfile | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 69d33e7eba..06507f18d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,15 @@ Skipping this step is the #1 cause of incorrect or incomplete work. ## Writing Rules -Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100. +Writing rules, from Orwell, 1946. These govern prose: docs, PR text, messages. Never touch code or technical terms; swap in everyday words only where precision survives. + +1. Never use a metaphor, simile or other figure of speech which you are used to seeing in print. +2. Never use a long word where a short one will do. +3. If it is possible to cut a word out, always cut it out. +4. Never use the passive where you can use the active. +5. Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent. +6. Break any of these rules sooner than say anything outright barbarous. +Review every prose output against these rules before delivering. --- diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 450cff5b5a..5a493a862f 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.19 +ENV OPENCODE_VERSION=1.18.25 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ From 169e6fcbca5c672a648e9b5ce7fa343d564e28dc Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 31 Aug 2026 11:50:59 +0200 Subject: [PATCH 226/298] :bug: Fix comments not close main menu (#11347) --- frontend/src/app/main/ui/workspace/main_menu.cljs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index e08de97b03..4824dd1f99 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -344,7 +344,7 @@ (mf/defc view-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout toggle-flag on-close]}] + [{:keys [layout toggle-flag on-close on-close-all]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) toggle-color-palette @@ -365,11 +365,11 @@ toggle-comments-visibility (mf/use-fn - (mf/deps on-close) + (mf/deps on-close-all) (fn [event] (dom/stop-propagation event) (st/emit! (dwcm/toggle-comments-visibility {:origin "workspace:menu"})) - (on-close)))] + (on-close-all)))] [:> dropdown-menu* {:show true :class (stl/css :base-menu :sub-menu :pos-3) @@ -1139,7 +1139,8 @@ :view [:> view-menu* {:layout layout :toggle-flag toggle-flag - :on-close close-sub-menu}] + :on-close close-sub-menu + :on-close-all close-all-menus}] :preferences [:> preferences-menu* {:layout layout From 2ed0981e8a1f3aebde587fa518c10ec249d7a500 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 31 Aug 2026 11:51:42 +0200 Subject: [PATCH 227/298] :bug: Fix disabled numeric input with token applied is broken (#11324) --- frontend/src/app/main/ui/ds/controls/numeric_input.cljs | 3 ++- .../src/app/main/ui/ds/controls/utilities/token_field.scss | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index f04c30b849..b356e88af4 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -839,7 +839,8 @@ (mf/with-effect [handle-unmount] handle-unmount) [:div {:class [class (stl/css-case :input-wrapper true - :resizable (not is-token-applied?))] + :resizable (and (not is-token-applied?) + (not disabled)))] :ref wrapper-ref :on-pointer-down on-scrub-pointer-down :on-pointer-move on-scrub-pointer-move diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss index 765e6d5eae..e8d1586fbd 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss @@ -22,9 +22,10 @@ column-gap: var(--sp-xs); align-items: center; inline-size: 100%; + block-size: var(--token-field-height); background: var(--token-field-bg-color); border-radius: $br-8; - padding-inline-end: var(--sp-xs); + padding: 0 var(--input-padding-size, var(--sp-s)); outline: $b-1 solid var(--token-field-outline-color); position: relative; From bfb5cae9f49250447f9aa183abb66f9cc6b7171c Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 31 Aug 2026 12:17:57 +0200 Subject: [PATCH 228/298] :bug: Fix viewer login modal lacks spacing between SSO buttons and work email field (#11312) * :bug: Fix spacing between SSO buttons and email field * :recycle: Update scrollbar on register page * :recycle: Translate hardcoded string --------- Co-authored-by: Eva Marco --- frontend/src/app/main/ui/auth.cljs | 4 +- frontend/src/app/main/ui/auth.scss | 3 + frontend/src/app/main/ui/auth/common.scss | 248 ++++++------------ frontend/src/app/main/ui/auth/login.cljs | 111 ++++---- frontend/src/app/main/ui/auth/login.scss | 80 ++++++ frontend/src/app/main/ui/auth/recovery.cljs | 19 +- frontend/src/app/main/ui/auth/recovery.scss | 4 - .../app/main/ui/auth/recovery_request.cljs | 28 +- .../app/main/ui/auth/recovery_request.scss | 15 -- frontend/src/app/main/ui/auth/register.cljs | 113 ++++---- frontend/src/app/main/ui/auth/register.scss | 77 +++--- frontend/src/app/main/ui/static.cljs | 2 +- frontend/src/app/main/ui/viewer/login.cljs | 66 +++-- frontend/src/app/main/ui/viewer/login.scss | 86 +++--- 14 files changed, 428 insertions(+), 428 deletions(-) diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index 790d8b0a3e..a77457c70c 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -13,7 +13,7 @@ [app.main.ui.auth.login :refer [login-page*]] [app.main.ui.auth.recovery :refer [recovery-page*]] [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-register*]] + [app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-service-privacy-policy*]] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*] :as raw-svg] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.util.dom :as dom] @@ -74,7 +74,7 @@ [:> recovery-page* {:params params}]) (when (= section :auth-register) - [:> terms-register*])]])) + [:> terms-service-privacy-policy*])]])) (mf/defc auth-page* diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index 7d593ce4fa..0462f23514 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -6,6 +6,7 @@ @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; +@use "./ds/mixins.scss" as *; .auth-section { display: grid; @@ -26,6 +27,8 @@ } .auth-section.register { + @include custom-scrollbar; + display: flex; justify-content: center; align-items: center; diff --git a/frontend/src/app/main/ui/auth/common.scss b/frontend/src/app/main/ui/auth/common.scss index 951ea442a6..066b6849d0 100644 --- a/frontend/src/app/main/ui/auth/common.scss +++ b/frontend/src/app/main/ui/auth/common.scss @@ -4,56 +4,15 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; -.auth-form-wrapper { - inline-size: 100%; - padding-block-end: 0; - display: grid; +.form { + display: flex; + flex-direction: column; gap: var(--sp-m); - - // Native
      inside auth-form-wrapper — no class available - form { - display: flex; - flex-direction: column; - gap: var(--sp-m); - margin-block-start: var(--sp-m); - } -} - -.auth-title-wrapper { - inline-size: 100%; - padding-block-end: 0; - display: grid; - gap: var(--sp-s); -} - -.separator { - border-color: var(--color-background-quaternary); - margin: 0; -} - -.auth-title { - @include use-typography("title-large"); - - line-height: 1.2; - color: var(--color-foreground-primary); -} - -.auth-subtitle { - @include use-typography("title-small"); - - color: var(--color-foreground-secondary); -} - -.auth-tagline { - @include use-typography("title-small"); - - margin: 0; - color: var(--color-foreground-secondary); + margin: var(--sp-m) 0; } .form-field { @@ -62,36 +21,40 @@ --input-min-width: 100%; } -.buttons-stack { - display: grid; - gap: var(--sp-s); -} +.form-submit-btn { + --button-bg-color: var(--color-accent-primary); + --button-border-color: var(--color-accent-primary); + --button-fg-color: var(--color-background-secondary); -.login-button, -.login-ldap-button { @include use-typography("headline-small"); display: flex; justify-content: center; align-items: center; - cursor: pointer; - background-color: var(--color-accent-primary); - border: $b-1 solid var(--color-accent-primary); - color: var(--color-background-secondary); + background-color: var(--button-bg-color); + border: $b-1 solid var(--button-border-color); + color: var(--button-fg-color); border-radius: $br-8; min-block-size: $sz-32; block-size: $sz-40; inline-size: 100%; + &:hover { + --button-bg-color: var(--color-accent-tertiary); + --button-border-color: var(--color-accent-tertiary); + --button-fg-color: var(--color-background-secondary); + + text-decoration: none; + } + &:disabled { - background-color: var(--color-background-quaternary); - border: 1px solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; + --button-bg-color: var(--color-background-quaternary); + --button-border-color: var(--color-background-quaternary); + --button-fg-color: var(--color-foreground-disabled); } } -.go-back { +.go-back-row { display: flex; flex-direction: column; gap: var(--sp-m); @@ -100,19 +63,68 @@ } .go-back-link { + --button-bg-color: var(--color-background-tertiary); + --button-border-color: var(--color-background-tertiary); + --button-fg-color: var(--color-foreground-secondary); + + @include use-typography("headline-small"); + background: none; - cursor: pointer; display: flex; justify-content: center; align-items: center; border-radius: $br-8; - background-color: var(--color-background-tertiary); - border: $b-1 solid var(--color-background-tertiary); - color: var(--color-foreground-secondary); - - @include use-typography("headline-small"); - + background-color: var(--button-bg-color); + border: $b-1 solid var(--button-border-color); + color: var(--button-fg-color); block-size: $sz-40; + + &:hover { + --button-bg-color: var(--color-background-quaternary); + --button-border-color: var(--color-background-quaternary); + --button-fg-color: var(--color-accent-primary); + + text-decoration: none; + } +} + +.separator { + border-color: var(--color-background-quaternary); + margin: 0; +} + +.wrapper { + inline-size: 100%; + padding-block-end: 0; + display: grid; + gap: var(--sp-m); +} + +.title-wrapper { + inline-size: 100%; + padding-block-end: 0; + display: grid; + gap: var(--sp-s); +} + +.title { + @include use-typography("title-large"); + + line-height: 1.2; + color: var(--color-foreground-primary); +} + +.subtitle { + @include use-typography("title-small"); + + color: var(--color-foreground-secondary); +} + +.tagline { + @include use-typography("title-small"); + + margin: 0; + color: var(--color-foreground-secondary); } .links { @@ -120,104 +132,10 @@ gap: var(--sp-xxl); } -.register, -.account, -.recovery-request, -.demo-account { - display: flex; - justify-content: center; - gap: var(--sp-s); - padding: 0; -} +.notification-email { + @include use-typography("title-medium"); -.register-text, -.account-text, -.recovery-text, -.demo-account-text { - @include use-typography("title-small"); - - text-align: right; - color: var(--color-foreground-secondary); -} - -.register-link, -.account-link, -.recovery-link, -.forgot-pass-link, -.demo-account-link { - @include use-typography("title-small"); - - text-align: left; - background-color: transparent; - border: none; - display: inline; + line-height: 1.2; color: var(--color-accent-primary); - - &:hover { - text-decoration: underline; - } -} - -.forgot-password { - display: flex; - justify-content: flex-end; -} - -.submit-btn, -.register-btn, -.recover-btn { - @include use-typography("headline-small"); - - background: none; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; - background-color: var(--color-accent-primary); - border: $b-1 solid var(--color-accent-primary); - color: var(--color-background-secondary); - border-radius: $br-8; - min-block-size: $sz-32; - block-size: $sz-40; - inline-size: 100%; - - &:disabled { - background-color: var(--color-background-quaternary); - border: $b-1 solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; - } -} - -.login-btn { - @include use-typography("title-small"); - - display: flex; - align-items: center; - gap: px2rem(6); - inline-size: 100%; - border-radius: $br-8; - background-color: var(--color-background-tertiary); - color: var(--color-foreground-primary); - - span { - padding-block-start: var(--sp-xxs); - } - - &:hover { - color: var(--color-foreground-primary); - background-color: var(--color-background-quaternary); - } - - &:disabled { - background-color: var(--color-background-quaternary); - border: 1px solid var(--color-background-quaternary); - color: var(--color-foreground-disabled); - cursor: unset; - } -} - -.auth-buttons { - display: flex; - gap: var(--sp-s); + margin-inline: $sz-36; } diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index c23cbf1c53..5cb6953f6d 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -71,10 +71,17 @@ (mf/defc login-form* [{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}] (let [initial (mf/with-memo [params] params) - error (mf/use-state false) + form (fm/use-form :schema schema:login-form :initial initial) + + error (mf/use-state false) + + show-password-field* (mf/use-state #(not (contains? cf/flags :login-with-custom-sso))) + show-password-field? (deref show-password-field*) + callback-url (:callback-url params) + on-error (fn [cause] (let [cause (ex-data cause)] @@ -102,12 +109,6 @@ :else (reset! error (tr "errors.generic"))))) - show-password-field* - (mf/use-state #(not (contains? cf/flags :login-with-custom-sso))) - - show-password-field? - (deref show-password-field*) - on-success (fn [data] (when (fn? on-success-callback) @@ -130,7 +131,8 @@ (->> (rp/cmd! :get-sso-provider {:email (:email params)}) (rx/map :id) (rx/catch (fn [cause] - (log/error :hint "error on retrieving sso provider" :cause cause) + (log/error :hint "error on retrieving sso provider" + :cause cause) (rx/of nil))) (rx/subs! (fn [sso-provider-id] (if sso-provider-id @@ -139,7 +141,7 @@ (reset! show-password-field* true)))))))))) on-submit-ldap - (mf/use-callback + (mf/use-fn (mf/deps form) (fn [event] (dom/prevent-default event) @@ -168,91 +170,102 @@ {:level :error} message]) [:& fm/form {:on-submit on-submit - :class (stl/css :login-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} - [:& fm/input - {:name :email - :type "email" - :label (tr "auth.work-email") - :class (stl/css :form-field)}]] + [:div {:class (stl/css :form-row)} + [:& fm/input {:name :email + :type "email" + :label (tr "auth.work-email") + :class (stl/css :form-field)}]] (when show-password-field? - [:div {:class (stl/css :fields-row)} - [:& fm/input - {:type "password" - :name :password - :auto-focus? true - :label (tr "auth.password") - :class (stl/css :form-field)}]]) + [:div {:class (stl/css :form-row)} + [:& fm/input {:type "password" + :name :password + :auto-focus? true + :label (tr "auth.password") + :class (stl/css :form-field)}]]) (when (and (not= origin :viewer) (or (contains? cf/flags :login) (contains? cf/flags :login-with-password))) - [:div {:class (stl/css :fields-row :forgot-password)} + [:div {:class (stl/css :form-row :forgot-password-row)} [:> lk/link* {:action on-recovery-request - :class (stl/css :forgot-pass-link) + :class (stl/css :forgot-password-link) :data-testid "forgot-password"} (tr "auth.forgot-password")]]) - [:div {:class (stl/css :buttons-stack)} + [:div {:class (stl/css :form-submit-buttons)} (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password)) - [:> fm/submit-button* - {:label (tr "labels.continue") - :data-testid "login-submit" - :class (stl/css :login-button)}]) + [:> fm/submit-button* {:label (tr "labels.continue") + :data-testid "login-submit" + :class (stl/css :form-submit-btn)}]) (when (contains? cf/flags :login-with-ldap) - [:> fm/submit-button* - {:label (tr "auth.login-with-ldap-submit") - :class (stl/css :login-ldap-button) - :on-click on-submit-ldap}])]]])) + [:> fm/submit-button* {:label (tr "auth.login-with-ldap-submit") + :class (stl/css :form-submit-btn) + :on-click on-submit-ldap}])]]])) (defn raw-icon [id] (mf/html [:> raw-svg* {:id id :class (stl/css :sso-icon)}])) -(mf/defc login-sso-buttons* +(mf/defc sso-buttons* [{:keys [params] :as props}] - (let [login-with-google (mf/use-fn (mf/deps params) #(login-with-sso "google" params)) - login-with-github (mf/use-fn (mf/deps params) #(login-with-sso "github" params)) - login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-sso "gitlab" params)) - login-with-oidc (mf/use-fn (mf/deps params) #(login-with-sso "oidc" params))] + (let [login-with-google + (mf/use-fn + (mf/deps params) + #(login-with-sso "google" params)) - [:div {:class (stl/css :auth-buttons)} + login-with-github + (mf/use-fn + (mf/deps params) + #(login-with-sso "github" params)) + + login-with-gitlab + (mf/use-fn + (mf/deps params) + #(login-with-sso "gitlab" params)) + + login-with-oidc + (mf/use-fn + (mf/deps params) + #(login-with-sso "oidc" params))] + + [:div {:class (stl/css :sso-row)} (when (contains? cf/flags :login-with-google) [:> bl/button-link* {:on-click login-with-google :icon (raw-icon raw-icons/brand-google) :label (tr "auth.login-with-google-submit") - :class (stl/css :login-btn :btn-google-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-github) [:> bl/button-link* {:on-click login-with-github :icon (raw-icon raw-icons/brand-github) :label (tr "auth.login-with-github-submit") - :class (stl/css :login-btn :btn-github-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-gitlab) [:> bl/button-link* {:on-click login-with-gitlab :icon (raw-icon raw-icons/brand-gitlab) :label (tr "auth.login-with-gitlab-submit") - :class (stl/css :login-btn :btn-gitlab-auth)}]) + :class (stl/css :sso-btn)}]) (when (contains? cf/flags :login-with-oidc) [:> bl/button-link* {:on-click login-with-oidc :icon (raw-icon raw-icons/brand-openid) :label (or (not-empty cf/oidc-name) (tr "auth.login-with-oidc-submit")) - :class (stl/css :login-btn :btn-oidc-auth)}])])) + :class (stl/css :sso-btn)}])])) (mf/defc login-dialog* [{:keys [params] :as props}] [:* (when show-sso-login-buttons? [:* - [:> login-sso-buttons* {:params params}] + [:> sso-buttons* {:params params}] (when (or (contains? cf/flags :login) (contains? cf/flags :login-with-password) @@ -270,11 +283,11 @@ (mf/use-fn #(st/emit! (rt/nav :auth-register params)))] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title) + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title) :data-testid "login-title"} (tr "auth.login-account-title")] - [:p {:class (stl/css :auth-tagline)} + [:p {:class (stl/css :tagline)} (tr "auth.login-tagline")] (when (contains? cf/flags :demo-warning) @@ -286,7 +299,7 @@ [:div {:class (stl/css :links)} (when (contains? cf/flags :registration) - [:div {:class (stl/css :register)} + [:div {:class (stl/css :register-row)} [:span {:class (stl/css :register-text)} (tr "auth.register") " "] [:> lk/link* {:action go-register diff --git a/frontend/src/app/main/ui/auth/login.scss b/frontend/src/app/main/ui/auth/login.scss index 5aa37946bb..270eea41bf 100644 --- a/frontend/src/app/main/ui/auth/login.scss +++ b/frontend/src/app/main/ui/auth/login.scss @@ -6,8 +6,88 @@ @use "./common"; @use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; + +.forgot-password-row { + display: flex; + justify-content: flex-end; +} + +.forgot-password-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} + +.form-submit-buttons { + display: grid; + gap: var(--sp-s); +} .sso-icon { max-inline-size: px2rem(26); max-block-size: px2rem(26); } + +.sso-row { + display: flex; + gap: var(--sp-s); +} + +.sso-btn { + --button-bg-color: var(--color-background-tertiary); + --button-fg-color: var(--color-foreground-primary); + + @include use-typography("title-small"); + + display: flex; + align-items: center; + gap: px2rem(6); + inline-size: 100%; + border-radius: $br-8; + background-color: var(--button-bg-color); + color: var(--button-fg-color); + + &:hover { + --button-bg-color: var(--color-background-quaternary); + --button-fg-color: var(--color-foreground-primary); + } +} + +.register-row { + display: flex; + justify-content: center; + gap: var(--sp-s); + padding: 0; +} + +.register-text { + @include use-typography("title-small"); + + text-align: right; + color: var(--color-foreground-secondary); +} + +.register-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index d91f200db9..e51a2ecf88 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -60,39 +60,38 @@ :initial params)] [:& fm/form {:on-submit on-submit - :class (stl/css :recovery-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "password" :name :password-1 :show-success? true :label (tr "auth.new-password") :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "password" :name :password-2 :show-success? true :label (tr "auth.confirm-password") :class (stl/css :form-field)}]] - [:> fm/submit-button* - {:label (tr "auth.recovery-submit") - :class (stl/css :submit-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.recovery-submit") + :class (stl/css :form-submit-btn)}]])) ;; --- Recovery Request Page (mf/defc recovery-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title)} "Forgot your password?"] - [:div {:class (stl/css :auth-subtitle)} "Please enter your new password"] + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")] + [:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")] [:hr {:class (stl/css :separator)}] [:> recovery-form* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:a {:on-click #(st/emit! (rt/nav :auth-login)) :class (stl/css :go-back-link)} (tr "profile.recovery.go-to-login")]]]]) diff --git a/frontend/src/app/main/ui/auth/recovery.scss b/frontend/src/app/main/ui/auth/recovery.scss index 4d0d4750bd..136a0cd63a 100644 --- a/frontend/src/app/main/ui/auth/recovery.scss +++ b/frontend/src/app/main/ui/auth/recovery.scss @@ -5,7 +5,3 @@ // Copyright (c) KALEIDOS INC Sucursal en España SL @use "./common"; - -.submit-btn { - margin-block-start: var(--sp-l); -} diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 78caa421ff..d6b8338bee 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -70,18 +70,17 @@ (st/emit! (du/request-profile-recovery params)))))] [:& fm/form {:on-submit on-submit - :class (stl/css :recovery-request-form) + :class (stl/css :form) :form form} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :email :label (tr "auth.work-email") :type "text" :class (stl/css :form-field)}]] - [:> fm/submit-button* - {:label (tr "auth.recovery-request-submit") - :data-testid "recovery-resquest-submit" - :class (stl/css :recover-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.recovery-request-submit") + :data-testid "recovery-resquest-submit" + :class (stl/css :form-submit-btn)}]])) ;; --- Recovery Request Page @@ -90,14 +89,14 @@ [{:keys [params on-success-callback go-back-callback]}] (let [default-go-back #(st/emit! (rt/nav :auth-login)) go-back (or go-back-callback default-go-back)] - [:div {:class (stl/css :auth-form-wrapper)} - [:h1 {:class (stl/css :auth-title)} (tr "auth.recovery-request-title")] - [:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")] + [:div {:class (stl/css :wrapper)} + [:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")] + [:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")] [:hr {:class (stl/css :separator)}] [:> recovery-form* {:params params :on-success-callback on-success-callback}] [:hr {:class (stl/css :separator)}] - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:> lk/link* {:action go-back :class (stl/css :go-back-link) :data-testid "go-back-link"} @@ -106,11 +105,10 @@ (mf/defc recovery-sent-page* [{:keys [email]}] - [:div {:class (stl/css :auth-form-wrapper :register-success)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title)} + [:div {:class (stl/css :wrapper :register-success)} + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :title)} (tr "auth.check-email")] [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery")]] - [:div {:class (stl/css :notification-text-email)} email] + [:div {:class (stl/css :notification-email)} email] [:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery-check")]]) - diff --git a/frontend/src/app/main/ui/auth/recovery_request.scss b/frontend/src/app/main/ui/auth/recovery_request.scss index 11d45df27b..136a0cd63a 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.scss +++ b/frontend/src/app/main/ui/auth/recovery_request.scss @@ -4,19 +4,4 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "ds/_utils.scss" as *; -@use "ds/_sizes.scss" as *; -@use "ds/typography.scss" as *; @use "./common"; - -.fields-row { - margin-block-end: var(--sp-s); -} - -.notification-text-email { - @include use-typography("title-medium"); - - line-height: 1.2; - color: var(--color-accent-primary); - margin-inline: $sz-36; -} diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index be5f3f280b..41acc9e841 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -26,39 +26,38 @@ ;; --- PAGE: Register -(mf/defc newsletter-options* +(mf/defc newsletter-checkbox* {::mf/private true} [] - (let [updates-label + (let [newsletter-label (mf/html - [:> i18n/tr-html* - {:tag-name "div" - :content (tr "onboarding-v2.newsletter.updates")}])] - [:div {:class (stl/css :fields-row :input-visible :newsletter-option-wrapper)} + [:> i18n/tr-html* {:tag-name "div" + :content (tr "onboarding-v2.newsletter.updates")}])] + + [:div {:class (stl/css :form-row :input-visible :newsletter-option-wrapper)} [:& fm/input {:name :accept-newsletter-updates :class (stl/css :checkbox-newsletter-updates) :type "checkbox" :default-checked false - :label updates-label}]])) + :label newsletter-label}]])) -(mf/defc terms-and-privacy* +(mf/defc terms-and-privacy-checkbox* {::mf/private true} [] - (let [terms-label + (let [terms-and-privacy-label (mf/html - [:> i18n/tr-html* - {:tag-name "div" - :content (tr "auth.terms-and-privacy-agreement" - cf/terms-of-service-uri - cf/privacy-policy-uri)}])] + [:> i18n/tr-html* {:tag-name "div" + :content (tr "auth.terms-and-privacy-agreement" + cf/terms-of-service-uri + cf/privacy-policy-uri)}])] - [:div {:class (stl/css :fields-row :input-visible :accept-terms-and-privacy-wrapper)} + [:div {:class (stl/css :form-row :input-visible :accept-terms-and-privacy-wrapper)} [:& fm/input {:name :accept-terms-and-privacy :show-error false :class (stl/css :checkbox-terms-and-privacy) :type "checkbox" :default-checked false - :label terms-label}]])) + :label terms-and-privacy-label}]])) (def ^:private schema:register-form [:map {:title "RegisterForm"} @@ -76,8 +75,7 @@ form (fm/use-form :schema schema:register-form :initial initial) - submitted? - (mf/use-state false) + submitted? (mf/use-state false) on-error (mf/use-fn @@ -167,22 +165,24 @@ (->> (rp/cmd! :prepare-register-profile cdata) (rx/subs! on-register-profile on-error #(reset! submitted? false))))))] - [:& fm/form {:on-submit on-submit :form form} - [:div {:class (stl/css :fields-row)} + [:& fm/form {:on-submit on-submit + :form form + :class (stl/css :form)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :fullname :label (tr "auth.fullname") :type "text" :show-success? true :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:type "text" :name :email :label (tr "auth.work-email") :data-testid "email-input" :show-success? true :class (stl/css :form-field)}]] - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :password :hint (tr "auth.password-length-hint") :label (tr "auth.password") @@ -191,21 +191,20 @@ :class (stl/css :form-field)}]] (when (contains? cf/flags :terms-and-privacy-checkbox) - [:> terms-and-privacy*]) + [:> terms-and-privacy-checkbox*]) - [:> newsletter-options*] + [:> newsletter-checkbox*] - [:> fm/submit-button* - {:label (tr "auth.register-submit") - :disabled @submitted? - :data-testid "register-form-submit" - :class (stl/css :register-btn)}]])) + [:> fm/submit-button* {:label (tr "auth.register-submit") + :disabled @submitted? + :data-testid "register-form-submit" + :class (stl/css :form-submit-btn)}]])) (mf/defc register-methods* [{:keys [params hide-separator on-success-callback]}] [:* (when login/show-sso-login-buttons? - [:> login/login-sso-buttons* {:params params}]) + [:> login/sso-buttons* {:params params}]) (when (or login/show-sso-login-buttons? (false? hide-separator)) [:hr {:class (stl/css :separator)}]) (when (contains? cf/flags :login-with-password) @@ -213,8 +212,8 @@ (mf/defc register-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper :register-form)} - [:h1 {:class (stl/css :auth-title) + [:div {:class (stl/css :wrapper :register-form)} + [:h1 {:class (stl/css :title) :data-testid "registration-title"} (tr "auth.register-title")] (when (contains? cf/flags :demo-warning) @@ -223,7 +222,7 @@ [:> register-methods* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :account)} + [:div {:class (stl/css :account-row)} [:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "] [:> lk/link* {:action #(st/emit! (rt/nav :auth-login params)) :class (stl/css :account-link) @@ -233,9 +232,9 @@ (when (contains? cf/flags :demo-users) [:* [:hr {:class (stl/css :separator)}] - [:div {:class (stl/css :demo-account)} + [:div {:class (stl/css :account-row)} [:> lk/link* {:action login/create-demo-profile - :class (stl/css :demo-account-link)} + :class (stl/css :account-link)} (tr "auth.create-demo-account")]]])]]) @@ -244,31 +243,31 @@ (mf/defc register-success-page* [{:keys [params]}] (let [email (or (:email params) (::email storage/user))] - [:div {:class (stl/css :auth-form-wrapper :register-success)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title)} + [:div {:class (stl/css :wrapper :register-success)} + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :register-success-title)} (tr "auth.check-email")] [:div {:class (stl/css :notification-text)} (tr "auth.verification-sent-email")]] - [:div {:class (stl/css :notification-text-email)} email]])) + [:div {:class (stl/css :notification-email)} email]])) -(mf/defc terms-register* +(mf/defc terms-service-privacy-policy* [] (let [show-all? (and cf/terms-of-service-uri cf/privacy-policy-uri) show-terms? (some? cf/terms-of-service-uri) show-privacy? (some? cf/privacy-policy-uri)] (when show-all? - [:div {:class (stl/css :terms-register)} + [:div {:class (stl/css :terms)} (when show-terms? - [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :auth-link)} + [:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :terms-link)} (tr "auth.terms-of-service")]) (when show-all? - [:span {:class (stl/css :and-text)} + [:span {:class (stl/css :terms-and)} (dm/str " " (tr "labels.and") " ")]) (when show-privacy? - [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :auth-link)} + [:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :terms-link)} (tr "auth.privacy-policy")])]))) ;; --- PAGE: register validation @@ -334,9 +333,9 @@ [:& fm/form {:on-submit on-submit :form form - :class (stl/css :register-validate-form)} + :class (stl/css :auth-form)} - [:div {:class (stl/css :fields-row)} + [:div {:class (stl/css :form-row)} [:& fm/input {:name :fullname :label (tr "auth.fullname") :type "text" @@ -344,30 +343,28 @@ :class (stl/css :form-field)}]] (when (contains? cf/flags :terms-and-privacy-checkbox) - [:> terms-and-privacy*]) + [:> terms-and-privacy-checkbox*]) - [:> newsletter-options*] - - [:> fm/submit-button* - {:label (tr "auth.register-submit") - :disabled @submitted? - :class (stl/css :register-btn)}]])) + [:> newsletter-checkbox*] + [:> fm/submit-button* {:label (tr "auth.register-submit") + :disabled @submitted? + :class (stl/css :form-submit-btn)}]])) (mf/defc register-validate-page* [{:keys [params]}] - [:div {:class (stl/css :auth-form-wrapper :register-form)} + [:div {:class (stl/css :wrapper :register-form)} - [:div {:class (stl/css :auth-title-wrapper)} - [:h2 {:class (stl/css :auth-title) + [:div {:class (stl/css :title-wrapper)} + [:h2 {:class (stl/css :title) :data-testid "register-title"} (tr "auth.register-account-title")] - [:div {:class (stl/css :auth-subtitle)} (tr "auth.register-account-tagline")]] + [:div {:class (stl/css :subtitle)} (tr "auth.register-account-tagline")]] [:> register-validate-form* {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :go-back)} + [:div {:class (stl/css :go-back-row)} [:> lk/link* {:action #(st/emit! (rt/nav :auth-register {})) :class (stl/css :go-back-link)} (tr "labels.go-back")]]]]) diff --git a/frontend/src/app/main/ui/auth/register.scss b/frontend/src/app/main/ui/auth/register.scss index 1b238cb353..71b7c8b4a0 100644 --- a/frontend/src/app/main/ui/auth/register.scss +++ b/frontend/src/app/main/ui/auth/register.scss @@ -4,57 +4,64 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL +@use "./common"; @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/typography.scss" as *; -@use "./common"; -.checkbox-terms-and-privacy, .checkbox-newsletter-updates { align-items: flex-start; } +.checkbox-terms-and-privacy { + align-items: flex-start; +} + .register-form { gap: var(--sp-xxl); } +.account-row { + display: flex; + justify-content: center; + gap: var(--sp-s); + padding: 0; +} + +.account-text { + @include use-typography("title-small"); + + text-align: right; + color: var(--color-foreground-secondary); +} + +.account-link { + @include use-typography("title-small"); + + text-align: left; + background-color: transparent; + border: none; + display: inline; + color: var(--color-accent-primary); + + &:hover { + text-decoration: underline; + } +} + .register-success { gap: var(--sp-xxl); } -.register-success .auth-title { +.register-success-title { @include use-typography("title-medium"); line-height: 1.2; + color: var(--color-foreground-primary); } -.notification-text { - @include use-typography("body-medium"); - - color: var(--color-foreground-secondary); -} - -.notification-text-email { - @include use-typography("title-medium"); - - line-height: 1.2; - color: var(--color-accent-primary); - margin-inline: $sz-36; -} - -.logo-btn { - block-size: $sz-40; -} - -.logo-container { - display: flex; - justify-content: flex-start; - inline-size: $sz-120; - margin-block-end: var(--sp-xxl); -} - -.terms-register { +.terms { @include use-typography("body-small"); display: flex; @@ -63,15 +70,15 @@ inline-size: 100%; } -.and-text { - border-block-end: $b-1 solid transparent; - color: var(--color-foreground-secondary); -} - -.auth-link { +.terms-link { color: var(--color-accent-primary); &:hover { text-decoration: underline; } } + +.terms-and { + border-block-end: $b-1 solid transparent; + color: var(--color-foreground-secondary); +} diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 95b73d0191..543eede969 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -185,7 +185,7 @@ :on-click set-section} (tr "auth.login-here")]] [:div {:class (stl/css :links)} [:hr {:class (stl/css :separator)}] - [:> register/terms-register*]]] + [:> register/terms-service-privacy-policy*]]] :register-validate [:div {:class (stl/css :form-container)} diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index 0371714419..6ede841b73 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -12,8 +12,10 @@ [app.main.store :as st] [app.main.ui.auth.login :refer [login-dialog*]] [app.main.ui.auth.recovery-request :refer [recovery-request-page*]] - [app.main.ui.auth.register :refer [register-methods* register-success-page* terms-register* register-validate-form*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.auth.register :refer [register-methods* register-success-page* + register-validate-form* terms-service-privacy-policy*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -24,14 +26,15 @@ {::mf/register modal/components ::mf/register-as :login-register} [_] - (let [user-email (mf/use-state "") + (let [user-email (mf/use-state "") register-token (mf/use-state "") current-section* (mf/use-state :login) - current-section (deref current-section*) + current-section (deref current-section*) set-current-section - (mf/use-fn #(reset! current-section* %)) + (mf/use-fn + #(reset! current-section* %)) set-section (mf/use-fn @@ -41,7 +44,9 @@ (keyword))] (set-current-section section)))) - go-back-to-login (mf/use-fn #(set-current-section :login)) + go-back-to-login + (mf/use-fn + #(set-current-section :login)) main-section (or (= current-section :login) @@ -51,13 +56,16 @@ (fn [event] (dom/prevent-default event) (st/emit! (modal/hide))) + success-email-sent (fn [email] (reset! user-email email) (set-current-section :email-sent)) + success-login (fn [] (.reload js/window.location true)) + success-register (fn [data] (reset! register-token (:token data)) @@ -66,48 +74,47 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "labels.continue-with-penpot")] - [:button {:class (stl/css :modal-close-btn) - :title (tr "labels.close") - :on-click close} deprecated-icon/close]] + [:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")] + [:> icon-button* {:variant "ghost" + :class (stl/css :modal-close) + :aria-label (tr "labels.close") + :on-click close + :icon i/close}]] [:div {:class (stl/css :modal-content)} (case current-section :login - [:div {:class (stl/css :form-container)} - [:> login-dialog* - {:on-success-callback success-login - :origin :viewer}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :recovery-request)} + [:div {:class (stl/css :login-form)} + [:> login-dialog* {:on-success-callback success-login + :origin :viewer}] + [:div {:class (stl/css :login-links)} + [:div [:a {:on-click set-section - :class (stl/css :recovery-link) :data-value "recovery-request"} (tr "auth.forgot-password")]] - [:div {:class (stl/css :register)} - [:span {:class (stl/css :register-text)} + [:div + [:span (tr "auth.register") " "] [:a {:on-click set-section - :class (stl/css :register-link) :data-value "register"} (tr "auth.register-submit")]]]] :register - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-methods* {:on-success-callback success-register}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :account)} + [:div {:class (stl/css :login-links)} + [:div [:span (tr "auth.already-have-account") " "] [:a {:on-click set-section :data-value "login"} (tr "auth.login-here")]]]] :register-validate - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-validate-form* {:params {:token @register-token} :on-success-callback success-email-sent}] - [:div {:class (stl/css :links)} - [:div {:class (stl/css :register)} + [:div {:class (stl/css :login-links)} + [:div [:a {:on-click set-section :data-value "register"} (tr "labels.go-back")]]]] @@ -115,10 +122,11 @@ :recovery-request [:> recovery-request-page* {:go-back-callback go-back-to-login :on-success-callback success-email-sent}] + :email-sent - [:div {:class (stl/css :form-container)} + [:div {:class (stl/css :login-form)} [:> register-success-page* {:params {:email @user-email}}]]) (when main-section - [:div {:class (stl/css :links)} - [:> terms-register*]])]]])) + [:div {:class (stl/css :login-links)} + [:> terms-service-privacy-policy*]])]]])) diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/viewer/login.scss index 11cb81d678..227d160ea6 100644 --- a/frontend/src/app/main/ui/viewer/login.scss +++ b/frontend/src/app/main/ui/viewer/login.scss @@ -4,75 +4,71 @@ // // Copyright (c) KALEIDOS INC Sucursal en España SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--color-overlay-default); } .modal-container { - @extend %modal-container-base; - - width: deprecated.$s-368; + position: relative; + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-inline-size: $sz-364; + min-block-size: $sz-192; + max-inline-size: $sz-512; + max-block-size: $sz-712; + inline-size: $sz-364; } .modal-header { - margin-bottom: deprecated.$s-24; + margin-block-end: var(--sp-xxl); } -.modal-title { - @include deprecated.uppercase-title-typography; +.modal-header-title { + @include use-typography("headline-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); } -.modal-close-btn { - @extend %modal-close-btn-base; +.modal-close { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-s); } .modal-content { - @include deprecated.flex-column; - @include deprecated.body-small-typography; + @include use-typography("body-small"); - gap: deprecated.$s-24; - max-height: deprecated.$s-400; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + max-block-size: px2rem(576); overflow: hidden auto; - - form { - display: flex; - flex-direction: column; - margin-bottom: 1.5rem; - gap: 0.75rem; - } } -.form-container { +.login-form { display: flex; justify-content: center; flex-direction: column; + gap: var(--sp-m); } -.links { +.login-links { position: relative; -} - -.link-entry { - display: flex; - flex-direction: column; - gap: deprecated.$s-12; - - span { - text-align: center; - font-size: deprecated.$fs-14; - color: var(--modal-text-foreground-color); - margin-top: deprecated.$s-12; - } - - a { - @extend %button-secondary; - - height: deprecated.$s-40; - text-transform: uppercase; - font-size: deprecated.$fs-11; - } + color: var(--color-foreground-primary); } From 806f9473607c486891501134ec83ed7286389eff Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 31 Aug 2026 12:38:20 +0200 Subject: [PATCH 229/298] :bug: Fix avoid empty space at the bottom when there are many layers (#11427) --- .../src/app/main/ui/ds/layout/tab_switcher.scss | 1 + frontend/src/app/main/ui/workspace/sidebar.cljs | 4 +--- frontend/src/app/main/ui/workspace/sidebar.scss | 13 ++++++++++--- .../src/app/main/ui/workspace/sidebar/layers.scss | 9 ++++++--- .../src/app/main/ui/workspace/sidebar/sitemap.scss | 3 ++- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index c03a03e047..de1c34ccfc 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,6 +114,7 @@ display: grid; width: 100%; height: 100%; + min-block-size: 0; outline: $b-1 solid var(--tab-panel-outline-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index d66dcdc4ff..bb17de88fb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -97,9 +97,7 @@ sitemap-height (if sitemap-collapsed? 32 height)] - [:article {:class (stl/css :layers-tab) - :style {:--height (dm/str height "px")}} - + [:article {:class (stl/css :layers-tab)} [:> sitemap* {:layout layout :height sitemap-height :collapsed sitemap-collapsed? diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index 66239eb9e9..ebfaa57ea3 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -79,13 +79,15 @@ .layers-tab { padding-block-start: var(--sp-xs); - overflow-x: hidden; + display: flex; + flex-direction: column; + overflow: hidden; + min-block-size: 0; } .layers-tab-resize-area { background-color: var(--color-background-primary); - position: absolute; - inset-inline-start: 0; + flex: 0 0 auto; inline-size: 100%; padding: px2rem(3) 0 px2rem(1); block-size: $sz-6; @@ -120,11 +122,16 @@ .left-sidebar-content { grid-area: content; inset-inline-end: calc(-1 * var(--sp-s)); + min-block-size: 0; + overflow: hidden; } .left-sidebar-tabs { --tabs-nav-padding-inline-start: var(--sp-m); --tabs-nav-padding-inline-end: var(--sp-m); + + block-size: 100%; + min-block-size: 0; } .left-sidebar-resize-area { diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 328c92afdb..d329a3d2fb 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -31,11 +31,10 @@ } .tool-window-content { - --calculated-height: calc(#{px2rem(136)} + var(--height, #{$sz-200})); - display: flex; flex-direction: column; - block-size: calc(100vh - var(--calculated-height)); + flex: 1 1 auto; + min-block-size: 0; inline-size: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size)); overflow: auto; scrollbar-gutter: stable; @@ -117,6 +116,10 @@ .layers { position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-block-size: 0; } .replace-wrapper { diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index 4bfbe1c95d..e8f5e46574 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -15,7 +15,8 @@ position: relative; display: flex; flex-direction: column; - flex: 1; + flex: 0 0 auto; + flex-shrink: 0; inline-size: 100%; block-size: var(--height, $sz-200); } From 93ac6d8338367d4eecc04f3c3fd1aa49c98aabc9 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 31 Aug 2026 13:31:17 +0200 Subject: [PATCH 230/298] :sparkles: Add a new nitrate endpoint to check the airgapped flag (#11434) --- backend/src/app/rpc/management/nitrate.clj | 15 +++++++++++++++ .../backend_tests/rpc_management_nitrate_test.clj | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 9dc140ba4a..a470f2451e 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -1045,3 +1045,18 @@ RETURNING id, deleted_at;") (update acc :created conj email))))) {:created [] :skipped []} emails))))) + +;; ---- API: get-air-gapped + +(def ^:private schema:get-air-gapped-result + [:map + [:air-gapped ::sm/boolean]]) + +(sv/defmethod ::get-air-gapped + "Returns whether this Penpot instance runs in air-gapped mode." + {::doc/added "2.18" + ::sm/params [:map] + ::sm/result schema:get-air-gapped-result + ::rpc/auth false} + [_cfg _params] + {:air-gapped (contains? cf/flags :air-gapped-conf)}) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index aabf58f31d..da78b2e733 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -194,6 +194,15 @@ (string? (get version k))))) (t/is (= cf/version version)))) +(t/deftest get-air-gapped + (let [out (th/management-command! {::th/type :get-air-gapped})] + (t/is (th/success? out)) + (t/is (false? (-> out :result :air-gapped)))) + (binding [cf/flags (conj cf/flags :air-gapped-conf)] + (let [out (th/management-command! {::th/type :get-air-gapped})] + (t/is (th/success? out)) + (t/is (true? (-> out :result :air-gapped)))))) + (t/deftest get-teams-returns-only-owned-non-default-non-deleted (with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}] (let [profile (th/create-profile* 1 {:is-active true}) From fc207a176877ba360fd3350fc61e1d0a53db0d28 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Mon, 31 Aug 2026 14:00:14 +0200 Subject: [PATCH 231/298] :bug: Fix shapes flyout in toolbar only opens with the space key, and doesn't close when pressed again (#11331) --- .../app/main/ui/workspace/top_toolbar.cljs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 2590122475..86d13c8a5c 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -149,16 +149,26 @@ on-main-key-down (mf/use-fn + (mf/deps open) (fn [event] (cond - (kbd/space? event) + (and open (kbd/esc? event)) + (reset! open* false) + + (or (kbd/enter? event) (kbd/space? event)) + (do + (dom/prevent-default event) + (if open + (reset! open* false) + (do + (cancel-timer! close-timer*) + (reset! open* true)))) + + (kbd/down-arrow? event) (do (dom/prevent-default event) (cancel-timer! close-timer*) - (reset! open* true)) - - (and open (kbd/esc? event)) - (reset! open* false)))) + (reset! open* true))))) on-flyout-key-down (mf/use-fn @@ -218,7 +228,10 @@ :aria-expanded open :has-tooltip false :icon default-icon - :on-click on-select-tool + :on-click (fn [event] + (cancel-timer! open-timer*) + (cancel-timer! close-timer*) + (on-select-tool event)) :on-key-down on-main-key-down :data-tool (name default-tool)}] From 36cf4f39e1a0b82b2eec4eb4fe3981d6b1f19437 Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Mon, 31 Aug 2026 14:10:28 +0200 Subject: [PATCH 232/298] :sparkles: Apply method Penpot.waitForLayoutUpdate, removing workarounds (#11433) * MCP server: Update instruction manual to point to method instead of sleep * MCP plugin: Call waitForLayoutUpdate prior to every code execution * Composable test suite: Use waitForLayoutUpdate instead of 200ms delay in tests --- .../src/task-handlers/ExecuteCodeTaskHandler.ts | 11 +++++++++++ mcp/packages/server/data/initial_instructions.md | 12 ++++++++++-- .../src/composable-tests/operations/OpAssert.ts | 9 ++------- .../src/composable-tests/util/PenpotSync.ts | 9 ++++++++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index df2fb65832..87ea2c7616 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -214,6 +214,17 @@ export class ExecuteCodeTaskHandler extends TaskHandler { let result: any; try { + // wait for layout updates prior to executing the supplied code (if method is available) + try { + // @ts-ignore - TODO Penpot.waitForLayoutUpdate is not yet in the released types + if (penpot.waitForLayoutUpdate) { + // @ts-ignore + await penpot.waitForLayoutUpdate(); + } + } catch (e) { + console.error("Error waiting for layout update:", e); + } + // execute the code in an async function with the context variables as parameters result = await (async (ctx) => { const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index 34aaf95d16..be9177619c 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -16,6 +16,14 @@ This is the full list of types/interfaces in the Penpot API: $api_types You use the `storage` object extensively to store data and utility functions you define across tool calls. This allows you to inspect intermediate results while still being able to build on them in subsequent code executions. +## Asynchronous Updates + +Changes made to a design may take effect asynchronously. +So if you need to read the result of your executions/observe properties affected by a change in the same `execude_code` call, use +`await penpot.waitForLayoutUpdate();` +before trying to observe changes. +Every `execude_code` call waits for updates before applying your code, so you never need to call `waitForLayoutUpdate` at the beginning of your code. + # The Structure of Penpot Designs A Penpot design ultimately consists of shapes. @@ -144,7 +152,7 @@ Boards can have layout systems that automatically control the positioning and sp 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. + The auto-sizing is asynchronous; use `waitForLayoutUpdate` before reading 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` @@ -391,7 +399,7 @@ Applying tokens: - TokenTextDecorationProps: "textDecoration" - TokenTypographyProps: "typography" * `token.applyToShapes(shapes, properties)` - Apply from token - * Application is **asynchronous** (wait for ~100ms to see the effects) + * Application is **asynchronous** (use `waitForLayoutUpdate`) * After application: - `shape.tokens` returns a mapping `{ propertyName: "token.name" }` from `TokenProperty` to token name - The actual shape properties that the tokens control will reflect the token's resolved value. diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts index 3158075b9d..67951c0683 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpAssert.ts @@ -27,13 +27,8 @@ export class OpAssert extends Operation { } async applyTo(situation: Situation): Promise { - try { - this.assertion(situation); - } catch { - // a read may have raced propagation; let it settle and check once more - await PenpotSync.awaitPropagation(); - this.assertion(situation); - } + await PenpotSync.awaitPropagation(); + this.assertion(situation); } toString(): string { diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts index a2334ce832..ff4db17676 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts @@ -12,6 +12,13 @@ export class PenpotSync { * explicit "wait for propagation" primitive. */ static awaitPropagation(): Promise { - return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + // @ts-ignore + if (penpot.waitForLayoutUpdate) { + // @ts-ignore + return penpot.waitForLayoutUpdate(); + } else { + throw new Error("PenpotSync.awaitPropagation: waitForLayoutUpdate is not available"); + return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + } } } From ac5c88be6607ca58c28fe4f74056c0f941088afa Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastava Date: Mon, 31 Aug 2026 17:41:43 +0530 Subject: [PATCH 233/298] :books: Fix typo on docs (#11429) Signed-off-by: Vaibhav Srivastava --- docs/technical-guide/developer/devenv.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 5c3de5254a..bbecd6bb39 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -338,7 +338,7 @@ cd mcp pnpm run bootstrap:multi-user ``` -This will start the MCP server and the multi-user plugin that will be loaded automaticaly by Penpot. +This will start the MCP server and the multi-user plugin that will be loaded automatically by Penpot. There is a NGINX proxy that makes a proxy-pass from outside the docker container so you don't need to remember the ports it's using. From 66b4a99ac32e2ed95ee7a9483f4ad5b639f5fbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 31 Aug 2026 14:42:51 +0200 Subject: [PATCH 234/298] :tada: Implement export jobs to process export requests (#11296) * :sparkles: Add export job model, store and scheduler to exporter * :sparkles: Render wasm exports on pooled worker threads * :sparkles: Add export job REST API to exporter * :sparkles: Use export job API and allow cancelling wasm exports * :wrench: Show export jobs in the internal debug panel * :wrench: Pass flags and export job settings to the exporter container * :books: Document the exporter job API and its redis layout --- backend/resources/app/templates/debug.tmpl | 55 ++ backend/resources/app/templates/styles.css | 29 ++ backend/src/app/http/debug.clj | 47 +- backend/src/app/main.clj | 1 + backend/src/app/redis.clj | 39 ++ docker/images/docker-compose.yaml | 14 +- exporter/README.md | 99 ++++ exporter/package.json | 2 +- exporter/src/app/auth.cljs | 98 ++++ exporter/src/app/config.cljs | 20 +- exporter/src/app/core.cljs | 81 ++- exporter/src/app/handlers.cljs | 74 ++- exporter/src/app/handlers/export.cljs | 103 ++++ exporter/src/app/handlers/export_frames.cljs | 106 ++-- exporter/src/app/handlers/export_shapes.cljs | 177 +++---- exporter/src/app/handlers/jobs.cljs | 60 +++ exporter/src/app/http.cljs | 5 +- exporter/src/app/jobs.cljs | 284 +++++++++++ exporter/src/app/jobs/scheduler.cljs | 149 ++++++ exporter/src/app/jobs/store.cljs | 87 ++++ exporter/src/app/jobs/utils.cljs | 86 ++++ exporter/src/app/redis.cljs | 149 +++++- exporter/src/app/renderer.cljs | 29 +- exporter/src/app/renderer/wasm.cljs | 469 ++---------------- exporter/src/app/router.cljs | 61 +++ exporter/src/app/wasm.cljs | 28 +- exporter/src/app/wasm/pool.cljs | 246 +++++++++ exporter/src/app/wasm/render.cljs | 452 +++++++++++++++++ exporter/src/app/wasm/worker.cljs | 71 +++ .../exporter_tests/export_shapes_test.cljs | 31 ++ exporter/test/exporter_tests/jobs_test.cljs | 86 ++++ exporter/test/exporter_tests/runner.cljs | 12 +- .../test/exporter_tests/scheduler_test.cljs | 81 +++ .../test/exporter_tests/wasm_pool_test.cljs | 46 ++ .../src/app/main/data/exports/assets.cljs | 153 +++++- frontend/src/app/main/repo.cljs | 22 + frontend/src/app/main/ui/exports/assets.cljs | 41 +- frontend/translations/en.po | 20 + frontend/translations/es.po | 20 + render-wasm/src/main.rs | 7 +- 40 files changed, 2934 insertions(+), 706 deletions(-) create mode 100644 exporter/README.md create mode 100644 exporter/src/app/auth.cljs create mode 100644 exporter/src/app/handlers/export.cljs create mode 100644 exporter/src/app/handlers/jobs.cljs create mode 100644 exporter/src/app/jobs.cljs create mode 100644 exporter/src/app/jobs/scheduler.cljs create mode 100644 exporter/src/app/jobs/store.cljs create mode 100644 exporter/src/app/jobs/utils.cljs create mode 100644 exporter/src/app/router.cljs create mode 100644 exporter/src/app/wasm/pool.cljs create mode 100644 exporter/src/app/wasm/render.cljs create mode 100644 exporter/src/app/wasm/worker.cljs create mode 100644 exporter/test/exporter_tests/export_shapes_test.cljs create mode 100644 exporter/test/exporter_tests/jobs_test.cljs create mode 100644 exporter/test/exporter_tests/scheduler_test.cljs create mode 100644 exporter/test/exporter_tests/wasm_pool_test.cljs diff --git a/backend/resources/app/templates/debug.tmpl b/backend/resources/app/templates/debug.tmpl index c9579c0b1f..64ddd9d150 100644 --- a/backend/resources/app/templates/debug.tmpl +++ b/backend/resources/app/templates/debug.tmpl @@ -296,5 +296,60 @@ Debug Main Page
    + + + +
    +
    +
    + Export jobs: + + Export jobs as the exporter left them in redis. Records expire an hour + after the export settles, so this is a live view, not a history. + + +
    +
    + + + clear +
    +
    + +
    + + + + + + + + + + + + + + + {% for job in export-jobs %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
    JOB IDSTATEPROGRESSCMDBACKENDNAMECREATEDENDED
    {{job.id}}{{job.state}}{% if job.interrupted %} (interrupted){% endif %}{{job.done}} / {{job.total}}{{job.cmd}}{{job.backend}}{{job.name}}{{job.created-at}}{{job.ended-at}}
    No export jobs.
    +
    +
    +
    {% endblock %} diff --git a/backend/resources/app/templates/styles.css b/backend/resources/app/templates/styles.css index bbcc3fbb48..56c74d594a 100644 --- a/backend/resources/app/templates/styles.css +++ b/backend/resources/app/templates/styles.css @@ -143,6 +143,35 @@ nav > div:not(:last-child) { height: fit-content; } +/* A widget that holds a table rather than a form: full width, and tall + enough to be worth scrolling inside. */ +.dashboard.wide { + margin-top: 0px; +} + +.widget.wide { + max-width: none; + width: 100%; +} + +.widget.wide .scroll-box { + max-height: 320px; + overflow-y: auto; + margin-top: 10px; +} + +.widget.wide table { + width: 100%; + border-collapse: collapse; +} + +.widget.wide th { + text-align: left; + position: sticky; + top: 0; + background: white; +} + .widget input[type=submit] { outline: none; border: 1px solid gray; diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 1bbf306b9c..9daaeb28af 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -26,6 +26,7 @@ [app.db :as db] [app.features.file-migrations :as feat.fmig] [app.http.session :as session] + [app.redis :as rds] [app.rpc.commands.auth :as auth] [app.rpc.commands.files-create :refer [create-file]] [app.rpc.commands.profile :as profile] @@ -53,11 +54,53 @@ ;; INDEX ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private max-export-jobs 200) + +(defn- scan-export-job-keys + "Note: no index for now, get them all and filter" + [conn pattern] + (loop [cursor "0" + found []] + (let [[cursor keys] (rds/scan conn cursor pattern max-export-jobs) + found (into found keys)] + (if (or (nil? cursor) + (= "0" cursor) + (>= (count found) max-export-jobs)) + (into [] (take max-export-jobs) found) + (recur cursor found))))) + +(defn- get-export-jobs + [cfg job-id] + (let [filtered? (not (str/empty-or-nil? job-id)) + job-uuid (when filtered? (parse-uuid job-id))] + (if (and filtered? (nil? job-uuid)) + [] + (try + (let [pattern (str "penpot.exporter." (cf/get :tenant) ".job." (or job-uuid "*"))] + (->> (rds/run! cfg (fn [{:keys [::rds/conn]}] + (->> (scan-export-job-keys conn pattern) + (mapv (fn [key] (rds/hget conn key "data")))))) + (keep (fn [blob] + (try + (t/decode-str blob) + (catch Throwable _ nil)))) + (sort-by :created-at #(compare %2 %1)) + ;; The exporter stores instants as epoch millis. + (map (fn [{:keys [created-at ended-at] :as job}] + (-> job + (assoc :created-at (some-> created-at ct/inst (ct/format-inst :rfc1123))) + (assoc :ended-at (some-> ended-at ct/inst (ct/format-inst :rfc1123)))))) + (vec))) + (catch Throwable cause + (l/warn :hint "unable to read export jobs" :cause cause) + []))))) + (defn index-handler [cfg request] (let [profile-id (::session/profile-id request) offset (clock/get-offset profile-id) - profile (profile/get-profile cfg profile-id)] + profile (profile/get-profile cfg profile-id) + job-filter (some-> request :params :job-id str/trim)] {::yres/status 200 ::yres/headers {"content-type" "text/html"} ::yres/body (-> (io/resource "app/templates/debug.tmpl") @@ -69,6 +112,8 @@ (ct/format-duration offset) "NO OFFSET") :current-time (ct/format-inst (ct/now) :http) + :export-jobs (get-export-jobs cfg job-filter) + :export-job-filter job-filter :supported-features cfeat/supported-features}))})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index b75a3ee5b3..a17170fb1e 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -288,6 +288,7 @@ ::http.debug/routes {::db/pool (ig/ref ::db/pool) + ::rds/pool (ig/ref ::rds/pool) ::session/manager (ig/ref ::session/manager) ::mbus/msgbus (ig/ref ::mbus/msgbus) ::sto/storage (ig/ref ::sto/storage) diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 2539b9eddc..7a71d4ba66 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -29,6 +29,7 @@ io.lettuce.core.api.sync.RedisScriptingCommands io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec + io.lettuce.core.KeyScanCursor io.lettuce.core.KeyValue io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.pubsub.RedisPubSubListener @@ -40,6 +41,8 @@ io.lettuce.core.RedisURI io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources + io.lettuce.core.ScanArgs + io.lettuce.core.ScanCursor io.lettuce.core.ScriptOutputType io.lettuce.core.SetArgs io.netty.channel.nio.NioEventLoopGroup @@ -71,6 +74,8 @@ (-blpop [_ timeout keys]) (-eval [_ script]) (-get [_ key]) + (-scan [_ cursor pattern limit]) + (-hget [_ key field]) (-set [_ key val args]) (-del [_ key-or-keys]) (-ping [_])) @@ -205,6 +210,20 @@ (assert (string? key) "key expected to be string") (.get cmd ^String key)) + (-scan [_ cursor pattern limit] + (let [args (-> (ScanArgs.) + (.match ^String pattern) + (.limit (long limit))) + result (.scan cmd + ^ScanCursor (ScanCursor/of ^String cursor) + ^ScanArgs args)] + (MapEntry/create + (.getCursor ^KeyScanCursor result) + (vec (.getKeys ^KeyScanCursor result))))) + + (-hget [_ key field] + (.hget cmd ^String key ^String field)) + (-set [_ key val args] (.set cmd ^String key @@ -345,6 +364,26 @@ (l/err :hint "timeout on get redis key" :key key :cause cause) nil))) +(defn scan + [conn cursor pattern limit] + (assert (string? cursor) "cursor must be string instance") + (assert (string? pattern) "pattern must be string instance") + (try + (-scan conn cursor pattern limit) + (catch RedisCommandTimeoutException cause + (l/err :hint "timeout on scan" :pattern pattern :cause cause) + nil))) + +(defn hget + [conn key field] + (assert (string? key) "key must be string instance") + (assert (string? field) "field must be string instance") + (try + (-hget conn key field) + (catch RedisCommandTimeoutException cause + (l/err :hint "timeout on hget" :key key :cause cause) + nil))) + (defn set ([conn key val] (set conn key val nil)) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 59b326c76d..b8edda5239 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -197,14 +197,24 @@ services: - penpot environment: - << : [*penpot-secret-key, *penpot-public-uri] + << : [*penpot-flags, *penpot-secret-key, *penpot-public-uri] # Don't touch it; this uses an internal docker network to # communicate with the frontend. PENPOT_INTERNAL_URI: http://penpot-frontend:8080 - ## Valkey (or previously Redis) is used for the websockets notifications. + ## Valkey (or previously Redis) is used for the websockets notifications + ## and for storing the state export jobs PENPOT_REDIS_URI: redis://penpot-valkey/0 + # PENPOT_EXPORTER_MAX_CONCURRENT_JOBS: 4 + # PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE: 2 + # PENPOT_EXPORTER_QUEUE_MAX: 64 + # PENPOT_EXPORTER_JOB_TTL: 3600 + # PENPOT_WASM_WORKER_POOL_MAX: 2 + # PENPOT_WASM_WORKER_POOL_MIN: 1 + # PENPOT_WASM_WORKER_IDLE_TIMEOUT: 300 + # PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE: 134217728 + penpot-postgres: image: "postgres:15" restart: always diff --git a/exporter/README.md b/exporter/README.md new file mode 100644 index 0000000000..df4d4412db --- /dev/null +++ b/exporter/README.md @@ -0,0 +1,99 @@ +# Exporter + +Node service that renders shapes and files to bitmap, SVG and PDF. Wasm exports +are **jobs**: created over HTTP, admitted by a scheduler with bounded +concurrency, and persisted in Redis so their state can be queried and cancelled. +The legacy entry point, which is what the browser backend still goes through, +runs the export as soon as it is asked for, with no admission control. + +## HTTP API + +Mounted under `/api/export` (the router matches on the path *after* that prefix, +so it also works when the process is hit directly on `/`). + +| Method | Path | Description | +|----------|-----------------|----------------------------------------------------| +| `POST` | `/` | Legacy command multiplex; runs unscheduled | +| `POST` | `/jobs` | Create an export job | +| `GET` | `/jobs/{id}` | Job record | +| `DELETE` | `/jobs/{id}` | Request cancellation | + +Job states: `queued` -> `running` -> `ended` | `error` | `cancelled`. The last +three are terminal. + +## Redis layout + +Every key is namespaced with `penpot.exporter.` plus the tenant +(`PENPOT_TENANT`, `default` in code but set to the workspace name in devenv, +e.g. `devenv-ws0`). + +``` +penpot.exporter.{tenant}.job.{job-id} hash field: data (transit blob of the + whole record) +penpot.exporter.{tenant}.job-cancel pubsub payload: the job id, one line +``` + +There is no index: the keyspace is one self-expiring hash per job and nothing +else. Each hash carries the same TTL as the exported file +(`PENPOT_EXPORTER_JOB_TTL`, default 3600s), refreshed on every write and never +after the job settles. + +## Inspecting Redis + +Redis is not published on the host, so `redis-cli` from your machine gets +connection refused. Run it **inside the devenv container**, against the `valkey` +host on database 0: + +```bash +redis-cli -h valkey -n 0 +``` + +`redis-cli -u "$PENPOT_REDIS_URI"` does the same and follows whatever the env is +set to (`redis://valkey/0` in devenv). + +Keys carry the tenant, which in devenv is the **workspace name** +(`$PENPOT_TENANT`, e.g. `devenv-ws0`), not `default`. From the prompt: + +``` +# every job record +KEYS penpot.exporter.devenv-ws0.job.* + +# the whole record, transit-json in the `data` field +HGET penpot.exporter.devenv-ws0.job. data + +# seconds left before the record expires +TTL penpot.exporter.devenv-ws0.job. + +# watch cancellations as they are published (blocks the connection) +SUBSCRIBE penpot.exporter.devenv-ws0.job-cancel + +# drop one record +DEL penpot.exporter.devenv-ws0.job. +``` + +`KEYS` is fine here -- the keyspace is a handful of job hashes. On a real +deployment use `SCAN 0 MATCH penpot.exporter..job.* COUNT 100` instead. +Do not `FLUSHDB`: the backend shares this database. + +The backend debug UI also renders these records: `/dbg` has an *Export jobs* +section, with a `?job-id=` filter. + +## Configuration + +| Variable | Default | Description | +|---------------------------------------|---------|--------------------------------------| +| `PENPOT_REDIS_URI` | `redis://redis/0` | Job store and cancel topic | +| `PENPOT_TENANT` | `default` | Key and topic prefix | +| `PENPOT_EXPORTER_JOB_TTL` | `3600` | Lifetime of a job record, in seconds | +| `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` | `4` | Admission limit | +| `PENPOT_EXPORTER_MAX_JOBS_PER_PROFILE`| `2` | Per-profile admission limit | +| `PENPOT_EXPORTER_QUEUE_MAX` | `64` | Queue cap; over it, `429 :queue-full` | +| `PENPOT_WASM_WORKER_POOL_MAX` | `2` | Headless render worker threads; min 1 | +| `PENPOT_WASM_WORKER_POOL_MIN` | `1` | Workers kept warm; clamped to the max | +| `PENPOT_WASM_WORKER_IDLE_TIMEOUT` | `300` | Silence before a worker is terminated, in seconds | +| `PENPOT_WASM_WORKER_IMAGE_CACHE_SIZE` | `134217728` | Per-worker image cache budget, in bytes | + +A headless job leases one render worker for its whole run, so it is admitted +only when a worker is free: `PENPOT_WASM_WORKER_POOL_MAX` is the real limit for +them, and `PENPOT_EXPORTER_MAX_CONCURRENT_JOBS` bounds the browser ones +alongside. diff --git a/exporter/package.json b/exporter/package.json index 680eafdab7..6174fe4257 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -39,7 +39,7 @@ "check-fmt:clj": "cljfmt check --parallel=true src/ test/", "lint:clj": "clj-kondo --parallel --lint src/ test/", "build:test": "clojure -M:dev:shadow-cljs compile test", - "test": "pnpm run build:test && node target/tests/test.js", + "test": "pnpm run build:test && PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY:-test-secret-key} node target/tests/test.js", "test:quiet": "node ./scripts/test-quiet.js" } } diff --git a/exporter/src/app/auth.cljs b/exporter/src/app/auth.cljs new file mode 100644 index 0000000000..d6ac0cf042 --- /dev/null +++ b/exporter/src/app/auth.cljs @@ -0,0 +1,98 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.auth + "Resolves the caller's session cookie to a real profile id. + + The export commands take `:profile-id` from the request body, which was + harmless while it only picked a pub/sub topic. The job API can read and + cancel other people's work, so its ownership comes from the session: the + token goes to the backend's `get-profile` command, which answers with the + anonymous profile (`uuid/zero`) when it is not a valid session. + + Results are memoized briefly, so a burst of export calls from one client is + one round trip rather than one per request." + (:require + ["undici" :as http] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [promesa.core :as p])) + +(def ^:private cache-ttl-ms 60000) + +(defonce ^:private cache (atom {})) + +(defn- put-in-cache + "Stores the resolution by session token and removes expired entries. + Without cleanup, long-lived exporters accumulate stale entries" + [cache token profile-id now] + (-> (into {} (remove (fn [[_ {:keys [expires-at]}]] (<= expires-at now))) cache) + (assoc token {:profile-id profile-id + :expires-at (+ now cache-ttl-ms)}))) + +(defn- rpc-uri + [] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join "api/rpc/command/get-profile") + (str))) + +(defn- fetch-profile-id + [token] + (let [uri (rpc-uri) + headers #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}] + (->> (p/do (http/fetch uri #js {:method "POST" :headers headers :body (t/encode-str {})})) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [body] + (some-> body t/decode-str :id))) + (p/merr (fn [cause] + (l/warn :hint "unable to resolve session profile" :uri uri :cause cause) + (p/resolved nil)))))) + +(defn resolve-profile-id + "Promise of the authenticated profile id, or nil for an anonymous or absent + session." + [token] + (if (nil? token) + (p/resolved nil) + (let [{:keys [profile-id expires-at]} (get @cache token)] + (if (and expires-at (> expires-at (js/Date.now))) + (p/resolved profile-id) + (->> (fetch-profile-id token) + (p/fmap (fn [profile-id] + (let [profile-id (when (and profile-id (not= uuid/zero profile-id)) profile-id)] + (swap! cache put-in-cache token profile-id (js/Date.now)) + profile-id)))))))) + +(defn require-profile-id + "Like `resolve-profile-id`, but rejects anonymous callers." + [token] + (->> (resolve-profile-id token) + (p/mcat (fn [profile-id] + (if profile-id + (p/resolved profile-id) + (ex/raise :type :authentication + :code :authentication-required + :hint "no valid session for this request")))))) + +(defn check-owner! + "Raises unless `profile-id` owns `job`." + [job profile-id] + (when (or (nil? job) + (not= (str (:profile-id job)) (str profile-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "job does not exist")) + job) diff --git a/exporter/src/app/config.cljs b/exporter/src/app/config.cljs index df88aae0e6..0fcc5a052d 100644 --- a/exporter/src/app/config.cljs +++ b/exporter/src/app/config.cljs @@ -28,7 +28,15 @@ :http-server-port 6061 :http-server-host "0.0.0.0" :tempdir "/tmp/penpot" - :redis-uri "redis://redis/0"}) + :redis-uri "redis://redis/0" + :exporter-max-concurrent-jobs 4 + :exporter-max-jobs-per-profile 2 + :exporter-queue-max 64 + :exporter-job-ttl 3600 + :wasm-worker-pool-max 2 + :wasm-worker-pool-min 1 + :wasm-worker-idle-timeout 300 + :wasm-worker-image-cache-size (* 128 1024 1024)}) (def ^:private schema:config [:map {:title "config"} @@ -42,7 +50,15 @@ [:redis-uri {:optional true} :string] [:tempdir {:optional true} :string] [:browser-pool-max {:optional true} ::sm/int] - [:browser-pool-min {:optional true} ::sm/int]]) + [:browser-pool-min {:optional true} ::sm/int] + [:exporter-max-concurrent-jobs {:optional true} ::sm/int] + [:exporter-max-jobs-per-profile {:optional true} ::sm/int] + [:exporter-queue-max {:optional true} ::sm/int] + [:exporter-job-ttl {:optional true} ::sm/int] + [:wasm-worker-pool-max {:optional true} ::sm/int] + [:wasm-worker-pool-min {:optional true} ::sm/int] + [:wasm-worker-idle-timeout {:optional true} ::sm/int] + [:wasm-worker-image-cache-size {:optional true} ::sm/int]]) (def ^:private decode-config (sm/decoder schema:config sm/string-transformer)) diff --git a/exporter/src/app/core.cljs b/exporter/src/app/core.cljs index a69d0a63c9..ec04fa7685 100644 --- a/exporter/src/app/core.cljs +++ b/exporter/src/app/core.cljs @@ -7,51 +7,88 @@ (ns app.core (:require ["node:process" :as proc] + ["node:worker_threads" :as wt] [app.browser :as bwr] [app.common.logging :as l] [app.config :as cf] [app.http :as http] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.redis :as redis] [app.wasm :as wasm] + [app.wasm.pool :as wasm.pool] + [app.wasm.worker :as wasm.worker] [promesa.core :as p])) (enable-console-print!) (l/setup! {:app :info}) (defn start + "Render workers run this same bundle, so the thread decides what gets booted: + the http server and its pools, or one render worker." [& _] - (l/info :msg "initializing" - :public-uri (str (cf/get :public-uri)) - :internal-uri (str (cf/get-internal-uri)) - :version (:full cf/version)) - (when (contains? cf/flags :wasm-export) - (l/warn :msg "headless wasm export enabled (experimental)" - :hint (str "renders run in-process on a single shared wasm module, " - "one at a time; not recommended for busy instances") - :wasm-dir wasm/artifact-dir - :image-cache-mb wasm/image-cache-mb)) - (p/do! - (bwr/init) - (redis/init) - (http/init))) + (if-not ^boolean wt/isMainThread + (wasm.worker/main) + (do + (l/info :msg "initializing" + :public-uri (str (cf/get :public-uri)) + :internal-uri (str (cf/get-internal-uri)) + :version (:full cf/version)) + (when (contains? cf/flags :wasm-export) + (l/info :msg "headless wasm export enabled (experimental)" + :wasm-dir wasm/artifact-dir + :workers (cf/get :wasm-worker-pool-max) + :image-cache-size (cf/get :wasm-worker-image-cache-size))) + (p/do + (bwr/init) + (redis/init) + (jobs/init) + (job.utils/init) + (wasm.pool/init) + (http/init))))) (def main start) +;; Draining a pool waits for every checked-out resource to come back, which an +;; export in flight can hold for as long as its own timeout. On a hot reload +;; that would block `start` from ever running again, leaving a drained pool that +;; fails every later job. +(def ^:private shutdown-step-timeout 3000) + +(defn- shutdown-step + [label f] + (-> (p/race [(p/do (f)) + (p/fmap (constantly ::timeout) (p/delay shutdown-step-timeout))]) + (p/handle (fn [result cause] + (when (or (some? cause) (= ::timeout result)) + (l/warn :hint "shutdown step did not finish cleanly" + :step label + :cause cause)) + nil)))) + (defn stop [done] ;; an empty line for visual feedback of restart (js/console.log "") - (l/info :msg "stopping") - (p/do! - (bwr/stop) - (redis/stop) - (http/stop) - (done))) + (if-not ^boolean wt/isMainThread + ;; A render worker owns no server, pools or connections; nothing to unwind. + (done) + (do + (l/info :msg "stopping") + (p/do + (shutdown-step "browser-pool" bwr/stop) + (shutdown-step "wasm-worker-pool" wasm.pool/stop) + (shutdown-step "redis" redis/stop) + (shutdown-step "http" http/stop) + (done))))) (.on proc/default "uncaughtException" (fn [cause] (js/console.error cause))) -(.on proc/default "SIGTERM" (fn [] (proc/exit 0))) -(.on proc/default "SIGINT" (fn [] (proc/exit 0))) +;; Signals are only delivered to the main thread, and `exit` in a worker would +;; take down that worker rather than the process. +(when ^boolean wt/isMainThread + (.on proc/default "SIGTERM" (fn [] (proc/exit 0))) + (.on proc/default "SIGINT" (fn [] (proc/exit 0)))) diff --git a/exporter/src/app/handlers.cljs b/exporter/src/app/handlers.cljs index b40911fbf1..bd2b453951 100644 --- a/exporter/src/app/handlers.cljs +++ b/exporter/src/app/handlers.cljs @@ -6,22 +6,44 @@ (ns app.handlers (:require + [app.auth :as auth] [app.common.data :as d] - [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] - [app.handlers.export-frames :as export-frames] - [app.handlers.export-shapes :as export-shapes] + [app.handlers.export :as export] [app.util.transit :as t] [clojure.spec.alpha :as s] - [cuerdas.core :as str])) + [promesa.core :as p])) (l/set-level! :debug) +(def ^:private error-codes + #{:queue-full}) + (defn on-error [error exchange] (let [{:keys [type code] :as data} (ex-data error)] (cond + (and (= :validation type) + (contains? error-codes code)) + (let [data {:type :validation + :code code + :hint (ex-message error)}] + (l/warn :hint "rejecting export request" :code code) + (-> exchange + (assoc :response/status 429) + (assoc :response/body (t/encode data)) + (assoc :response/headers {"content-type" "application/transit+json"}))) + + (= :authentication type) + (let [data {:type :authentication + :code code + :hint (ex-message error)}] + (-> exchange + (assoc :response/status 401) + (assoc :response/body (t/encode data)) + (assoc :response/headers {"content-type" "application/transit+json"}))) + (or (= :validation type) (= :assertion type)) (let [explain (us/pretty-explain data) @@ -62,27 +84,27 @@ (assoc :response/body (t/encode (d/without-nils data))) (assoc :response/headers {"content-type" "application/transit+json"})))))) -(defmulti command-spec :cmd) - -(s/def ::id ::us/string) -(s/def ::wait ::us/boolean) -(s/def ::cmd ::us/keyword) - -(defmethod command-spec :export-shapes [_] ::export-shapes/params) -(defmethod command-spec :export-frames [_] ::export-frames/params) - -(s/def ::params - (s/and (s/keys :req-un [::cmd] - :opt-un [::wait]) - (s/multi-spec command-spec :cmd))) - (defn handler - [{:keys [:request/params] :as exchange}] - (let [{:keys [cmd] :as params} (us/conform ::params params)] + "The original `POST /api/export` entry point, and the one the browser backend + still goes through. The export runs as soon as it is asked for, and the + contract is unchanged: `:wait` answers with the finished resource, otherwise + with the resource handle while the work runs." + [{:keys [:request/params :request/auth-token] :as exchange}] + (let [{:keys [cmd wait] :as params} (export/conform-params params)] (l/debug :hint "process-request" :cmd cmd) - (case cmd - :export-shapes (export-shapes/handler exchange params) - :export-frames (export-frames/handler exchange params) - (ex/raise :type :internal - :code :method-not-implemented - :hint (str/istr "method ~{cmd} not implemented"))))) + (->> (auth/resolve-profile-id auth-token) + (p/mcat (fn [profile-id] + ;; The session wins when there is one; the body value stays + ;; the fallback so nothing that used to work stops working. + (export/export! auth-token (cond-> params + (some? profile-id) + (assoc :profile-id profile-id))))) + (p/mcat (fn [{:keys [resource pending]}] + (if wait + (p/fmap (fn [resource] + (assoc exchange :response/body resource)) + pending) + (do + (p/merr (constantly nil) pending) + (p/resolved + (assoc exchange :response/body (dissoc resource :path)))))))))) diff --git a/exporter/src/app/handlers/export.cljs b/exporter/src/app/handlers/export.cljs new file mode 100644 index 0000000000..a7b309f652 --- /dev/null +++ b/exporter/src/app/handlers/export.cljs @@ -0,0 +1,103 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.handlers.export + "Handle export jobs" + (:require + [app.common.spec :as us] + [app.handlers.export-frames :as export-frames] + [app.handlers.export-shapes :as export-shapes] + [app.jobs :as jobs] + [app.jobs.scheduler :as scheduler] + [app.jobs.utils :as job.utils] + [clojure.spec.alpha :as s] + [promesa.core :as p])) + +;; --- PARAMS + +(defmulti command-spec :cmd) + +(s/def ::cmd ::us/keyword) +(s/def ::wait ::us/boolean) + +(defmethod command-spec :export-shapes [_] ::export-shapes/params) +(defmethod command-spec :export-frames [_] ::export-frames/params) + +(s/def ::params + (s/and (s/keys :req-un [::cmd] + :opt-un [::wait]) + (s/multi-spec command-spec :cmd))) + +(defn conform-params + [params] + (us/conform ::params params)) + +(defn- prepare + [cmd auth-token params] + (case cmd + :export-shapes (export-shapes/prepare auth-token params) + :export-frames (export-frames/prepare auth-token params))) + +(defn- current + [job] + (or (jobs/lookup (:id job)) job)) + +(defn- run-and-track + [job run] + (->> (p/do (run job)) + (p/mcat (fn [resource] + (->> (jobs/complete! (current job) resource) + (p/fmap (constantly resource))))) + (p/merr (fn [cause] + (if (jobs/cancelled? (:id job)) + (p/rejected cause) + (->> (jobs/fail! (current job) cause) + (p/mcat (fn [_] (p/rejected cause))))))))) + +(defn- run-now! + "Runs the job as soon as it is created, outside the scheduler." + [job] + (->> (p/do (jobs/start! job)) + (p/mcat (fn [job] (p/do ((jobs/run-fn (:id job)) job)))) + (p/fnly (fn [_ _] + (jobs/release! (:id job)) + (job.utils/release! (:id job)))))) + +(defn- create! + [auth-token {:keys [cmd profile-id] :as params} start] + (let [{:keys [resource total headless run]} (prepare cmd auth-token params)] + (->> (jobs/create! {:profile-id profile-id + :cmd cmd + ;; What the renderer will actually do, not what the + ;; client asked for: `is-wasm` alone still renders in + ;; the browser without the `wasm-export` flag, or for + ;; svg, and the backend decides both the admission cap + ;; and whether the client offers to cancel. + :backend (if headless "wasm" "browser") + :total total + :name (:name resource) + :resource-id (:id resource)} + (fn [job] (run-and-track job run))) + (p/fmap (fn [job] + (try + {:job job + :resource resource + :pending (start job)} + (catch :default cause + (jobs/fail! job cause) + (jobs/release! (:id job)) + (throw cause)))))))) + +(defn create-job! + "Returns a promise of `{:job :resource :pending}`." + [auth-token params] + (create! auth-token params scheduler/submit!)) + +(defn export! + "Like `create-job!`, but the work starts right away: This is what + keeps browser exports behaving exactly as they did before there were jobs." + [auth-token params] + (create! auth-token params run-now!)) diff --git a/exporter/src/app/handlers/export_frames.cljs b/exporter/src/app/handlers/export_frames.cljs index f13a389eaa..ed290d4b50 100644 --- a/exporter/src/app/handlers/export_frames.cljs +++ b/exporter/src/app/handlers/export_frames.cljs @@ -6,19 +6,17 @@ (ns app.handlers.export-frames (:require - [app.common.logging :as l] [app.common.spec :as us] - [app.handlers.export-shapes :refer [prepare-exports]] + [app.handlers.export-shapes :refer [count-objects headless-exports? prepare-exports]] [app.handlers.resources :as rsc] - [app.redis :as redis] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.renderer :as rd] [app.util.shell :as sh] [cljs.spec.alpha :as s] [cuerdas.core :as str] [promesa.core :as p])) -(declare ^:private handle-export) -(declare ^:private create-pdf) (declare ^:private join-pdf) (declare ^:private move-file) @@ -38,85 +36,53 @@ (s/keys :req-un [::exports] :opt-un [::name ::is-wasm])) -(defn handler - [{:keys [:request/auth-token] :as exchange} {:keys [exports] :as params}] - ;; NOTE: we need to have the `:type` prop because the exports - ;; datastructure preparation uses it for creating the groups. - (let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports) - (prepare-exports auth-token))] - - (handle-export exchange (assoc params :exports exports)))) - -(defn handle-export - [{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id is-wasm] :as params}] - (let [topic (str profile-id) - file-id (-> exports first :file-id) - - resource - (rsc/create :pdf (or name (-> exports first :name))) - - on-progress - (fn [done] - (let [data {:type :export-update - :resource-id (:id resource) - :status "running" - :done done}] - (redis/pub! topic data))) - - on-complete - (fn [resource] - (let [data {:type :export-update - :resource-id (:id resource) - :resource-uri (:uri resource) - :name (:name resource) - :filename (:filename resource) - :mtype (:mtype resource) - :status "ended"}] - (redis/pub! topic data))) - - on-error - (fn [cause] - (l/error :hint "unexpected error on frames exportation" :cause cause) - (let [data {:type :export-update - :resource-id (:id resource) - :name (:name resource) - :filename (:filename resource) - :status "error" - :cause (ex-message cause)}] - (redis/pub! topic data))) - - result-cache - (atom []) +(defn- run-export + [job auth-token resource {:keys [exports is-wasm file-id]}] + (let [rendered (atom []) on-object - (fn [{:keys [path] :as object}] - (let [res (swap! result-cache conj path)] - (on-progress (count res)))) + (fn [{:keys [path] :as _object}] + (job.utils/track! (:id job) path) + (jobs/progress! job (count (swap! rendered conj path)))) - procs - (->> (seq exports) - (map #(rd/render (assoc % :is-wasm is-wasm) on-object)))] + exports + (map #(assoc % :is-wasm is-wasm :job-id (:id job)) exports)] - (->> (p/all procs) - (p/fmap (fn [] @result-cache)) - (p/mcat (partial join-pdf file-id)) + (job.utils/track! (:id job) (:path resource)) + (->> (rd/with-scope exports + (fn [render] + (jobs/check-cancelled! job) + (->> exports + (map (fn [export] (render export on-object))) + (p/all)))) + (p/fmap (fn [_] @rendered)) + (p/mcat (partial join-pdf job file-id)) (p/mcat (partial move-file resource)) (p/fmap (constantly resource)) (p/mcat (partial rsc/upload-resource auth-token)) (p/mcat (fn [resource] (->> (sh/stat (:path resource)) (p/fmap #(merge resource %))))) - (p/merr on-error) - (p/fnly (fn [resource cause] - (when-not cause - (on-complete resource))))) + (p/fmap (fn [resource] (dissoc resource :path)))))) - (assoc exchange :response/body (dissoc resource :path)))) +(defn prepare + [auth-token {:keys [exports name is-wasm] :as _params}] + (let [exports (-> (map #(assoc % :type :pdf :scale 1 :suffix "") exports) + (prepare-exports auth-token is-wasm)) + resource (rsc/create :pdf (or name (-> exports first :name))) + file-id (-> exports first :file-id)] + {:resource resource + :total (count-objects exports) + :headless (headless-exports? exports is-wasm) + :run (fn [job] (run-export job auth-token resource + {:exports exports + :is-wasm is-wasm + :file-id file-id}))})) (defn- join-pdf - [file-id paths] + [job file-id paths] (p/let [prefix (str/concat "penpot.pdfunite." file-id ".") - path (sh/tempfile :prefix prefix :suffix ".pdf")] + path (job.utils/track! (:id job) (sh/tempfile :prefix prefix :suffix ".pdf"))] (apply sh/run-cmd! "pdfunite" (conj (vec paths) path)) path)) diff --git a/exporter/src/app/handlers/export_shapes.cljs b/exporter/src/app/handlers/export_shapes.cljs index a07b58ad72..954285ba1e 100644 --- a/exporter/src/app/handlers/export_shapes.cljs +++ b/exporter/src/app/handlers/export_shapes.cljs @@ -7,10 +7,10 @@ (ns app.handlers.export-shapes (:require [app.common.data :as d] - [app.common.logging :as l] [app.common.spec :as us] [app.handlers.resources :as rsc] - [app.redis :as redis] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] [app.renderer :as rd] [app.util.mime :as mime] [app.util.shell :as sh] @@ -18,9 +18,6 @@ [cuerdas.core :as str] [promesa.core :as p])) -(declare ^:private handle-single-export) -(declare ^:private handle-multiple-export) -(declare ^:private assoc-file-name) (declare prepare-exports) ;; Regex to clean namefiles @@ -50,87 +47,92 @@ (s/keys :req-un [::exports ::profile-id] :opt-un [::wait ::name ::skip-children ::force-multiple ::is-wasm])) -(defn handler - [{:keys [:request/auth-token] :as exchange} {:keys [exports force-multiple] :as params}] - (let [exports (prepare-exports exports auth-token)] - (if (and (not force-multiple) - (= 1 (count exports)) - (= 1 (count (-> exports first :objects)))) - (handle-single-export exchange (-> params - (assoc :export (first exports)) - (dissoc :exports))) - (handle-multiple-export exchange (assoc params :exports exports))))) +(defn count-objects + [exports] + (reduce + 0 (map (comp count :objects) exports))) -(defn- handle-single-export - [{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}] - (let [resource (rsc/create (:type export) (or name (:name export))) - export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))] +(defn- render! + [job export on-object] + (jobs/check-cancelled! job) + (rd/render (assoc export :job-id (:id job)) on-object)) - (->> (rd/render export - (fn [{:keys [path] :as object}] - (sh/move! path (:path resource)))) +(defn- scoped-renders + "Renders every export, the headless ones sharing a single worker." + [job exports on-object] + (rd/with-scope exports + (fn [render] + (jobs/check-cancelled! job) + (->> exports + (map (fn [export] (render export on-object))) + (p/all))))) + +(defn- run-single + [job auth-token resource {:keys [export is-wasm skip-children]}] + (job.utils/track! (:id job) (:path resource)) + (->> (render! job + (assoc export :skip-children skip-children :is-wasm (boolean is-wasm)) + (fn [{:keys [path] :as _object}] + (job.utils/track! (:id job) path) + (sh/move! path (:path resource)))) + (p/fmap (constantly resource)) + (p/mcat (partial rsc/upload-resource auth-token)) + (p/fmap (fn [resource] (dissoc resource :path))))) + +(defn- run-multiple + [job auth-token resource {:keys [exports is-wasm]}] + (let [failure (volatile! nil) + + zip (rsc/create-zip :resource resource + :on-error (fn [cause] (vreset! failure cause)) + :on-progress (fn [{:keys [done]}] + (jobs/progress! job done))) + + append (fn [{:keys [filename path] :as _object}] + (job.utils/track! (:id job) path) + (rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))] + + (job.utils/track! (:id job) (:path resource)) + (->> (scoped-renders job + (map #(assoc % :is-wasm (boolean is-wasm) :job-id (:id job)) exports) + append) + (p/mcat (fn [_] + (if-let [cause @failure] + (p/rejected cause) + (rsc/close-zip zip)))) (p/fmap (constantly resource)) (p/mcat (partial rsc/upload-resource auth-token)) - (p/fmap (fn [resource] - (dissoc resource :path))) - (p/fmap (fn [resource] - (assoc exchange :response/body resource))) - (p/merr (fn [cause] - (l/error :hint "unexpected error on single export" - :cause cause) - (p/rejected cause)))))) + (p/fmap (fn [resource] (dissoc resource :path)))))) -(defn- handle-multiple-export - [{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}] - (let [resource (rsc/create :zip (or name (-> exports first :name))) - total (count exports) - topic (str profile-id) +(defn headless-exports? + "Whether any of `exports` renders headless, and so whether the job leases a + render worker. Mirrors what `rd/with-scope` decides at run time." + [exports is-wasm] + (boolean (some #(rd/headless? {:is-wasm is-wasm :type (:type %)}) exports))) - on-progress (fn [{:keys [done]}] - (when-not wait - (let [data {:type :export-update - :resource-id (:id resource) - :status "running" - :total total - :done done}] - (redis/pub! topic data)))) +(defn prepare + [auth-token {:keys [exports force-multiple name skip-children is-wasm] :as _params}] + (let [exports (prepare-exports exports auth-token is-wasm) + headless? (headless-exports? exports is-wasm) + single? (and (not force-multiple) + (= 1 (count exports)) + (= 1 (count (-> exports first :objects))))] + (if single? + (let [export (first exports) + resource (rsc/create (:type export) (or name (:name export)))] + {:resource resource + :total 1 + :headless headless? + :run (fn [job] (run-single job auth-token resource + {:export export + :is-wasm is-wasm + :skip-children skip-children}))}) - on-error (fn [cause] - (l/error :hint "unexpected error on multiple export" :cause cause) - (if wait - (p/rejected cause) - (redis/pub! topic {:type :export-update - :resource-id (:id resource) - :status "error" - :cause (ex-message cause)}))) - - zip (rsc/create-zip :resource resource - :on-error on-error - :on-progress on-progress) - - append (fn [{:keys [filename path] :as resource}] - (rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_"))) - - proc (->> exports - (map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append))) - (p/all) - (p/mcat (fn [_] (rsc/close-zip zip))) - (p/fmap (constantly resource)) - (p/mcat (partial rsc/upload-resource auth-token)) - (p/fmap (fn [resource] - (let [data {:type :export-update - :name (:name resource) - :filename (:filename resource) - :resource-id (:id resource) - :resource-uri (:uri resource) - :mtype (:mtype resource) - :status "ended"}] - (p/do (redis/pub! topic data) - (assoc exchange :response/body resource))))) - (p/merr on-error))] - (if wait - (p/then proc #(assoc exchange :response/body (dissoc % :path))) - (assoc exchange :response/body (dissoc resource :path))))) + (let [resource (rsc/create :zip (or name (-> exports first :name)))] + {:resource resource + :total (count-objects exports) + :headless headless? + :run (fn [job] (run-multiple job auth-token resource + {:exports exports :is-wasm is-wasm}))})))) (defn- assoc-file-name "A transducer that assocs a candidate filename and avoid duplicates" @@ -160,13 +162,18 @@ default-partition-size 50) (defn prepare-exports - [exports token] - (letfn [(process-group [group] - (sequence (comp (partition-all default-partition-size) - (map process-partition)) - group)) + [exports token is-wasm] + (letfn [(process-group [[part1 :as group]] + ;; The browser renders a partition as a single DOM page, so it is + ;; chunked to bound that page. A wasm export is headless, so + ;; it does not need to be chunked, and can be rendered as a single partition. + (if (rd/headless? {:is-wasm is-wasm :type (:type part1)}) + [(build-render group)] + (sequence (comp (partition-all default-partition-size) + (map build-render)) + group))) - (process-partition [[part1 :as part]] + (build-render [[part1 :as part]] {:file-id (:file-id part1) :page-id (:page-id part1) :share-id (:share-id part1) diff --git a/exporter/src/app/handlers/jobs.cljs b/exporter/src/app/handlers/jobs.cljs new file mode 100644 index 0000000000..eca18e8cca --- /dev/null +++ b/exporter/src/app/handlers/jobs.cljs @@ -0,0 +1,60 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.handlers.jobs + "REST surface for export jobs, under `/api/export/jobs`. + + Ownership always comes from the session (see `app.auth`), never from the + request body." + (:require + [app.auth :as auth] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.handlers.export :as export] + [app.jobs :as jobs] + [promesa.core :as p])) + +(defn create + [{:keys [:request/auth-token :request/params] :as exchange}] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (let [params (-> params + (assoc :profile-id profile-id) + (export/conform-params))] + (l/dbg :hint "create export job" :cmd (:cmd params) :profile-id (str profile-id)) + (export/create-job! auth-token params)))) + (p/fmap (fn [{:keys [job resource pending]}] + ;; A failure is reported through the job record, so the + ;; promise must not surface as an unhandled rejection. + (p/merr (constantly nil) pending) + (-> exchange + (assoc :response/body (-> (or (jobs/lookup (:id job)) job) + (assoc :filename (:filename resource)) + (assoc :mtype (:mtype resource))))))))) + +(defn fetch + [{:keys [:request/auth-token] :as exchange} job-id] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (->> (jobs/fetch job-id) + (p/fmap #(auth/check-owner! % profile-id))))) + (p/fmap (fn [job] + (assoc exchange :response/body job))))) + +(defn cancel + [{:keys [:request/auth-token] :as exchange} job-id] + (->> (auth/require-profile-id auth-token) + (p/mcat (fn [profile-id] + (->> (jobs/fetch job-id) + (p/fmap #(auth/check-owner! % profile-id))))) + (p/mcat (fn [job] (jobs/cancel! (:id job)))) + (p/mcat (fn [_] (jobs/fetch job-id))) + (p/fmap (fn [job] + (if job + (assoc exchange :response/body job) + (ex/raise :type :not-found + :code :object-not-found + :hint "job does not exist")))))) diff --git a/exporter/src/app/http.cljs b/exporter/src/app/http.cljs index e025ca2584..93100885f0 100644 --- a/exporter/src/app/http.cljs +++ b/exporter/src/app/http.cljs @@ -15,6 +15,7 @@ [app.common.transit :as t] [app.config :as cf] [app.handlers :as handlers] + [app.router :as router] [cuerdas.core :as str] [lambdaisland.uri :as u] [promesa.core :as p])) @@ -94,7 +95,7 @@ size (js/Buffer.byteLength data "utf-8")] (-> exchange (assoc :response/body data) - (assoc :response/status 200) + (assoc :response/status (or status 200)) (update :response/headers assoc "content-type" "application/transit+json") (update :response/headers assoc "content-length" size))) @@ -159,7 +160,7 @@ (defn init [] - (let [handler (-> handlers/handler + (let [handler (-> (router/create handlers/handler) (wrap-health) (wrap-auth "auth-token") (wrap-response-format) diff --git a/exporter/src/app/jobs.cljs b/exporter/src/app/jobs.cljs new file mode 100644 index 0000000000..2630c847c7 --- /dev/null +++ b/exporter/src/app/jobs.cljs @@ -0,0 +1,284 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs + "Export job model and lifecycle. + + The record is persisted in redis (`app.jobs.store`); the runtime bits that + cannot be serialized -- cancel callbacks, the cancel signal shared with a + render worker, the throttling bookkeeping -- stay in this process, keyed by + job id. + + Every state change also publishes the same `:export-update` message the + exporter has always published, so websocket clients keep working unchanged." + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.jobs.store :as store] + [app.redis :as redis] + [promesa.core :as p])) + +(l/set-level! :debug) + +;; A large export reports progress per object; persisting each one would be +;; hundreds of writes for information nobody reads at that resolution. +(def ^:private progress-throttle-ms 250) + +(def ^:private terminal-states #{"ended" "error" "cancelled"}) + +(defonce ^:private registry (atom {})) + +(defn- now-ms + [] + (inst-ms (ct/now))) + +(defn- runtime + [job-id] + (get @registry (str job-id))) + +(defn lookup + "The live record of a job running in this process, or nil." + [job-id] + (:job (runtime job-id))) + +(defn fetch + "The job record from the shared store." + [job-id] + (store/fetch job-id)) + +(defn- publish! + [{:keys [id profile-id resource-id state done total name filename mtype + resource-uri error]}] + (redis/pub! (redis/->tenant-key (str profile-id)) + (d/without-nils + {:type :export-update + :job-id id + :resource-id resource-id + :status state + :done done + :total total + :name name + :filename filename + :mtype mtype + :resource-uri resource-uri + :cause error}))) + +(defn- store-job! + [job] + (swap! registry update (str (:id job)) assoc :job job) + job) + +(defn create! + "Builds a queued job and persists it. `run-fn` is a 1-arg fn of the job that + performs the export and returns a promise; the caller decides when to run + it." + [{:keys [profile-id cmd backend total name resource-id]} run-fn] + (let [job {:id (uuid/next) + :profile-id profile-id + :cmd cmd + :backend backend + :state "queued" + :done 0 + :total total + :name name + :resource-id resource-id + :created-at (now-ms)}] + (swap! registry assoc (str (:id job)) {:job job :run-fn run-fn :cancelled? false}) + (->> (store/persist! job) + (p/fmap (constantly job))))) + +(defn run-fn + [job-id] + (:run-fn (runtime job-id))) + +(defn cancelled? + [job-id] + (boolean (:cancelled? (runtime job-id)))) + +(defn terminal? + [job] + (contains? terminal-states (:state job))) + +(defn check-cancelled! + "Raises when the job has been cancelled. Called as each render's turn comes + up, so a cancellation stops the ones that have not started yet." + [job] + (when (cancelled? (:id job)) + (ex/raise :type :internal + :code :job-cancelled + :hint "export job was cancelled"))) + +(defn cancel-signal + "Int32Array over a SharedArrayBuffer, readable from a worker thread: 0 while + the job is live, 1 once it has been cancelled. Nil once the job has been + released -- writing the signal back would leave an entry for a settled job in + the registry that nothing would ever remove." + [job-id] + (let [k (str job-id)] + (when-let [rt (get @registry k)] + (or (:cancel-signal rt) + (let [signal (js/Int32Array. (js/SharedArrayBuffer. 4))] + (swap! registry update k (fn [rt] (some-> rt (assoc :cancel-signal signal)))) + (when (cancelled? job-id) + (js/Atomics.store signal 0 1)) + signal))))) + +(defn on-cancel + "Registers a callback used to abort the job's in-flight work (terminating a + render worker). A job fans out over several renders, so callbacks + accumulate. One registered for a job that already settled is dropped: keeping + it would revive that job's registry entry for good." + [job-id f] + (swap! registry update (str job-id) + (fn [rt] (some-> rt (update :cancel-fns (fnil conj []) f))))) + +(defn release! + "Drops the runtime entry once the job settled. The persisted record stays + until its TTL." + [job-id] + (swap! registry dissoc (str job-id))) + +(defn- live + "The job as the lifecycle last left it, or nil once it settled. Callers hold + the snapshot handed to them when their work started; writing that back would + resurrect a failed export as running and drop the error with it." + [job] + (when-let [current (:job (runtime (:id job)))] + (when-not (terminal? current) + current))) + +(defn- persist-and-publish! + [job] + (store-job! job) + (publish! job) + (store/persist! job)) + +(defn transition! + "Moves the job on. The first terminal state wins: anything arriving after it + is dropped, so a late failure cannot overwrite a cancellation, nor a straggler + overwrite either." + [job data] + (if-let [job (live job)] + (persist-and-publish! (merge job data)) + (p/resolved job))) + +(defn start! + [job] + (transition! job {:state "running" :started-at (now-ms)})) + +(defn progress! + "Reports `done` objects completed. Writes are throttled, so the caller need + not care how often it calls this." + [{:keys [id] :as job} done] + (if-let [job (live job)] + (let [k (str id) + now (now-ms) + last (:last-progress-ms (runtime id) 0) + job (assoc job :done done) + write? (>= (- now last) progress-throttle-ms)] + (store-job! job) + (if write? + (do + (swap! registry update k assoc :last-progress-ms now) + (persist-and-publish! job)) + (p/resolved job))) + (p/resolved job))) + +(defn complete! + [job {:keys [uri filename mtype size] :as _resource}] + (transition! job {:state "ended" + :ended-at (now-ms) + :done (:total job) + :resource-uri uri + :filename filename + :mtype mtype + :size size})) + +(defn fail! + [job cause] + (l/error :hint "export job failed" :job-id (str (:id job)) :cause cause) + (transition! job {:state "error" + :ended-at (now-ms) + :error (ex-message cause)})) + +(defn- cancel-local! + [job-id] + (let [k (str job-id) + rt (get @registry k) + job (:job rt)] + (if (or (nil? job) (terminal? job)) + (p/resolved job) + (do + (swap! registry update k (fn [rt] (some-> rt (assoc :cancelled? true)))) + (when-let [signal (:cancel-signal rt)] + (js/Atomics.store signal 0 1)) + ;; Recorded before the callbacks run, not after: one of them + ;; (`scheduler/drop-queued!`) releases the job, and `transition!` on a + ;; released job is a no-op, so a queued job would keep claiming to be + ;; queued in the store and never publish its `cancelled` update. + (let [result (transition! job {:state "cancelled" :ended-at (now-ms)})] + (doseq [f (:cancel-fns rt)] + (try + (f) + (catch :default cause + (l/warn :hint "error on job cancel callback" :job-id k :cause cause)))) + result))))) + +(defn cancel! + "Cancels a job. One this process does not own is broadcast over the cancel + topic, so whoever runs it acts on it. Idempotent." + [job-id] + (if (some? (runtime job-id)) + (cancel-local! job-id) + (->> (fetch job-id) + (p/mcat (fn [job] + (cond + (nil? job) (p/resolved nil) + (terminal? job) (p/resolved job) + :else (p/do + (store/request-cancel! (:id job)) + job))))))) + +(defn- clean-abandoned! + "Marks every job left mid-flight by a previous process as cancelled. + + A queue and its running jobs live in the memory of the process that owns + them, so nothing in flight when it died can be resumed; without this the + record would keep claiming to be running until its TTL expires. + + NOTE: the store cannot tell whose jobs are whose, so with more than one + exporter behind a load balancer this would also cancel a sibling's running + jobs. Single-instance deployments only." + [] + (->> (store/fetch-all) + (p/mcat (fn [jobs] + (let [abandoned (remove terminal? jobs)] + (when (seq abandoned) + (l/warn :hint "cancelling jobs abandoned by a previous process" + :count (count abandoned))) + (->> abandoned + (map (fn [job] + (store/persist! (assoc job + :state "cancelled" + :interrupted true + :ended-at (now-ms))))) + (p/all))))) + (p/fmap (fn [result] (count result))) + (p/merr (fn [cause] + (l/warn :hint "unable to clean abandoned jobs" :cause cause) + (p/resolved 0))))) + +(defn init + [] + (store/on-cancel-request + (fn [job-id] + (when (some? (runtime job-id)) + (l/info :hint "remote cancel request" :job-id job-id) + (cancel-local! job-id)))) + (clean-abandoned!)) diff --git a/exporter/src/app/jobs/scheduler.cljs b/exporter/src/app/jobs/scheduler.cljs new file mode 100644 index 0000000000..efad44d99f --- /dev/null +++ b/exporter/src/app/jobs/scheduler.cljs @@ -0,0 +1,149 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.scheduler + "Admission control for export jobs. + + Limits concurrent jobs and rejects work rather than allowing an unbounded backlog. + Queue order is FIFO, except jobs whose profile is already at its cap are skipped, + as are headless jobs once every render worker is busy." + (:require + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.config :as cf] + [app.jobs :as jobs] + [app.jobs.utils :as job.utils] + [app.wasm.pool :as pool] + [promesa.core :as p])) + +(l/set-level! :debug) + +(defonce ^:private state + (atom {:running {} ;; job-id -> {:profile-id :headless?} + :queue []})) ;; vector of {:job :resolve :reject} + +(defn- max-concurrent [] (cf/get :exporter-max-concurrent-jobs 4)) +(defn- max-per-profile [] (cf/get :exporter-max-jobs-per-profile 2)) +(defn- max-queued [] (cf/get :exporter-queue-max 64)) + +(defn- headless? + [job] + (= "wasm" (:backend job))) + +(defn- running-for + [{:keys [running]} profile-id] + (count (filter #(= profile-id (:profile-id %)) (vals running)))) + +(defn- running-headless + [{:keys [running]}] + (count (filter :headless? (vals running)))) + +(defn- eligible? + [state job] + (and (< (count (:running state)) (max-concurrent)) + (< (running-for state (:profile-id job)) (max-per-profile)) + ;; A headless job holds one render worker for its whole run, so admitting + ;; more of them than there are workers would only move the wait inside + ;; the pool, with the job already reporting itself as running. + (or (not (headless? job)) + (< (running-headless state) (pool/capacity))))) + +(declare ^:private pump!) + +(defn- finish! + [job-id] + (swap! state update :running dissoc (str job-id)) + (jobs/release! job-id) + (job.utils/release! job-id) + (pump!)) + +(defn- execute! + [{:keys [id profile-id] :as job}] + (swap! state update :running assoc (str id) {:profile-id profile-id + :headless? (headless? job)}) + (if (jobs/cancelled? id) + (do (finish! id) + (p/resolved job)) + (let [run-fn (jobs/run-fn id)] + (->> (p/do (jobs/start! job)) + (p/mcat (fn [job] (p/do (run-fn job)))) + (p/fnly (fn [_ _] (finish! id))))))) + +(defn- drop-queued! + "Removes a queued job and settles its promise, freeing its queue slot on cancellation." + [job-id] + (let [entry (volatile! nil)] + (swap! state (fn [state] + (let [queue (:queue state) + idx (->> (map-indexed vector queue) + (some (fn [[idx entry]] + (when (= (str job-id) (str (-> entry :job :id))) + idx))))] + (if idx + (do (vreset! entry (nth queue idx)) + (assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx))))) + state)))) + (when-let [{:keys [resolve]} @entry] + (jobs/release! job-id) + (job.utils/release! job-id) + (resolve nil)))) + +(defn- take-eligible + "Pops the first queued entry that can run now, or nil." + [state] + (let [queue (:queue state) + idx (->> (map-indexed vector queue) + (some (fn [[idx entry]] + (when (eligible? state (:job entry)) + idx))))] + (when idx + [(assoc state :queue (into (subvec queue 0 idx) (subvec queue (inc idx)))) + (nth queue idx)]))) + +(defn- pump! + [] + (loop [] + (let [entry (volatile! nil)] + (swap! state (fn [state] + (if-let [[next-state next-entry] (take-eligible state)] + (do (vreset! entry next-entry) next-state) + (do (vreset! entry nil) state)))) + (when-let [{:keys [job resolve reject]} @entry] + (-> (execute! job) + (p/then resolve) + (p/catch reject)) + (recur))))) + +(defn submit! + "Registers `job` for execution. Returns a promise of the job's result, which + resolves when the export actually finishes; callers that only need the handle + can ignore it. Raises when the exporter is saturated." + [job] + (let [resolve* (volatile! nil) + reject* (volatile! nil) + pending (p/create (fn [resolve reject] + (vreset! resolve* resolve) + (vreset! reject* reject)))] + (if (eligible? @state job) + (-> (execute! job) + (p/then @resolve*) + (p/catch @reject*)) + + (let [queued (volatile! false)] + (swap! state (fn [state] + (if (< (count (:queue state)) (max-queued)) + (do (vreset! queued true) + (update state :queue conj {:job job + :resolve @resolve* + :reject @reject*})) + (do (vreset! queued false) state)))) + (when-not @queued + (ex/raise :type :validation + :code :queue-full + :hint "too many queued export jobs")) + (jobs/on-cancel (:id job) (fn [] (drop-queued! (:id job)))) + (l/dbg :hint "export job queued" :job-id (str (:id job))))) + pending)) diff --git a/exporter/src/app/jobs/store.cljs b/exporter/src/app/jobs/store.cljs new file mode 100644 index 0000000000..502c2d0676 --- /dev/null +++ b/exporter/src/app/jobs/store.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.store + "Redis persistence for export jobs. + + Stores each job as a single blob with a TTL matching the exported file, + so records expire with their files. + + Reads use Redis. Cancellation requires the process running the job." + (:require + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [app.redis :as redis] + [promesa.core :as p])) + +(def ^:private cancel-topic (redis/->key "job-cancel")) + +(defn- job-key + [job-id] + (redis/->key "job." job-id)) + +(defn- ttl + [] + (cf/get :exporter-job-ttl 3600)) + +(defn persist! + "Writes the job record and refreshes its TTL. + + If the write fails, log it and continue. The export still runs + and publishes websocket updates, but the job can't be fetched + afterward (fetch returns nil, REST returns 404)." + [{:keys [id state] :as job}] + (let [jkey (job-key id)] + (->> (p/do + (redis/hset! jkey {:data (t/encode-str job)}) + (redis/expire! jkey (ttl)) + job) + (p/merr (fn [cause] + (if (= :redis-not-available (:code (ex-data cause))) + (l/warn :hint "job record not persisted, no redis connection" + :job-id (str id) :state state) + (l/error :hint "unable to persist job record" + :job-id (str id) :state state :cause cause)) + (p/resolved job)))))) + +(defn fetch + "The job record, or nil when unknown or expired." + [job-id] + (->> (redis/hgetall (job-key job-id)) + (p/fmap (fn [data] + (when-let [blob (get data "data")] + (try + (t/decode-str blob) + (catch :default cause + (l/warn :hint "unable to decode job record" :job-id (str job-id) :cause cause) + nil))))))) + +(defn fetch-all + [] + (->> (redis/scan (redis/->key "job.*")) + (p/mcat (fn [keys] + (->> (map (fn [k] + (->> (redis/hgetall k) + (p/fmap (fn [data] + (when-let [blob (get data "data")] + (try + (t/decode-str blob) + (catch :default _ nil))))))) + keys) + (p/all)))) + (p/fmap (fn [jobs] (vec (remove nil? jobs)))))) + +(defn request-cancel! + "Asks every exporter to cancel `job-id`. Only the one running it will act." + [job-id] + (redis/pub! cancel-topic (str job-id))) + +(defn on-cancel-request + "Registers `handler` (fn of the job-id string) for cancel requests. Returns an + unsubscribe fn." + [handler] + (redis/sub! cancel-topic handler)) diff --git a/exporter/src/app/jobs/utils.cljs b/exporter/src/app/jobs/utils.cljs new file mode 100644 index 0000000000..37480bb706 --- /dev/null +++ b/exporter/src/app/jobs/utils.cljs @@ -0,0 +1,86 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.jobs.utils + "Temp file ownership for export jobs. + + Temp files used to be cleaned only by the per-file timer in `app.util.shell`, + an hour after creation and lost entirely on restart. Here each job owns the + paths it creates, so they are dropped as soon as it settles and whatever a + crash left behind is cleaned at boot." + (:require + ["node:fs/promises" :as fsp] + ["node:path" :as path] + [app.common.logging :as l] + [app.config :as cf] + [app.util.shell :as sh] + [cuerdas.core :as str] + [promesa.core :as p])) + +(def ^:private managed-prefix "penpot.") + +(defonce ^:private tracked (atom {})) + +(defn track! + "Registers `path` as owned by `job-id`, so it is removed when the job settles." + [job-id path] + (when (and job-id path) + (swap! tracked update (str job-id) (fnil conj #{}) path)) + path) + +(defn- remove-path! + [path] + (->> (p/do (fsp/rm path #js {:recursive true :force true})) + (p/merr (fn [cause] + (l/warn :hint "unable to remove job temp file" :path path :cause cause) + (p/resolved nil))))) + +(defn release! + "Removes every file the job owns. Called once the job reached a terminal + state and its result has already been uploaded, so nothing else reads them." + [job-id] + (let [k (str job-id) + paths (get @tracked k)] + (swap! tracked dissoc k) + (if (seq paths) + (->> (map remove-path! paths) + (p/all) + (p/fmap (fn [_] + (l/dbg :hint "released job temp files" :job-id k :count (count paths)) + nil))) + (p/resolved nil)))) + +(defn- clean! + "Removes managed temp files older than the job TTL. They can only be leftovers + of a previous process: every live one belongs to a job of this process." + [] + (let [max-age (* 1000 (cf/get :exporter-job-ttl 3600)) + now (js/Date.now)] + (->> (p/do (fsp/readdir sh/tmpdir)) + (p/mcat (fn [entries] + (->> (filter #(str/starts-with? % managed-prefix) entries) + (map (fn [entry] + (let [fpath (path/join sh/tmpdir entry)] + (->> (p/do (fsp/stat fpath)) + (p/mcat (fn [^js stat] + (if (> (- now (inst-ms (.-mtime stat))) max-age) + (->> (remove-path! fpath) + (p/fmap (constantly 1))) + (p/resolved 0)))) + (p/merr (fn [_] (p/resolved 0))))))) + (p/all)))) + (p/fmap (fn [results] + (let [removed (reduce + 0 results)] + (when (pos? removed) + (l/info :hint "removed orphaned export temp files" :count removed)) + removed))) + (p/merr (fn [cause] + (l/warn :hint "temp file cleanup failed" :cause cause) + (p/resolved 0)))))) + +(defn init + [] + (clean!)) diff --git a/exporter/src/app/redis.cljs b/exporter/src/app/redis.cljs index 8dfb144f39..8fca0c335c 100644 --- a/exporter/src/app/redis.cljs +++ b/exporter/src/app/redis.cljs @@ -8,47 +8,170 @@ (:require ["ioredis" :as redis] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.transit :as t] - [app.config :as cf])) + [app.config :as cf] + [promesa.core :as p])) (l/set-level! :trace) (def client (atom nil)) +;; A connection in subscriber mode rejects every other command, so the +;; subscriptions need a connection of their own. +(def ^:private subscriber (atom nil)) + +(def ^:private subscriptions (atom {})) + (defn- create-client - [uri] + [uri role] (let [^js client (new redis/default uri)] (.on client "connect" - (fn [] (l/info :hint "redis connection established" :uri uri))) + (fn [] (l/info :hint "redis connection established" :uri uri :role role))) (.on client "error" - (fn [cause] (l/error :hint "error on redis connection" :cause cause))) + (fn [cause] (l/error :hint "error on redis connection" :role role :cause cause))) (.on client "close" - (fn [] (l/warn :hint "connection closed"))) + (fn [] (l/warn :hint "connection closed" :role role))) (.on client "reconnect" - (fn [ms] (l/warn :hint "reconnecting to redis" :ms ms))) + (fn [ms] (l/warn :hint "reconnecting to redis" :role role :ms ms))) (.on client "end" - (fn [] (l/warn :hint "client ended, no more connections will be attempted"))) + (fn [] (l/warn :hint "client ended, no more connections will be attempted" :role role))) client)) +(defn- dispatch-message + [topic payload] + (doseq [handler (get @subscriptions topic)] + (try + (handler payload) + (catch :default cause + (l/error :hint "error on redis subscription handler" :topic topic :cause cause))))) + (defn init [] - (swap! client (fn [prev] - (when prev (.disconnect ^js prev)) - (create-client (cf/get :redis-uri))))) - + (let [uri (cf/get :redis-uri)] + (swap! client (fn [prev] + (when prev (.disconnect ^js prev)) + (create-client uri "commands"))) + (swap! subscriber (fn [prev] + (when prev (.disconnect ^js prev)) + (let [^js conn (create-client uri "subscriber")] + (.on conn "message" (fn [topic payload] (dispatch-message topic payload))) + ;; Reinstate subscriptions after a reconnection. + (.on conn "connect" + (fn [] + (doseq [topic (keys @subscriptions)] + (.subscribe conn topic)))) + conn))))) (defn stop [] + (reset! subscriptions {}) + (swap! subscriber (fn [conn] + (when conn (.quit ^js conn)) + nil)) (swap! client (fn [client] (when client (.quit ^js client)) nil))) (def ^:private tenant (cf/get :tenant)) +(defn ->tenant-key + "Namespaces `parts` under the tenant, the prefix the backend msgbus uses." + [& parts] + (dm/str tenant "." (apply str parts))) + +(defn ->key + "Namespaces `parts` under the exporter, inside the tenant." + [& parts] + (dm/str "penpot.exporter." tenant "." (apply str parts))) + (defn pub! + "Publishes on `topic`, which must already be namespaced." [topic payload] - (let [payload (if (map? payload) (t/encode-str payload) payload) - topic (dm/str tenant "." topic)] + (let [payload (if (map? payload) (t/encode-str payload) payload)] (when-let [client @client] (.publish ^js client topic payload)))) + +(defn sub! + "Subscribes `handler` (fn of the raw payload string) to `topic`, which must + already be namespaced. Returns a 0-arg fn that removes this handler." + [topic handler] + (swap! subscriptions update topic (fnil conj []) handler) + (when-let [conn @subscriber] + (.subscribe ^js conn topic)) + (fn [] + (swap! subscriptions update topic (fn [handlers] (vec (remove #(= % handler) handlers)))))) + +(defn- with-client + "Runs `f` against the command connection. Rejects when there is no connection + or the command fails: whether a failure is survivable depends on what the + caller was doing, and only the caller knows." + [f] + (if-let [client @client] + (p/do (f client)) + (p/rejected (ex/error :type :internal + :code :redis-not-available + :hint "no redis connection")))) + +(defn- with-client-lenient + "For reads, where an unreachable redis is reported as \"nothing there\"." + [f] + (->> (with-client f) + (p/merr (fn [cause] + (l/warn :hint "redis command failed" :cause cause) + (p/resolved nil))))) + +(defn hset! + "Writes `data` (a map of string/keyword -> value) as a hash. Nil values are + dropped, since redis has no null." + [k data] + (let [obj (reduce-kv (fn [obj field value] + (if (some? value) + (doto obj (unchecked-set (name field) (str value))) + obj)) + #js {} + data)] + (if (zero? (alength (js/Object.keys obj))) + (p/resolved nil) + (with-client (fn [^js client] (.hset client k obj)))))) + +(defn hgetall + "Returns the hash as a map of string keys, or nil when it does not exist." + [k] + (->> (with-client-lenient (fn [^js client] (.hgetall client k))) + (p/fmap (fn [result] + (when (and result (pos? (alength (js/Object.keys result)))) + (persistent! + (reduce (fn [res field] + (assoc! res field (unchecked-get result field))) + (transient {}) + (js/Object.keys result)))))))) + +(defn expire! + [k seconds] + (with-client (fn [^js client] (.expire client k seconds)))) + +(defn del! + [k] + (with-client (fn [^js client] (.del client k)))) + +(defn scan + "Every key matching `pattern`, walked in cursor batches so a large keyspace is + never blocked the way `KEYS` would block it. + + Batches are accumulated in memory rather than consumed as a stream, which a + promise-returning fn cannot express. Fine for the job keyspace, but reading + redis wants a streaming or reactive interface before it is used for more." + [pattern] + (letfn [(step [cursor found] + (->> (with-client-lenient (fn [^js client] (.scan client cursor "MATCH" pattern "COUNT" 200))) + (p/mcat (fn [result] + (if (nil? result) + (p/resolved found) + (let [next-cursor (aget result 0) + found (into found (aget result 1))] + (if (= "0" next-cursor) + (p/resolved found) + (step next-cursor found))))))))] + (step "0" []))) diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index 01093f5da6..2cc0c424e4 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -26,6 +26,7 @@ (s/def ::token ::us/string) (s/def ::filename ::us/string) (s/def ::is-wasm ::us/boolean) +(s/def ::job-id ::us/uuid) (s/def ::object (s/keys :req-un [::id ::name ::suffix ::filename] @@ -36,18 +37,22 @@ (s/def ::render-params (s/keys :req-un [::file-id ::page-id ::scale ::token ::type ::objects] - :opt-un [::is-wasm])) + :opt-un [::is-wasm ::job-id])) + +(defn headless? + "Whether `params` renders with render-wasm rather than a browser." + [{:keys [type is-wasm]}] + (and is-wasm (contains? cf/flags :wasm-export) (not= :svg type))) (defn render [{:keys [type is-wasm] :as params} on-object] (us/verify ::render-params params) (us/verify fn? on-object) - (let [wasm-export? (contains? cf/flags :wasm-export) - headless? (and is-wasm wasm-export? (not= :svg type))] + (let [headless? (headless? params)] (when is-wasm (l/info :hint "render" :type type - :wasm-export wasm-export? + :wasm-export (contains? cf/flags :wasm-export) :backend (if headless? "wasm" "browser"))) (if headless? (rw/render params on-object) @@ -58,3 +63,19 @@ :pdf (rp/render params on-object) :svg (rs/render params on-object))))) +(defn with-scope + "Runs `f`, a fn of a render fn with the same signature as `render`. Exports + that render headless share one worker for the whole call instead of acquiring + one per render; the browser backend keeps rendering them in parallel." + [exports f] + (if (some headless? exports) + (rw/with-scope (:job-id (first exports)) + (fn [render-leased] + (f (fn [params on-object] + (us/verify ::render-params params) + (us/verify fn? on-object) + (if (headless? params) + (render-leased params on-object) + (render params on-object)))))) + (f render))) + diff --git a/exporter/src/app/renderer/wasm.cljs b/exporter/src/app/renderer/wasm.cljs index 54ad486bee..a284e35269 100644 --- a/exporter/src/app/renderer/wasm.cljs +++ b/exporter/src/app/renderer/wasm.cljs @@ -5,447 +5,46 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.renderer.wasm - "Headless renderer backend: renders exports with the render-wasm Skia - pipeline in this Node process, with no browser and no WebGL. + "Main-thread side of the headless renderer. - Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and - images -> relayout text with the real fonts -> render each object. - - One shared WASM design state, so requests are serialized one at a time. - - Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the - browser path." + Renders run on pooled workers because Skia calls are synchronous and would block + the HTTP server and other exports. Each job keeps one worker for all its renders, + sharing its caches and pool slot." (:require - ["node:fs" :as fs] - ["undici" :as http] - [app.common.data :as d] - [app.common.fonts :as cfnt] - ;; Required for side effects: these register the transit read handlers and - ;; deftype impls the `get-page` response is decoded into. - [app.common.geom.matrix] - [app.common.geom.point] - [app.common.geom.rect] - [app.common.logging :as l] - [app.common.transit :as t] - [app.common.types.fills.impl] - [app.common.types.objects-map] - [app.common.types.path.impl] - [app.common.types.shape] - [app.common.types.shape.images :as images] - [app.common.uri :as u] - [app.common.uuid :as uuid] - [app.config :as cf] - [app.util.mime :as mime] - [app.util.shell :as sh] - [app.wasm :as wasm] - [app.wasm.serialize :as serialize] - [cuerdas.core :as str] + [app.jobs :as jobs] + [app.wasm.pool :as pool] [promesa.core :as p])) -;; --- module lifecycle (one shared, lazily-initialized instance) - -(defonce ^:private module* (atom nil)) - -(defn- ensure-module! +(defn- serializer + "Chains thunks so a job's renders run one at a time on its worker. A failure + is isolated: it doesn't break the chain for the next one." [] - (or @module* - (reset! module* (wasm/init!)))) + (let [queue (atom (p/resolved nil))] + (fn [thunk] + (let [result (p/handle @queue (fn [_ _] (thunk)))] + (reset! queue (p/handle result (fn [_ _] nil))) + result)))) -;; --- serialized access to the shared module -;; -;; `handle-multiple-export` fans out partitions concurrently, but there is one -;; design state and one global mem buffer, so their serialize/render/alloc must -;; not interleave. - -(defonce ^:private queue (atom (p/resolved nil))) - -(defn- enqueue! - "Runs `thunk` (0-arg, returns a promise) only after all previously enqueued - work has settled. Returns `thunk`'s promise. A task's failure is isolated: - it doesn't break the chain for the next task." - [thunk] - (let [result (p/handle @queue (fn [_ _] (thunk)))] - (reset! queue (p/handle result (fn [_ _] nil))) - result)) - -;; --- backend endpoints -;; -;; Every fetch targets the internal endpoint (falling back to public-uri), -;; in a deployment the exporter reaches the backend over the container network - -(defn- internal-uri - "Absolute URI for `path` on the internal (backend) endpoint." - [path] - (-> (cf/get-internal-uri) - (u/ensure-path-slash) - (u/join path) - (str))) - -(defn- error-detail - "Node's fetch reports every transport failure as a bare `TypeError: fetch - failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a - nested `cause` chain that the logger does not print. Flattens the chain into - one readable string." - [cause] - (->> (iterate (fn [^js e] (unchecked-get e "cause")) cause) - (take-while some?) - (take 5) - (map (fn [^js e] - (let [code (unchecked-get e "code") - msg (or (unchecked-get e "message") (str e))] - (if code (str code ": " msg) msg)))) - (str/join " <- "))) - -(defn- fetch! - "`undici/fetch` that fails with an ex-info carrying the target uri and the - unwrapped cause chain, so a failed request says what actually went wrong and - against which endpoint." - [uri opts] - (->> (p/do (http/fetch uri opts)) - (p/merr (fn [cause] - (p/rejected (ex-info "http fetch failed" - {:uri uri :detail (error-detail cause)} - cause)))))) - -(defn- explain - "Log-friendly reason for `cause`: the detail `fetch!` already attached, or a - freshly unwrapped chain for anything else (WASM aborts, decode errors)." - [cause] - (or (:detail (ex-data cause)) - (error-detail cause))) - -(defn- rpc-headers - "Auth headers for backend RPC calls (management key + bearer)." - [token] - #js {"Content-Type" "application/transit+json" - "X-Shared-Key" (str "exporter " cf/management-key) - "Authorization" (str "Bearer " token)}) - -(defn- asset-headers - "Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to - a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple - authentication types\")." - [token] - #js {"X-Shared-Key" (str "exporter " cf/management-key) - "Cookie" (str "auth-token=" token)}) - -;; --- shape bundle fetch (backend RPC) - -(defn- fetch-objects - "Fetches the exported roots and their children from the backend via the - `get-page` RPC (`:object-id`, as the browser render path does), using the - same auth the exporter uses elsewhere (management key + bearer)." - [{:keys [file-id page-id share-id token objects]}] - (let [headers (rpc-headers token) - root-ids (into #{} (map :id) objects) - body (t/encode-str (cond-> {:file-id file-id - :page-id page-id} - (seq root-ids) (assoc :object-id root-ids) - share-id (assoc :share-id share-id))) - uri (internal-uri "api/rpc/command/get-page")] - (l/dbg :hint "wasm render: get-page" - :uri uri - :file-id (str file-id) - :page-id (str page-id) - :roots (count root-ids)) - (->> (fetch! uri #js {:method "POST" :headers headers :body body}) - (p/mcat (fn [^js resp] - (if (= 200 (.-status resp)) - (.text resp) - (->> (.text resp) - (p/mcat (fn [resp-body] - (l/error :hint "wasm render: get-page failed" - :uri uri - :status (.-status resp) - :body resp-body) - (p/rejected (ex-info "get-page failed" - {:status (.-status resp) - :body resp-body})))))))) - (p/fmap t/decode-str) - (p/fmap :objects)))) - -;; --- font resolution -;; -;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape` -;; reports it. Custom (team) fonts resolve through the file's font variants, -;; google fonts through the shared `app.common.fonts` catalog; builtin -;; fonts through its bundled family + the frontend's static `/fonts/`. - -(defn- fetch-font-variants - "Team (custom) font variants for the file, or nil — a failure here degrades - to fallback fonts, it does not fail the export." - [{:keys [file-id share-id token]}] - (let [headers (rpc-headers token) - body (t/encode-str (cond-> {:file-id file-id} - share-id (assoc :share-id share-id))) - uri (internal-uri "api/rpc/command/get-font-variants")] - (->> (fetch! uri #js {:method "POST" :headers headers :body body}) - (p/mcat (fn [^js resp] - (if (= 200 (.-status resp)) - (.text resp) - (p/resolved nil)))) - (p/fmap (fn [s] (when s (t/decode-str s)))) - (p/merr (fn [cause] - (l/warn :hint "wasm render: get-font-variants failed" - :uri uri :detail (explain cause) :cause cause) - (p/resolved nil)))))) - -(defn- fetch-ttf-bytes - "Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure - here degrades to fallback fonts, it does not fail the export." - ([uri] (fetch-ttf-bytes uri #js {:method "GET"})) - ([uri opts] - (->> (fetch! uri opts) - (p/mcat (fn [^js resp] - (if (= 200 (.-status resp)) - (.arrayBuffer resp) - (p/resolved nil)))) - (p/merr (fn [cause] - (l/warn :hint "wasm render: font fetch failed" - :uri uri :detail (explain cause) :cause cause) - (p/resolved nil)))))) - -;; TTF bytes cached for the process lifetime, keyed by whatever identifies the -;; variant (a gfont id+weight+style, a builtin file name). -(defonce ^:private font-bytes* (atom {})) - -(defn- cached-ttf-bytes - [cache-key fetch-fn] - (if-let [bytes (get @font-bytes* cache-key)] - (p/resolved bytes) - (->> (fetch-fn) - (p/fmap (fn [buf] - (when buf (swap! font-bytes* assoc cache-key buf)) - buf))))) - -(defn- fetch-asset-bytes - [asset-id {:keys [token]}] - (fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id)) - #js {:method "GET" :headers (asset-headers token)})) - -(defn- fetch-gfont-bytes - [ttf-url] - (fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))) - -(defn- fetch-builtin-font-bytes - [ttf-file] - (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) - -(defn- make-resolve-font - "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom - variants first, matching uuid+weight+style then degrading to uuid+weight then - uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps - every builtin family to; google catalog otherwise." - [variants params] - (fn [{:keys [id weight style]}] - (let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3)) - style-str (if (zero? style) "normal" "italic") - variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) - (= (:font-weight v) weight) - (= (name (:font-style v)) style-str))) - variants) - (d/seek (fn [v] (and (= (:font-id v) font-uuid) - (= (:font-weight v) weight))) - variants) - (d/seek (fn [v] (= (:font-id v) font-uuid)) variants))] - (cond - (:ttf-file-id variant) - (fetch-asset-bytes (:ttf-file-id variant) params) - - (= uuid/zero font-uuid) - (fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style)) - - :else - (if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)] - (fetch-gfont-bytes gurl) - (p/resolved nil)))))) - -;; --- fallback fonts (emoji + per-script noto fonts) -;; -;; Emoji and non-latin scripts render through fallback families, not through -;; any span's font family, so `wasm/fonts-for-shape` never reports them and the -;; provisioning above never uploads them. Must run per request, since -;; `clear-fonts!` empties the store; the TTF bytes stay cached per process. - -(defn- scene-fallback-fonts - "Fallback font descriptors needed by the scene's text. Deduped because - several languages map to one noto family and provisioning is concurrent — - otherwise they all miss the byte cache at once and refetch the same TTF." - [scene] - (let [texts (for [shape (vals scene) - :when (= :text (:type shape)) - node (or (some->> (:content shape) (tree-seq :children :children)) []) - :let [text (:text node)] - :when (string? text)] - text) - emoji? (boolean (some cfnt/contains-emoji? texts)) - langs (reduce cfnt/collect-used-languages #{} texts)] - (distinct - (cond-> (cfnt/add-noto-fonts [] langs) - emoji? (cfnt/add-emoji-font))))) - -(defn- fetch-fallback-font-bytes - "Downloads one fallback font's TTF. Cached by the whole variant, not just - `font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a - font-id-only key would serve the first downloaded variant for every other one." - [{:keys [font-id weight style]}] - (if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))] - (cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url)) - (p/resolved nil))) - -(defn- provision-fallback-fonts! - [scene] - (->> (scene-fallback-fonts scene) - (map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}] - (if-let [font-uuid (cfnt/gfont-id->uuid font-id)] - (->> (fetch-fallback-font-bytes font) - (p/fmap (fn [buf] - (if buf - (wasm/store-font! {:id (uuid/get-u32 font-uuid) - :weight weight - :style style - :emoji? (boolean is-emoji) - :fallback? (boolean is-fallback)} - buf) - (l/warn :hint "wasm render: fallback font unavailable" - :font-id font-id))))) - (p/resolved nil)))) - (p/all))) - -;; --- image resolution -;; -;; Image fills reference file-media ids; the encoded bytes go straight to -;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens -;; once per request rather than per rendered object. - -(defn- fetch-file-media-bytes - "Downloads an image fill's encoded bytes by file-media id." - [media-id {:keys [token]}] - (let [headers (asset-headers token) - uri (internal-uri (str "assets/by-file-media-id/" media-id))] - (->> (fetch! uri #js {:method "GET" :headers headers}) - (p/mcat (fn [^js resp] - (if (= 200 (.-status resp)) - (.arrayBuffer resp) - (do - (l/warn :hint "wasm render: image fetch non-200" - :media-id (str media-id) - :uri uri - :status (.-status resp)) - (p/resolved nil))))) - (p/merr (fn [cause] - (l/warn :hint "wasm render: image fetch failed" - :media-id (str media-id) :uri uri - :detail (explain cause) :cause cause) - (p/resolved nil)))))) - -(defn- provision-images! - "Fetches and stores every image the scene references (shape, stroke and - text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, - the image store is not reset per request, so already-held images are skipped - and repeated exports of a file reuse them." - [scene params] - (let [all-ids (images/scene-image-ids scene) - new-ids (remove wasm/image-cached? all-ids)] - (l/dbg :hint "wasm render: provisioning images" - :total (count all-ids) - :cached (- (count all-ids) (count new-ids))) - (->> new-ids - (map (fn [image-id] - (->> (fetch-file-media-bytes image-id params) - (p/fmap (fn [buf] - (if buf - (do - (l/dbg :hint "wasm render: image stored" - :media-id (str image-id) - :bytes (.-byteLength ^js buf)) - (wasm/store-image! image-id buf)) - (l/warn :hint "wasm render: image unavailable" - :media-id (str image-id)))))))) - (p/all)))) - -(defn- relayout-text! - "Recomputes layout for every text shape, once the real fonts are provisioned - (serialize-time layout used the fallback)." - [scene] - (doseq [shape (vals scene) - :when (= :text (:type shape))] - (wasm/update-text-layout! (:id shape)))) - -;; --- render - -(defn- render-object-bytes - [type id scale] - (if (= :pdf type) - (let [bytes (wasm/render-shape-pdf id scale)] - (l/dbg :hint "PDF generated via Skia (render-wasm headless)" - :object-id (str id) - :backend "skia-wasm" - :bytes (.-length bytes)) - bytes) - (wasm/render-shape-raster id scale type))) - -(defn- render* - [{:keys [scale type objects] :as params} on-object] - (l/dbg :hint "wasm render: start" - :type type - :scale scale - :objects (count objects) - :file-id (str (:file-id params)) - :page-id (str (:page-id params))) - (->> (ensure-module!) - (p/mcat (fn [_] (fetch-objects params))) - (p/mcat (fn [scene] - (l/dbg :hint "wasm render: scene fetched" :shapes (count scene)) - (serialize/serialize-scene! scene) - (l/dbg :hint "wasm render: scene serialized") - ;; So fonts from a previous request don't leak into this one. - (wasm/clear-fonts!) - (->> (p/all [(fetch-font-variants params) - (provision-images! scene params) - (provision-fallback-fonts! scene)]) - (p/mcat - (fn [[variants _]] - (let [resolve-font (make-resolve-font (or variants []) params)] - ;; Before rendering, so the relayout below sees real - ;; font metrics. Deduped across objects: a partition - ;; sharing one family downloads its TTF once. - (wasm/provision-fonts! (map :id objects) resolve-font)))) - (p/mcat - (fn [_] - (relayout-text! scene) - (p/run - (fn [{:keys [id] :as object}] - (let [bytes (render-object-bytes type id scale) - path (sh/tempfile :prefix "penpot.tmp.wasm." - :suffix (mime/get-extension type))] - (l/dbg :hint "wasm render: object rendered" - :object-id (str id) :bytes (.-length bytes)) - (fs/writeFileSync path bytes) - ;; `on-object` returns a plain value (zip append) or - ;; a promise (single export's file move); `p/do` - ;; normalizes both to a thenable. - (p/do (on-object (assoc object :path path))))) - objects)))))) - (p/fmap (fn [result] - ;; After the request, never mid-render, so an image can't - ;; disappear under a running export. - (let [evicted (wasm/evict-images! wasm/image-cache-mb)] - (when (pos? evicted) - (l/info :hint "wasm render: evicted cached images" :count evicted))) - result)) - (p/merr (fn [cause] - (l/error :hint "wasm render: failed" - :detail (explain cause) - :internal-uri (str (cf/get-internal-uri)) - :cause cause) - ;; A panic can leave the mem buffer allocated or the instance - ;; aborted; drop it so the next request rebuilds a fresh one. - (reset! module* nil) - (p/rejected cause))))) +(defn with-scope + "Runs `f`, a fn of a 2-arg render fn. Every render goes to the same worker, + one at a time, so the cancel check runs as each render's turn comes up." + [job-id f] + (pool/with-worker + (fn [worker] + (let [chain (serializer) + live (volatile! worker) + signal (when job-id (jobs/cancel-signal job-id)) + opts {:cancel-buffer (some-> signal (.-buffer)) + :cancelled? (when job-id #(jobs/cancelled? job-id))}] + (when job-id + ;; Between objects the worker sees the flag; inside a render only + ;; terminating the thread stops it. Cleared on the way out so a later + ;; cancel cannot terminate a worker that is by then somebody else's. + (jobs/on-cancel job-id (fn [] (pool/terminate! @live)))) + (->> (p/do (f (fn [params on-object] + (chain #(pool/render-on worker params on-object opts))))) + (p/fnly (fn [_ _] (vreset! live nil)))))))) (defn render - "Public entry. `enqueue!` keeps concurrent exports off each other's toes on - the shared WASM instance." [params on-object] - (enqueue! (fn [] (render* params on-object)))) + (with-scope (:job-id params) (fn [render*] (render* params on-object)))) diff --git a/exporter/src/app/router.cljs b/exporter/src/app/router.cljs new file mode 100644 index 0000000000..991ab60a24 --- /dev/null +++ b/exporter/src/app/router.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.router + "Method + path dispatch. + + Requests arrive with whatever prefix the proxy in front uses (`/api/export` + in devenv, `/` when talking to the process directly), so routes are matched + on the remainder after that prefix." + (:require + [app.common.exceptions :as ex] + [app.handlers.jobs :as jobs.handlers] + [cuerdas.core :as str])) + +(def ^:private mount-point "/api/export") + +(defn- route-path + [path] + (let [path (or path "/") + path (if (str/starts-with? path mount-point) + (subs path (count mount-point)) + path) + path (str/rtrim path "/")] + (if (str/empty? path) "/" path))) + +(defn- job-id + [path prefix] + (let [id (subs path (count prefix))] + (when-not (or (str/empty? id) (str/includes? id "/")) + id))) + +(defn create + "Builds the request handler. `legacy-handler` serves the original + `POST /api/export` command multiplex." + [legacy-handler] + (fn [{:keys [:request/method :request/path] :as exchange}] + (let [path (route-path path)] + (cond + (and (= "post" method) (= "/" path)) + (legacy-handler exchange) + + (and (= "post" method) (= "/jobs" path)) + (jobs.handlers/create exchange) + + (and (= "get" method) (str/starts-with? path "/jobs/")) + (if-let [id (job-id path "/jobs/")] + (jobs.handlers/fetch exchange id) + (ex/raise :type :not-found :code :object-not-found :hint "unknown route")) + + (and (= "delete" method) (str/starts-with? path "/jobs/")) + (if-let [id (job-id path "/jobs/")] + (jobs.handlers/cancel exchange id) + (ex/raise :type :not-found :code :object-not-found :hint "unknown route")) + + :else + (ex/raise :type :not-found + :code :route-not-found + :hint (str "no route for " method " " path)))))) diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index 31e6bbbe67..dfebf84d39 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -43,9 +43,9 @@ path in devenv and inside the bundle, so it is a constant." "resources/wasm") -(def image-cache-mb - "Byte budget (MB) the image store is trimmed to between requests." - 256) +(def image-cache-size + "Byte budget the image store is trimmed to between requests." + (* 256 1024 1024)) (defn- read-result-bytes "Reads `len` bytes from the WASM heap starting at `offset`, copying them out @@ -121,9 +121,9 @@ [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) (defn fonts-for-shapes - "Distinct font families needed by every subtree in `shape-ids`. Objects in a - partition overwhelmingly share families, so deduping here means one download - and one `_store_font` per family rather than one per object." + "Distinct font families needed by every subtree in `shape-ids`. Objects in one + export overwhelmingly share families, so deduping here means one download and + one `_store_font` per family rather than one per object." [shape-ids] (into [] (comp (mapcat fonts-for-shape) (d/distinct-xf font-key)) @@ -156,10 +156,14 @@ "Recomputes a text shape's layout with the currently provisioned fonts. Text is laid out at serialize time using the fallback font (real fonts aren't uploaded yet), so this must run again after `provision-fonts!` or glyph metrics/line - breaks are wrong." + breaks are wrong. + + Forced, because provisioning a font changes nothing `update_layout` keys on: + it early-returns while the content is unchanged and the layout still matches + its container, which is exactly the case here." [shape-id] (let [buf (uuid/get-u32 shape-id)] - (h/call wasm/internal-module "_update_shape_text_layout_for" + (h/call wasm/internal-module "_force_update_shape_text_layout_for" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)))) (defn image-cached? @@ -205,10 +209,10 @@ (h/call module "_store_image"))) (defn evict-images! - "Evicts least-recently-used images until the store retains at most `max-mb` - megabytes. Returns the number evicted." - [max-mb] - (h/call wasm/internal-module "_evict_images_to_budget" max-mb)) + "Evicts least-recently-used images until the store retains at most `max-bytes` + bytes. Returns the number evicted." + [max-bytes] + (h/call wasm/internal-module "_evict_images_to_budget" max-bytes)) (defn provision-fonts! "Resolves and uploads every font needed by `shape-ids`, each family fetched diff --git a/exporter/src/app/wasm/pool.cljs b/exporter/src/app/wasm/pool.cljs new file mode 100644 index 0000000000..64f8c9d05e --- /dev/null +++ b/exporter/src/app/wasm/pool.cljs @@ -0,0 +1,246 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.pool + "Pool of headless render workers. + + Mirrors `app.browser`: a `generic-pool` whose objects are `worker_threads` + instead of browsers, so acquisition and eviction behave the same way for both + render backends. A worker is expensive to build (it boots its own render-wasm + module), hence the pooling. + + Acquisition is not capped: the admission scheduler is the backpressure, and + the idle watchdog guarantees a wedged worker gives its slot back. + + Workers run the same bundle as the main thread; `app.core/start` branches on + `isMainThread`. Without the `wasm-export` flag no worker is spawned at all; + with it there is always at least one, since a headless render has nowhere + else to go." + (:require + ["generic-pool" :as gp] + ["node:path" :as path] + ["node:process" :as proc] + ["node:worker_threads" :as wt] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [promesa.core :as p])) + +(l/set-level! :info) + +(defonce pool (atom nil)) +(defonce ^:private worker-id (atom 0)) + +(def ^:private ready-timeout-ms 60000) + +(defn- idle-timeout-ms + "How long a render may go silent before the worker is presumed wedged. Reset + on every message, so a long export keeps its worker as long as it keeps + reporting objects; only a thread stuck inside Skia, which reports nothing and + emits no `exit`, runs it out." + [] + (* 1000 (cf/get :wasm-worker-idle-timeout 300))) + +(defn- worker-script + [] + (path/resolve (aget (.-argv proc/default) 1))) + +(defn- create-worker + [] + (p/create + (fn [resolve reject] + (let [script (worker-script) + id (swap! worker-id inc) + worker (new wt/Worker script) + timer (js/setTimeout + (fn [] + (l/error :hint "render worker did not become ready" :worker-id id) + (.terminate ^js worker) + (reject (ex/error :type :internal + :code :worker-not-ready + :hint "render worker did not become ready"))) + ready-timeout-ms)] + + (unchecked-set worker "__id" id) + (unchecked-set worker "__alive" true) + + (.on ^js worker "error" + (fn [cause] + (l/error :hint "render worker error" :worker-id id :cause cause) + (unchecked-set worker "__alive" false) + ;; A worker that dies while booting has to fail its own creation; + ;; rejecting after `resolve` is a no-op, so this is safe for the + ;; errors that arrive once it is already in the pool. + (js/clearTimeout timer) + (reject cause))) + + (.on ^js worker "exit" + (fn [code] + (l/info :hint "render worker exited" :worker-id id :code code) + (unchecked-set worker "__alive" false))) + + ;; Not `.once`: a stray message before the handshake would consume the + ;; listener and leave the worker hanging until `ready-timeout-ms`. + (letfn [(on-ready [data] + (when (= "ready" (unchecked-get data "type")) + (js/clearTimeout timer) + (.off ^js worker "message" on-ready) + (l/info :origin "factory" :action "create" :worker-id id) + (resolve worker)))] + (.on ^js worker "message" on-ready)))))) + +(def ^:private worker-pool-factory + #js {:create create-worker + :destroy (fn [worker] + (l/info :origin "factory" :action "destroy" + :worker-id (unchecked-get worker "__id")) + (.terminate ^js worker)) + :validate (fn [worker] + (p/resolved (true? (unchecked-get worker "__alive"))))}) + +(defn capacity + "How many renders can run at once, and so how many headless jobs the + scheduler may admit. Zero exactly when headless export is off, which is also + when no job is headless, so a headless job always has a worker to wait for." + [] + (if (contains? cf/flags :wasm-export) + ;; Clamped rather than rejected: a bad value should not stop the exporter + ;; from booting, and a headless render has no other backend to fall back to. + (max 1 (cf/get :wasm-worker-pool-max 2)) + 0)) + +(defn init + [] + (let [configured (cf/get :wasm-worker-pool-max 2) + max-workers (capacity)] + (when (and (pos? max-workers) (not= configured max-workers)) + (l/warn :hint "wasm-worker-pool-max raised to the minimum of one" + :configured configured)) + (if (pos? max-workers) + (let [opts #js {:max max-workers + :min (min max-workers (cf/get :wasm-worker-pool-min 1)) + :testOnBorrow true + :evictionRunIntervalMillis 30000 + :numTestsPerEvictionRun 2 + :idleTimeoutMillis 300000}] + (l/info :hint "initializing render worker pool" :opts opts) + (reset! pool (gp/createPool worker-pool-factory opts))) + (l/info :hint "render worker pool disabled, wasm export is off")) + (p/resolved nil))) + +(defn stop + [] + (when-let [instance @pool] + (l/info :hint "finalizing render worker pool") + (reset! pool nil) + (p/do + (.drain ^js instance) + (.clear ^js instance)))) + +(defn- run-on-worker + "Settles when the worker reports the render finished, failed, or the thread + went away. That last case matters: a terminated worker (how a cancel stops a + render mid-Skia) emits `exit` and never `error`, and a promise left pending + there would keep its pool slot borrowed for the life of the process." + [^js worker params cancel-buffer on-object] + (p/create + (fn [resolve reject] + (let [timer (volatile! nil)] + (letfn [(disarm [] + (when-let [t @timer] + (js/clearTimeout t) + (vreset! timer nil))) + + (rearm [] + (disarm) + (vreset! timer (js/setTimeout + (fn [] + (l/error :hint "render worker went silent, terminating" + :worker-id (unchecked-get worker "__id")) + (cleanup) + ;; Terminating is what frees the pool slot: + ;; the `exit` it raises has no listener left. + (unchecked-set worker "__alive" false) + (.terminate ^js worker) + (reject (ex/error :type :internal + :code :render-timeout + :hint "render worker stopped responding"))) + (idle-timeout-ms)))) + + (cleanup [] + (disarm) + (.off worker "message" on-message) + (.off worker "error" on-error) + (.off worker "exit" on-exit)) + + (on-error [cause] + (cleanup) + (reject cause)) + + (on-exit [code] + (cleanup) + (reject (ex/error :type :internal + :code :worker-exited + :hint (str "render worker exited with code " code)))) + + (on-message [data] + (rearm) + (case (unchecked-get data "type") + ;; A failure while the main thread handles the object (moving + ;; the file, appending to the zip) has to end the render too, + ;; or nothing ever settles this promise. + "object" (try + (on-object (t/decode-str (unchecked-get data "payload"))) + (catch :default cause + (cleanup) + (reject cause))) + "done" (do (cleanup) (resolve nil)) + "error" (do (cleanup) + (reject (ex/error :type :internal + :code (or (some-> (unchecked-get data "code") keyword) + :wasm-render-error) + :hint (unchecked-get data "message")))) + nil))] + + (.on worker "message" on-message) + (.once worker "error" on-error) + (.once worker "exit" on-exit) + (rearm) + (.postMessage worker #js {:type "render" + :params (t/encode-str params) + :cancel cancel-buffer})))))) + +(defn with-worker + "Acquires one worker for the whole of `f`, a fn of that worker." + [f] + (let [instance @pool] + (->> (p/do (.acquire ^js instance)) + (p/mcat (fn [worker] + (->> (p/do (f worker)) + (p/fmap (fn [result] + (.release ^js instance worker) + result)) + (p/merr (fn [cause] + ;; The module may be aborted or mid-write, and + ;; a terminated worker cannot be reused. + (-> (p/do (.destroy ^js instance worker)) + (p/handle (fn [_ _] (p/rejected cause)))))))))))) + +(defn render-on + "Renders `params` on an already acquired worker." + [worker params on-object {:keys [cancel-buffer cancelled?]}] + (if (and cancelled? (cancelled?)) + (p/rejected (ex/error :type :internal + :code :job-cancelled + :hint "export job was cancelled")) + (run-on-worker worker params cancel-buffer on-object))) + +(defn terminate! + [^js worker] + (when worker + (unchecked-set worker "__alive" false) + (.terminate worker))) diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs new file mode 100644 index 0000000000..d286c6cc1f --- /dev/null +++ b/exporter/src/app/wasm/render.cljs @@ -0,0 +1,452 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.render + "Headless render pipeline: renders exports with the render-wasm Skia pipeline, + with no browser and no WebGL. + + Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and + images -> relayout text with the real fonts -> render each object. + + This runs inside a render worker (`app.wasm.worker`), one WASM design state + per worker, so the synchronous Skia calls never block the process that serves + HTTP. `app.renderer.wasm` is the main-thread side that drives it. + + Moved here verbatim from `app.renderer.wasm`; git reads it as a new file only + because that namespace still exists as the proxy. Reviewable as a rename: + `git show :exporter/src/app/renderer/wasm.cljs | diff -u - `. + + Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the + browser path." + (:require + ["node:fs" :as fs] + ["undici" :as http] + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.fonts :as cfnt] + ;; Required for side effects: these register the transit read handlers and + ;; deftype impls the `get-page` response is decoded into. + [app.common.geom.matrix] + [app.common.geom.point] + [app.common.geom.rect] + [app.common.logging :as l] + [app.common.transit :as t] + [app.common.types.fills.impl] + [app.common.types.objects-map] + [app.common.types.path.impl] + [app.common.types.shape] + [app.common.types.shape.images :as images] + [app.common.uri :as u] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.util.mime :as mime] + [app.util.shell :as sh] + [app.wasm :as wasm] + [app.wasm.serialize :as serialize] + [cuerdas.core :as str] + [promesa.core :as p])) + +;; --- module lifecycle (one shared, lazily-initialized instance) + +(defonce ^:private module* (atom nil)) + +(defn- ensure-module! + [] + (or @module* + (reset! module* (wasm/init!)))) + +;; --- backend endpoints +;; +;; Every fetch targets the internal endpoint (falling back to public-uri), +;; in a deployment the exporter reaches the backend over the container network + +(defn- internal-uri + "Absolute URI for `path` on the internal (backend) endpoint." + [path] + (-> (cf/get-internal-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + +(defn- error-detail + "Node's fetch reports every transport failure as a bare `TypeError: fetch + failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a + nested `cause` chain that the logger does not print. Flattens the chain into + one readable string." + [cause] + (->> (iterate (fn [^js e] (unchecked-get e "cause")) cause) + (take-while some?) + (take 5) + (map (fn [^js e] + (let [code (unchecked-get e "code") + msg (or (unchecked-get e "message") (str e))] + (if code (str code ": " msg) msg)))) + (str/join " <- "))) + +(defn- fetch! + "`undici/fetch` that fails with an ex-info carrying the target uri and the + unwrapped cause chain, so a failed request says what actually went wrong and + against which endpoint." + [uri opts] + (->> (p/do (http/fetch uri opts)) + (p/merr (fn [cause] + (p/rejected (ex-info "http fetch failed" + {:uri uri :detail (error-detail cause)} + cause)))))) + +(defn- explain + "Log-friendly reason for `cause`: the detail `fetch!` already attached, or a + freshly unwrapped chain for anything else (WASM aborts, decode errors)." + [cause] + (or (:detail (ex-data cause)) + (error-detail cause))) + +(defn- rpc-headers + "Auth headers for backend RPC calls (management key + bearer)." + [token] + #js {"Content-Type" "application/transit+json" + "X-Shared-Key" (str "exporter " cf/management-key) + "Authorization" (str "Bearer " token)}) + +(defn- asset-headers + "Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to + a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple + authentication types\")." + [token] + #js {"X-Shared-Key" (str "exporter " cf/management-key) + "Cookie" (str "auth-token=" token)}) + +;; --- shape bundle fetch (backend RPC) + +(defn- fetch-objects + "Fetches the exported roots and their children from the backend via the + `get-page` RPC (`:object-id`, as the browser render path does), using the + same auth the exporter uses elsewhere (management key + bearer)." + [{:keys [file-id page-id share-id token objects]}] + (let [headers (rpc-headers token) + root-ids (into #{} (map :id) objects) + body (t/encode-str (cond-> {:file-id file-id + :page-id page-id} + (seq root-ids) (assoc :object-id root-ids) + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-page")] + (l/dbg :hint "wasm render: get-page" + :uri uri + :file-id (str file-id) + :page-id (str page-id) + :roots (count root-ids)) + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (->> (.text resp) + (p/mcat (fn [resp-body] + (l/error :hint "wasm render: get-page failed" + :uri uri + :status (.-status resp) + :body resp-body) + (p/rejected (ex-info "get-page failed" + {:status (.-status resp) + :body resp-body})))))))) + (p/fmap t/decode-str) + (p/fmap :objects)))) + +;; --- font resolution +;; +;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape` +;; reports it. Custom (team) fonts resolve through the file's font variants, +;; google fonts through the shared `app.common.fonts` catalog; builtin +;; fonts through its bundled family + the frontend's static `/fonts/`. + +(defn- fetch-font-variants + "Team (custom) font variants for the file, or nil — a failure here degrades + to fallback fonts, it does not fail the export." + [{:keys [file-id share-id token]}] + (let [headers (rpc-headers token) + body (t/encode-str (cond-> {:file-id file-id} + share-id (assoc :share-id share-id))) + uri (internal-uri "api/rpc/command/get-font-variants")] + (->> (fetch! uri #js {:method "POST" :headers headers :body body}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.text resp) + (p/resolved nil)))) + (p/fmap (fn [s] (when s (t/decode-str s)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: get-font-variants failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- fetch-ttf-bytes + "Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure + here degrades to fallback fonts, it does not fail the export." + ([uri] (fetch-ttf-bytes uri #js {:method "GET"})) + ([uri opts] + (->> (fetch! uri opts) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (p/resolved nil)))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: font fetch failed" + :uri uri :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +;; TTF bytes cached for the process lifetime, keyed by whatever identifies the +;; variant (a gfont id+weight+style, a builtin file name). +(defonce ^:private font-bytes* (atom {})) + +(defn- cached-ttf-bytes + [cache-key fetch-fn] + (if-let [bytes (get @font-bytes* cache-key)] + (p/resolved bytes) + (->> (fetch-fn) + (p/fmap (fn [buf] + (when buf (swap! font-bytes* assoc cache-key buf)) + buf))))) + +(defn- fetch-asset-bytes + [asset-id {:keys [token]}] + (fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id)) + #js {:method "GET" :headers (asset-headers token)})) + +(defn- fetch-gfont-bytes + [ttf-url] + (fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))) + +(defn- fetch-builtin-font-bytes + [ttf-file] + (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) + +(defn- make-resolve-font + "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom + variants first, matching uuid+weight+style then degrading to uuid+weight then + uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps + every builtin family to; google catalog otherwise." + [variants params] + (fn [{:keys [id weight style]}] + (let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3)) + style-str (if (zero? style) "normal" "italic") + variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight) + (= (name (:font-style v)) style-str))) + variants) + (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight))) + variants) + (d/seek (fn [v] (= (:font-id v) font-uuid)) variants))] + (cond + (:ttf-file-id variant) + (fetch-asset-bytes (:ttf-file-id variant) params) + + (= uuid/zero font-uuid) + (fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style)) + + :else + (if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)] + (fetch-gfont-bytes gurl) + (p/resolved nil)))))) + +;; --- fallback fonts (emoji + per-script noto fonts) +;; +;; Emoji and non-latin scripts render through fallback families, not through +;; any span's font family, so `wasm/fonts-for-shape` never reports them and the +;; provisioning above never uploads them. Must run per request, since +;; `clear-fonts!` empties the store; the TTF bytes stay cached per process. + +(defn- scene-fallback-fonts + "Fallback font descriptors needed by the scene's text. Deduped because + several languages map to one noto family and provisioning is concurrent — + otherwise they all miss the byte cache at once and refetch the same TTF." + [scene] + (let [texts (for [shape (vals scene) + :when (= :text (:type shape)) + node (or (some->> (:content shape) (tree-seq :children :children)) []) + :let [text (:text node)] + :when (string? text)] + text) + emoji? (boolean (some cfnt/contains-emoji? texts)) + langs (reduce cfnt/collect-used-languages #{} texts)] + (distinct + (cond-> (cfnt/add-noto-fonts [] langs) + emoji? (cfnt/add-emoji-font))))) + +(defn- fetch-fallback-font-bytes + "Downloads one fallback font's TTF. Cached by the whole variant, not just + `font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a + font-id-only key would serve the first downloaded variant for every other one." + [{:keys [font-id weight style]}] + (if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))] + (cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url)) + (p/resolved nil))) + +(defn- provision-fallback-fonts! + [scene] + (->> (scene-fallback-fonts scene) + (map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}] + (if-let [font-uuid (cfnt/gfont-id->uuid font-id)] + (->> (fetch-fallback-font-bytes font) + (p/fmap (fn [buf] + (if buf + (wasm/store-font! {:id (uuid/get-u32 font-uuid) + :weight weight + :style style + :emoji? (boolean is-emoji) + :fallback? (boolean is-fallback)} + buf) + (l/warn :hint "wasm render: fallback font unavailable" + :font-id font-id))))) + (p/resolved nil)))) + (p/all))) + +;; --- image resolution +;; +;; Image fills reference file-media ids; the encoded bytes go straight to +;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens +;; once per request rather than per rendered object. + +(defn- fetch-file-media-bytes + "Downloads an image fill's encoded bytes by file-media id." + [media-id {:keys [token]}] + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-file-media-id/" media-id))] + (->> (fetch! uri #js {:method "GET" :headers headers}) + (p/mcat (fn [^js resp] + (if (= 200 (.-status resp)) + (.arrayBuffer resp) + (do + (l/warn :hint "wasm render: image fetch non-200" + :media-id (str media-id) + :uri uri + :status (.-status resp)) + (p/resolved nil))))) + (p/merr (fn [cause] + (l/warn :hint "wasm render: image fetch failed" + :media-id (str media-id) :uri uri + :detail (explain cause) :cause cause) + (p/resolved nil)))))) + +(defn- provision-images! + "Fetches and stores every image the scene references (shape, stroke and + text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts, + the image store is not reset per request, so already-held images are skipped + and repeated exports of a file reuse them." + [scene params] + (let [all-ids (images/scene-image-ids scene) + new-ids (remove wasm/image-cached? all-ids)] + (l/dbg :hint "wasm render: provisioning images" + :total (count all-ids) + :cached (- (count all-ids) (count new-ids))) + (->> new-ids + (map (fn [image-id] + (->> (fetch-file-media-bytes image-id params) + (p/fmap (fn [buf] + (if buf + (do + (l/dbg :hint "wasm render: image stored" + :media-id (str image-id) + :bytes (.-byteLength ^js buf)) + (wasm/store-image! image-id buf)) + (l/warn :hint "wasm render: image unavailable" + :media-id (str image-id)))))))) + (p/all)))) + +(defn- relayout-text! + "Recomputes layout for every text shape, once the real fonts are provisioned + (serialize-time layout used the fallback)." + [scene] + (doseq [shape (vals scene) + :when (= :text (:type shape))] + (wasm/update-text-layout! (:id shape)))) + +;; --- render + +(defn- check-cancelled! + "Cancellation is cooperative: a render already inside Skia cannot be + interrupted, so the flag is only observed between objects. Killing a job + mid-object is the caller's job (terminating the worker)." + [{:keys [cancelled?] :as _params}] + (when (and cancelled? (cancelled?)) + (ex/raise :type :internal + :code :job-cancelled + :hint "export job was cancelled"))) + +(defn- render-object-bytes + [type id scale] + (if (= :pdf type) + (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + (wasm/render-shape-raster id scale type))) + +(defn- render* + [{:keys [scale type objects] :as params} on-object] + (l/dbg :hint "wasm render: start" + :type type + :scale scale + :objects (count objects) + :file-id (str (:file-id params)) + :page-id (str (:page-id params))) + (->> (ensure-module!) + (p/mcat (fn [_] (fetch-objects params))) + (p/mcat (fn [scene] + (l/dbg :hint "wasm render: scene fetched" :shapes (count scene)) + (serialize/serialize-scene! scene) + (l/dbg :hint "wasm render: scene serialized") + ;; So fonts from a previous request don't leak into this one. + (wasm/clear-fonts!) + (->> (p/all [(fetch-font-variants params) + (provision-images! scene params) + (provision-fallback-fonts! scene)]) + (p/mcat + (fn [[variants _]] + (let [resolve-font (make-resolve-font (or variants []) params)] + ;; Before rendering, so the relayout below sees real + ;; font metrics. Deduped across objects: shapes + ;; sharing one family download its TTF once. + (wasm/provision-fonts! (map :id objects) resolve-font)))) + (p/mcat + (fn [_] + (relayout-text! scene) + (p/run + (fn [{:keys [id] :as object}] + (check-cancelled! params) + (let [bytes (render-object-bytes type id scale) + path (sh/tempfile :prefix "penpot.tmp.wasm." + :suffix (mime/get-extension type))] + (l/dbg :hint "wasm render: object rendered" + :object-id (str id) :bytes (.-length bytes)) + (fs/writeFileSync path bytes) + ;; `on-object` returns a plain value (zip append) or + ;; a promise (single export's file move); `p/do` + ;; normalizes both to a thenable. + (p/do (on-object (assoc object :path path))))) + objects)))))) + (p/fmap (fn [result] + ;; After the request, never mid-render, so an image can't + ;; disappear under a running export. + (let [evicted (wasm/evict-images! (cf/get :wasm-worker-image-cache-size wasm/image-cache-size))] + (when (pos? evicted) + (l/info :hint "wasm render: evicted cached images" :count evicted))) + result)) + (p/merr (fn [cause] + (l/error :hint "wasm render: failed" + :detail (explain cause) + :internal-uri (str (cf/get-internal-uri)) + :cause cause) + ;; A panic can leave the mem buffer allocated or the instance + ;; aborted; drop it so the next request rebuilds a fresh one. + (reset! module* nil) + (p/rejected cause))))) + +(defn render + "Public entry. Renders every object of `params`, calling `on-object` with + `{:id :filename :path ...}` as each one is written out." + [params on-object] + (render* params on-object)) diff --git a/exporter/src/app/wasm/worker.cljs b/exporter/src/app/wasm/worker.cljs new file mode 100644 index 0000000000..c58f0f9cb2 --- /dev/null +++ b/exporter/src/app/wasm/worker.cljs @@ -0,0 +1,71 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.wasm.worker + "Render worker entry point. + + Owns one render-wasm module and renders one export request at a time. The + Skia calls are synchronous, so running them here is what lets several exports + progress at once: the main thread keeps serving HTTP, zipping and uploading + while this thread is blocked inside a render. + + Messages in: {type: \"render\", params: , cancel: SharedArrayBuffer} + Messages out: {type: \"ready\"} + {type: \"object\", payload: } one per rendered object + {type: \"done\"} | {type: \"error\", message, code}" + (:require + ["node:worker_threads" :as wt] + [app.common.logging :as l] + [app.common.transit :as t] + [app.wasm.render :as render] + [promesa.core :as p])) + +(defn- post! + [message] + (.postMessage ^js wt/parentPort message)) + +(defn- cancelled-fn + [buffer] + (if (some? buffer) + (let [signal (js/Int32Array. buffer)] + (fn [] (pos? (js/Atomics.load signal 0)))) + (constantly false))) + +(defn- handle-render + [data] + (let [params (-> (unchecked-get data "params") + (t/decode-str) + (assoc :cancelled? (cancelled-fn (unchecked-get data "cancel"))))] + (->> (render/render params + (fn [object] + (post! #js {:type "object" :payload (t/encode-str object)}))) + (p/fmap (fn [_] (post! #js {:type "done"}))) + (p/merr (fn [cause] + (l/warn :hint "render worker: request failed" :cause cause) + (post! #js {:type "error" + :message (or (ex-message cause) (str cause)) + :code (some-> cause ex-data :code name)}) + (p/resolved nil)))))) + +(defn- on-message + [data] + (case (unchecked-get data "type") + "render" (handle-render data) + (l/warn :hint "render worker: unknown message" :type (unchecked-get data "type")))) + +(defonce ^:private listening + ;; `defonce` survives a hot reload, so a reload does not stack a second + ;; listener on the port. The indirection through the var keeps the reloaded + ;; `on-message` in play instead of pinning the one captured at boot. + (delay + (.on ^js wt/parentPort "message" (fn [data] (on-message data))) + true)) + +(defn main + [& _] + @listening + (post! #js {:type "ready"}) + (l/info :hint "render worker ready")) diff --git a/exporter/test/exporter_tests/export_shapes_test.cljs b/exporter/test/exporter_tests/export_shapes_test.cljs new file mode 100644 index 0000000000..d512cabd78 --- /dev/null +++ b/exporter/test/exporter_tests/export_shapes_test.cljs @@ -0,0 +1,31 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.export-shapes-test + "Chunking of the browser backend." + (:require + [app.common.uuid :as uuid] + [app.handlers.export-shapes :as export-shapes] + [cljs.test :as t :include-macros true])) + +(defn- exports + [n type scale] + (let [file-id (uuid/next) + page-id (uuid/next)] + (mapv (fn [i] + {:file-id file-id + :page-id page-id + :object-id (uuid/next) + :name (str "shape-" i) + :suffix "" + :scale scale + :type type}) + (range n)))) + +(t/deftest browser-exports-are-chunked + (let [parts (export-shapes/prepare-exports (exports 120 :png 1) "token" false)] + (t/is (= 3 (count parts))) + (t/is (= [50 50 20] (mapv (comp count :objects) parts))))) diff --git a/exporter/test/exporter_tests/jobs_test.cljs b/exporter/test/exporter_tests/jobs_test.cljs new file mode 100644 index 0000000000..899c0f350f --- /dev/null +++ b/exporter/test/exporter_tests/jobs_test.cljs @@ -0,0 +1,86 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.jobs-test + "Job state machine. Runs without redis: a store write with no connection is + reported and swallowed, so only the in-process record is exercised." + (:require + [app.common.uuid :as uuid] + [app.jobs :as jobs] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- create! + [] + (jobs/create! {:profile-id (uuid/next) + :cmd :export-shapes + :backend "wasm" + :total 10 + :name "test" + :resource-id (uuid/next)} + (constantly (p/resolved nil)))) + +(t/deftest progress-does-not-resurrect-a-finished-job + (t/testing "a render reporting after the export failed cannot undo the failure" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/fail! (jobs/lookup (:id job)) (ex-info "boom" {})) + ;; `job` is the snapshot handed to the work when it started, which + ;; is what a straggling render still holds. + _ (jobs/progress! job 7)] + (let [current (jobs/lookup (:id job))] + (t/is (= "error" (:state current))) + (t/is (= "boom" (:error current))) + (t/is (not= 7 (:done current)))) + (jobs/release! (:id job)) + (done))))) + +(t/deftest first-terminal-state-wins + (t/testing "a failure arriving after a cancellation leaves the job cancelled" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/cancel! (:id job)) + _ (jobs/fail! job (ex-info "too late" {}))] + (let [current (jobs/lookup (:id job))] + (t/is (= "cancelled" (:state current))) + (t/is (nil? (:error current)))) + (jobs/release! (:id job)) + (done))))) + +(t/deftest cancel-is-recorded-before-the-callbacks-run + (t/testing "a queued job dropped by its own cancel callback still ends cancelled" + (t/async done + (let [seen (atom ::not-called)] + (p/let [job (create!) + ;; What `scheduler/drop-queued!` does: it takes the job off the + ;; queue and releases it. Anything the lifecycle wrote after + ;; the callbacks ran would be dropped on the floor, so by the + ;; time one is called the record has to be terminal already. + _ (jobs/on-cancel (:id job) + (fn [] + (reset! seen (:state (jobs/lookup (:id job)))) + (jobs/release! (:id job)))) + _ (jobs/cancel! (:id job))] + (t/is (= "cancelled" @seen)) + (t/is (nil? (jobs/lookup (:id job)))) + (done)))))) + +(t/deftest writes-stop-once-the-job-is-released + (t/testing "a late write for a job the scheduler already settled is dropped" + (t/async done + (p/let [job (create!) + _ (jobs/start! job) + _ (jobs/complete! (jobs/lookup (:id job)) {:uri "http://example/x" + :filename "x.zip" + :mtype "application/zip"}) + ended (jobs/lookup (:id job)) + _ (jobs/release! (:id job)) + _ (jobs/progress! job 3)] + (t/is (= "ended" (:state ended))) + (t/is (nil? (jobs/lookup (:id job)))) + (done))))) diff --git a/exporter/test/exporter_tests/runner.cljs b/exporter/test/exporter_tests/runner.cljs index a0b7c3020d..82c3e4c46f 100644 --- a/exporter/test/exporter_tests/runner.cljs +++ b/exporter/test/exporter_tests/runner.cljs @@ -10,15 +10,23 @@ [cljs.test :as t] [clojure.string :as str] [clojure.tools.cli :refer [parse-opts]] + [exporter-tests.export-shapes-test] + [exporter-tests.jobs-test] [exporter-tests.renderer-svg-test] + [exporter-tests.scheduler-test] [exporter-tests.shell-test] + [exporter-tests.wasm-pool-test] [goog.object :as gobj])) (enable-console-print!) (def test-namespaces - ['exporter-tests.renderer-svg-test - 'exporter-tests.shell-test]) + ['exporter-tests.export-shapes-test + 'exporter-tests.jobs-test + 'exporter-tests.renderer-svg-test + 'exporter-tests.scheduler-test + 'exporter-tests.shell-test + 'exporter-tests.wasm-pool-test]) (assert (every? find-ns-obj test-namespaces) "test-namespaces contains a namespace that isn't required in runner.cljs") diff --git a/exporter/test/exporter_tests/scheduler_test.cljs b/exporter/test/exporter_tests/scheduler_test.cljs new file mode 100644 index 0000000000..48c3791632 --- /dev/null +++ b/exporter/test/exporter_tests/scheduler_test.cljs @@ -0,0 +1,81 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.scheduler-test + "Admission control. A headless job leases one render worker for its whole run, + so no more of them may start than there are workers." + (:require + [app.common.uuid :as uuid] + [app.jobs :as jobs] + [app.jobs.scheduler :as scheduler] + [app.wasm.pool :as pool] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- pin-capacity! + "Fixes the worker count for the test. Returns the thunk that puts it back; + `with-redefs` cannot be used here, since the scheduler keeps admitting jobs + after the body of the test has returned." + [n] + (let [original pool/capacity] + (set! pool/capacity (constantly n)) + (fn [] (set! pool/capacity original)))) + +(defn- create! + [backend run-fn] + (jobs/create! {:profile-id (uuid/next) + :cmd :export-shapes + :backend backend + :total 1 + :name "test" + :resource-id (uuid/next)} + run-fn)) + +(defn- gate + "A promise and the fn that settles it, standing in for a render in flight." + [] + (let [resolve* (volatile! nil) + pending (p/create (fn [resolve _] (vreset! resolve* resolve)))] + [pending (fn [] (@resolve* nil))])) + +(t/deftest headless-jobs-wait-for-a-render-worker + (t/testing "with one worker, the second headless job stays queued until the first ends" + (t/async done + (let [restore! (pin-capacity! 1) + [render open] (gate) + started (atom [])] + (p/let [job1 (create! "wasm" (fn [_] (swap! started conj :one) render)) + job2 (create! "wasm" (fn [_] (swap! started conj :two) (p/resolved nil)))] + (let [p1 (scheduler/submit! job1) + p2 (scheduler/submit! job2)] + (p/do + (p/delay 10) + (t/is (= [:one] @started)) + (t/is (= "running" (:state (jobs/lookup (:id job1))))) + (t/is (= "queued" (:state (jobs/lookup (:id job2))))) + (open) + (p/all [p1 p2]) + (t/is (= [:one :two] @started)) + (restore!) + (done)))))))) + +(t/deftest a-browser-job-is-not-held-back-by-the-worker-pool + (t/testing "the headless cap applies to headless jobs only" + (t/async done + (let [restore! (pin-capacity! 1) + [render open] (gate) + started (atom [])] + (p/let [job1 (create! "wasm" (fn [_] (swap! started conj :wasm) render)) + job2 (create! "browser" (fn [_] (swap! started conj :browser) (p/resolved nil)))] + (let [p1 (scheduler/submit! job1) + p2 (scheduler/submit! job2)] + (p/do + (p/delay 10) + (t/is (= [:wasm :browser] @started)) + (open) + (p/all [p1 p2]) + (restore!) + (done)))))))) diff --git a/exporter/test/exporter_tests/wasm_pool_test.cljs b/exporter/test/exporter_tests/wasm_pool_test.cljs new file mode 100644 index 0000000000..4bf85eaa82 --- /dev/null +++ b/exporter/test/exporter_tests/wasm_pool_test.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns exporter-tests.wasm-pool-test + "Worker leasing, against a stub pool: `with-worker` must give the worker back + however its body ends." + (:require + [app.wasm.pool :as pool] + [cljs.test :as t :include-macros true] + [promesa.core :as p])) + +(defn- stub-pool! + "Installs a pool whose acquire/release/destroy only count calls." + [] + (let [calls (atom {:acquired 0 :released 0 :destroyed 0})] + (reset! pool/pool + #js {:acquire (fn [] (swap! calls update :acquired inc) (p/resolved ::worker)) + :release (fn [_] (swap! calls update :released inc) (p/resolved nil)) + :destroy (fn [_] (swap! calls update :destroyed inc) (p/resolved nil))}) + calls)) + +(t/deftest releases-the-worker-when-the-body-succeeds + (t/async done + (let [calls (stub-pool!)] + (p/let [result (pool/with-worker (fn [_] (p/resolved :ok)))] + (t/is (= :ok result)) + (t/is (= 1 (:acquired @calls))) + (t/is (= 1 (:released @calls))) + (t/is (= 0 (:destroyed @calls))) + (reset! pool/pool nil) + (done))))) + +(t/deftest gives-the-worker-back-when-the-body-throws-synchronously + (t/testing "a raise out of the scope body must not leave the worker borrowed" + (t/async done + (let [calls (stub-pool!)] + (->> (pool/with-worker (fn [_] (throw (ex-info "cancelled" {})))) + (p/hmap (fn [_ cause] + (t/is (some? cause)) + (t/is (= 1 (:acquired @calls))) + (t/is (= 1 (+ (:released @calls) (:destroyed @calls)))) + (reset! pool/pool nil) + (done)))))))) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 0ca870db09..f99bdb93cf 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -8,6 +8,7 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] @@ -141,35 +142,57 @@ :name page-name})))))) (defn- initialize-export-status - [exports cmd resource] + "`job` is only present on the job API path; without it the widget counts the + exports the client submitted, exactly as it always has." + [exports cmd resource {:keys [job-id total status backend] :as job}] (ptk/reify ::initialize-export-status ptk/UpdateEvent (update [_ state] - (assoc state :export {:in-progress true - :resource-id (:id resource) - :healthy? true - :error false - :progress 0 - :widget-visible true - :detail-visible true - :exports exports - :last-update (ct/now) - :cmd cmd})))) + (assoc state :export (cond-> {:in-progress true + :resource-id (:id resource) + :healthy? true + :error false + :progress 0 + :widget-visible true + :detail-visible true + :exports exports + :last-update (ct/now) + :cmd cmd} + (some? job) + (assoc :job-id job-id + :total total + :status status + :backend backend)))))) (defn- update-export-status - [{:keys [done status resource-uri filename mtype] :as data}] + [{:keys [done total status resource-uri filename mtype] :as data}] (ptk/reify ::update-export-status ptk/UpdateEvent (update [_ state] (let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now)) - healthy? (< time-diff 6000)] + healthy? (< time-diff 6000) + ;; The legacy path has no server-side figures to track; it keeps + ;; reporting progress over the client's own list. + job? (some? (get-in state [:export :job-id]))] (cond-> state + job? + (update :export assoc :status status) + + (and job? (some? total)) + (update :export assoc :total total) + (= status "running") (update :export assoc :progress done :last-update (ct/now) :healthy? healthy?) (= status "error") (update :export assoc :in-progress false :error (:cause data) :last-update (ct/now) :healthy? healthy?) + (= status "cancelling") + (update :export assoc :last-update (ct/now) :healthy? healthy?) + + (= status "cancelled") + (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?) + (= status "ended") (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?)))) @@ -178,17 +201,78 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) +;; The exporter is at capacity. Not a crash: the widget says so and the user +;; retries, instead of the generic error dialog. +(def ^:private saturation-codes #{:queue-full}) + +(defn- export-failed + "Reports a failure that happened before the export ever started, so the widget + settles instead of waiting for progress that will never arrive." + [exports cmd cause] + (ptk/reify ::export-failed + ptk/UpdateEvent + (update [_ state] + (assoc state :export {:in-progress false + :widget-visible true + :detail-visible true + :healthy? true + :progress 0 + :total (count exports) + :exports exports + :cmd cmd + :error (or (ex-message cause) true) + :error-code (:code (ex-data cause)) + :last-update (ct/now)})))) + +(defn cancel-export + "Stops the running export. Only reachable on the job API path, where the + exporter can actually abort the work. + + The widget settles from here rather than from the job's `cancelled` message: + the outcome is known once the request returns, and waiting on a round trip + through redis and the websocket would leave it stuck whenever that message is + missed." + [] + (ptk/reify ::cancel-export + ptk/WatchEvent + (watch [_ state _] + (when-let [job-id (get-in state [:export :job-id])] + (let [resource-id (get-in state [:export :resource-id]) + settle (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout)))] + (rx/concat + ;; Stopping is not instantaneous: the request has to reach the + ;; exporter and the work has to unwind. + (rx/of (update-export-status {:status "cancelling"})) + (->> (rp/cmd! :cancel-export-job {:job-id job-id}) + (rx/mapcat (fn [_] settle)) + ;; Already finished, or the exporter is gone; either way + ;; there is nothing left to stop. + (rx/catch (fn [_] settle))))))))) + ;; TODO: Remove once we support WASM SVG export (def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) (defn- wasm-export-enabled? "WASM export is available when the `wasm-export/v1` feature is active AND render-wasm is active for the current file. When render-wasm is inactive its - shape tree isn't loaded, so a client-side WASM render would crash." + shape tree isn't loaded, so a client-side WASM render would crash. + + This governs the client-side render only; it says nothing about the exporter." [state] (and (features/active-feature? state "wasm-export/v1") (features/active-feature? state "render-wasm/v1"))) +(defn- wasm-export-available? + "Whether the *exporter* renders with render-wasm. Its `enable-wasm-export` + flag has to be on too, otherwise the browser backend does the work and the + job API would promise capabilities the server does not have." + [state] + (and (wasm-export-enabled? state) + (contains? cf/flags :wasm-export))) + (defn- use-wasm-export? "Whether to take the client-side WASM export path for `export`." [state export] @@ -223,7 +307,7 @@ :profile-id profile-id :cmd :export-shapes :wait true - :is-wasm (wasm-export-enabled? state)})] + :is-wasm (wasm-export-available? state)})] (rx/concat (dwp/force-persist-and-wait 400) @@ -252,7 +336,7 @@ :cmd cmd :profile-id profile-id :force-multiple true - :is-wasm (wasm-export-enabled? state)} + :is-wasm (wasm-export-available? state)} (some? name) (assoc :name name)) @@ -266,7 +350,8 @@ stopper (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %))) + (= "error" (:status %)) + (= "cancelled" (:status %))) progress-stream)] (swap! st/ongoing-tasks conj :export) @@ -276,11 +361,30 @@ (rx/of ::dwp/force-persist) ;; Launch the exportation process and stores the resource id - ;; locally. - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource)))) + ;; locally. With wasm export active the job API is used instead: it + ;; answers with the exporter's own object count and gives a handle + ;; to cancel. + (->> (if (wasm-export-available? state) + (->> (rp/cmd! :create-export-job params) + (rx/map (fn [{job-id :id :keys [total] :as job}] + (vreset! resource-id (:resource-id job)) + (initialize-export-status exports cmd + {:id (:resource-id job)} + {:job-id job-id + :total total + :status (:state job) + :backend (:backend job)})))) + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource nil))))) + (rx/catch (fn [cause] + ;; Saturation is an answer, not a fault. + (if (contains? saturation-codes (:code (ex-data cause))) + (rx/of (export-failed exports cmd cause)) + (rx/concat + (rx/of (export-failed exports cmd cause)) + (rx/throw cause)))))) ;; We proceed to update the export state with incoming ;; progress updates. We delay the stopper for give some time @@ -297,7 +401,8 @@ ;; for ensure that after some security time, the stream is ;; completely closed. (->> progress-stream - (rx/filter #(= "ended" (:status %))) + (rx/filter #(or (= "ended" (:status %)) + (= "cancelled" (:status %)))) (rx/take 1) (rx/delay default-timeout) (rx/map #(clear-export-state @resource-id)) @@ -316,7 +421,7 @@ (watch [_ state _] (let [params (select-keys (:export state) [:exports :cmd])] (when (seq params) - (rx/of (request-multiple-export params))))))) + (rx/of (request-export params))))))) (defn export-shapes-event [exports origin] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 722c9a882a..68749a7f45 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -276,6 +276,28 @@ (let [default {:wait false :blob? false}] (send-export (merge default params)))) +(defmethod cmd! :create-export-job + [_ params] + (->> (http/send! {:method :post + :uri (u/join cf/public-uri "api/export/jobs") + :body (http/transit-data params) + :headers {"x-external-session-id" (cf/external-session-id) + "x-event-origin" (::ev/origin (meta params))} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + +(defmethod cmd! :cancel-export-job + [_ {:keys [job-id]}] + (->> (http/send! {:method :delete + :uri (u/join cf/public-uri "api/export/jobs/" (str job-id)) + :headers {"x-external-session-id" (cf/external-session-id)} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + (defn- multipart-upload [id params] (->> (http/send! {:method :post diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index 052910c404..f1be8e32a2 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -218,11 +218,25 @@ theme (or (:theme profile) theme/default) is-default-theme? (= theme/default theme) error? (:error state) + ;; The exporter is at capacity: worth its own wording, so the user + ;; knows retrying later is the thing to do. + busy? (= :queue-full (:error-code state)) healthy? (:healthy? state) detail-visible? (:detail-visible state) widget-visible? (:widget-visible state) progress (:progress state) items (:exports state) + job-id (:job-id state) + status (:status state) + queued? (and (some? job-id) (= "queued" status)) + cancelling? (and (some? job-id) (= "cancelling" status)) + cancelled? (and (some? job-id) (= "cancelled" status)) + ;; Only the wasm backend can actually stop: a browser render holds its + ;; pool slot until playwright gives up. + cancellable? (and (some? job-id) + (= "wasm" (:backend state)) + (:in-progress state) + (not cancelling?)) total (or (:total state) (count items)) complete? (= progress total) circ (* 2 Math/PI 12) @@ -236,6 +250,8 @@ color (cond error? clr/new-danger + (or cancelling? + cancelled?) clr/new-warning healthy? (if is-default-theme? clr/new-primary clr/new-primary-light) @@ -248,11 +264,20 @@ title (cond + busy? (tr "workspace.options.exporting-busy") error? (tr "workspace.options.exporting-object-error") + cancelling? (tr "workspace.options.exporting-cancelling") + cancelled? (tr "workspace.options.exporting-cancelled") + queued? (tr "workspace.options.exporting-queued") complete? (tr "workspace.options.exporting-complete") healthy? (tr "workspace.options.exporting-object") (not healthy?) (tr "workspace.options.exporting-object-slow")) + cancel-export + (mf/use-fn + (fn [] + (st/emit! (de/cancel-export)))) + retry-last-operation (mf/use-fn (fn [] @@ -294,11 +319,25 @@ [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] - (if error? + (cond + error? [:button {:class (stl/css :retry-btn) :on-click retry-last-operation} (tr "workspace.options.retry")] + cancellable? + [:* + [:button {:class (stl/css :retry-btn) + :on-click cancel-export} + (tr "workspace.options.cancel-export")] + [:span {:class (stl/css :progress)} + (dm/str progress " / " total)]] + + ;; A counter for work that is being abandoned says nothing useful. + (or cancelling? cancelled?) + nil + + :else [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index ed3725a732..f3e5468b10 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7775,10 +7775,30 @@ msgstr "Remove export" msgid "workspace.options.export.suffix" msgstr "Suffix" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancel" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Export complete" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Export cancelled" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelling..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Waiting..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "Export service is busy, please try again later" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exporting…" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6be38e578b..fe2f03aa3e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7564,10 +7564,30 @@ msgstr "Eliminar exportación" msgid "workspace.options.export.suffix" msgstr "Sufijo" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancelar" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Exportación completa" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Exportación cancelada" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelando..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Esperando..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "La cola de exportación está llena, inténtalo de nuevo en unos momentos" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exportando…" diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c5bff2105f..cc145d0cf1 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -743,15 +743,14 @@ pub extern "C" fn is_image_cached( } /// Evicts least-recently-used images until the store retains at most -/// `max_mb` megabytes of image data. Called by the headless exporter between +/// `max_bytes` bytes of image data. Called by the headless exporter between /// requests — never mid-render, so an image can't disappear under a running /// export; evicted images are re-provisioned by later requests that need /// them. Returns the number of evicted images. #[no_mangle] #[wasm_error] -pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result { - let max_bytes = (max_mb as usize) * 1024 * 1024; - let evicted = get_resources().images.evict_to_budget(max_bytes); +pub extern "C" fn evict_images_to_budget(max_bytes: u32) -> Result { + let evicted = get_resources().images.evict_to_budget(max_bytes as usize); Ok(evicted as u32) } From 736b99076f50af9d1d3225f417be8d78d9b8c5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 31 Aug 2026 15:50:19 +0200 Subject: [PATCH 235/298] :bug: Fix shapes cut when overflowing a non-clipping board (#11423) --- render-wasm/src/main.rs | 2 +- render-wasm/src/render.rs | 78 ++++++++++++++-------------- render-wasm/src/shapes.rs | 20 ++++--- render-wasm/src/state.rs | 12 +---- render-wasm/src/state/shapes_pool.rs | 57 +++++++++++++++++--- 5 files changed, 106 insertions(+), 63 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index cc145d0cf1..6ea370a9a5 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -910,7 +910,7 @@ pub extern "C" fn clean_modifiers() -> Result<()> { // the same tiles for the active modifier set, so the eviction // here is redundant and doubles the per-emission cost. if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() { - render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + render_state.update_tiles_shapes(&prev_modifier_ids, &state.shapes)?; } }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 985a2ca57a..e5c563d03e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -4202,22 +4202,8 @@ impl RenderState { result } - /* - * Incremental version of update_shape_tiles for pan/zoom operations. - * Updates the tile index and returns ONLY tiles that need cache invalidation. - * - * During pan operations, shapes don't move in world coordinates. The interest - * area (viewport) moves, which changes which tiles we track in the index, but - * tiles that were already cached don't need re-rendering just because the - * viewport moved. - * - * This function: - * 1. Updates the tile index (adds/removes shapes from tiles based on interest area) - * 2. Returns empty vec for cache invalidation (pan doesn't change tile content) - * - * Tile cache invalidation only happens when shapes actually move or change, - * which is handled by rebuild_touched_tiles, not during pan/zoom. - */ + /// Diffs the shape's tile set, leaving cached tiles alone. For callers where the + /// index moves but painted content does not: pan/zoom. pub fn update_shape_tiles_incremental( &mut self, shape: &Shape, @@ -4378,22 +4364,15 @@ impl RenderState { } } + self.index_dependent_ancestors(&ids, tree); + performance::end_measure!("rebuild_touched_tiles"); } - /// Invalidates extended rectangles and updates tiles for a set of shapes - /// - /// This function takes a set of shape IDs and for each one: - /// 1. Invalidates the extrect cache - /// 2. Updates the tiles to ensure proper rendering - /// - /// This is useful when you have a pre-computed set of shape IDs that need to be refreshed, - /// regardless of their relationship to other shapes (e.g., ancestors, descendants, or any other collection). - pub fn update_tiles_shapes( - &mut self, - shape_ids: &[Uuid], - tree: ShapesPoolMutRef<'_>, - ) -> Result<()> { + /// Re-indexes a set of shapes and evicts the cached tiles they dirty. Extrect caches + /// are not dropped here: `State::touch_shape` and `rebuild_modifier_tiles` invalidate + /// them at the source. + pub fn update_tiles_shapes(&mut self, shape_ids: &[Uuid], tree: ShapesPoolRef) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { @@ -4439,22 +4418,41 @@ impl RenderState { self.surfaces.invalidate_cached_tiles_intersecting(dirty); } - /// Rebuilds tiles for shapes with modifiers and processes their ancestors - /// - /// This function applies transformation modifiers to shapes and updates their tiles. - /// Additionally, it processes all ancestors of modified shapes to ensure their - /// extended rectangles are properly recalculated and their tiles are updated. - /// This is crucial for frames and groups that contain transformed children. + fn index_dependent_ancestors(&mut self, ids: &HashSet, tree: ShapesPoolRef) { + if ids.is_empty() { + return; + } + + let mut seen = HashSet::::new(); + + for id in ids.iter() { + for ancestor_id in tree.dependent_ancestor_ids(id) { + if ids.contains(&ancestor_id) || !seen.insert(ancestor_id) { + break; + } + let Some(shape) = tree.get(&ancestor_id) else { + continue; + }; + // A hidden ancestor paints nothing, but its parent may + if shape.hidden() { + continue; + } + let _ = self.update_shape_tiles_incremental(shape, tree); + } + } + } + pub fn rebuild_modifier_tiles( &mut self, tree: ShapesPoolMutRef<'_>, ids: &[Uuid], ) -> Result<()> { - // During interactive transform, skip ancestor invalidation: walking up to the - // parent frame evicts every tile the frame covers, including dense tiles with - // many siblings. Ancestor extrect caches are already invalidated by - // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by - // the committing code path (rebuild_touched_tiles). + // `set_modifiers` runs per pointer move, this runs once per rAF, so the ancestor + // caches are dropped here. Must precede any read of their tile coverage below. + for id in ids { + tree.invalidate_ancestors_extrect(id); + } + if self.options.is_interactive_transform() { self.update_tiles_shapes(ids, tree)?; } else { diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e5202e651a..e93fd35e24 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -960,6 +960,14 @@ impl Shape { Bounds::from_rect(&rect) } + pub fn extrect_depends_on_children(&self) -> bool { + match self.shape_type { + Type::Group(Group { masked: true }) => true, + Type::Group(_) | Type::Frame(_) => !self.clip_content, + _ => false, + } + } + fn apply_children_bounds( &self, bounds: Bounds, @@ -1124,21 +1132,21 @@ impl Shape { } fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { + // Own outsets (strokes, shadows, blur) are local-space, so they expand before the + // shape transform. Children extrects are already world-space: join them after it. let mut bounds = self.own_extrect_bounds(); - bounds = self.apply_children_bounds(bounds, shapes_pool, scale); - bounds = self.apply_children_blur(bounds, shapes_pool); if !self.transform.is_identity() { - // Expand everything in the shape's local axis-aligned space first (strokes, - // shadows, blur, children). Only after that do we map the resulting bounds - // through the shape transform so rotation/skew is reflected in the final - // extrect. let mut matrix = self.transform; let center = self.center(); matrix.post_translate(center); matrix.pre_translate(-center); bounds.transform_mut(&matrix); } + + bounds = self.apply_children_bounds(bounds, shapes_pool, scale); + bounds = self.apply_children_blur(bounds, shapes_pool); + bounds.to_rect() } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 8e9be844d7..6a29bef62a 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -341,21 +341,13 @@ impl State { } pub fn touch_current(&mut self) { - // `mark_touched` only drives incremental on-screen tile invalidation; - // the headless export path has no render state, so skip it there. - if self.loading || !has_render_state() { - return; - } if let Some(current_id) = self.current_id { - let prev = self - .shapes - .get(¤t_id) - .map(|shape| shape.extrect(&self.shapes, 1.0)); - get_render_state().mark_touched_with_prev(current_id, prev); + self.touch_shape(current_id); } } pub fn touch_shape(&mut self, id: Uuid) { + self.shapes.invalidate_ancestors_extrect(&id); if self.loading || !has_render_state() { return; } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f57bbcb51b..7fcc9ab642 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -239,6 +239,51 @@ impl ShapesPoolImpl { self.modified_shape_cache.clear() } + pub fn dependent_ancestor_ids<'a>(&'a self, id: &Uuid) -> impl Iterator + 'a { + let mut current = self + .uuid_to_idx + .get(id) + .and_then(|idx| self.shapes[*idx].parent_id); + + std::iter::from_fn(move || { + let parent_id = current.filter(|parent_id| !parent_id.is_nil())?; + let parent_idx = self.uuid_to_idx.get(&parent_id).copied()?; + let parent = &self.shapes[parent_idx]; + if !parent.extrect_depends_on_children() { + return None; + } + current = parent.parent_id; + Some(parent_id) + }) + } + + /// Drops the extrect cache of every ancestor whose extrect is affected by this shape + /// stopping at the first ancestor that clips. + pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) { + let mut current = self + .uuid_to_idx + .get(id) + .and_then(|idx| self.shapes[*idx].parent_id); + + while let Some(parent_id) = current.filter(|parent_id| !parent_id.is_nil()) { + let Some(parent_idx) = self.uuid_to_idx.get(&parent_id).copied() else { + break; + }; + if !self.shapes[parent_idx].extrect_depends_on_children() { + break; + } + + self.shapes[parent_idx].invalidate_extrect(); + // `get` returns a snapshot clone, we need to get mut + // and replace the OnceCell to reset it. + if let Some(cell) = self.modified_shape_cache.get_mut(&parent_idx) { + *cell = OnceCell::new(); + } + + current = self.shapes[parent_idx].parent_id; + } + } + pub fn set_modifiers(&mut self, modifiers: HashMap) { let mut ids = Vec::::new(); let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); @@ -254,10 +299,8 @@ impl ShapesPoolImpl { // When CLJS sends only root shapes (translation on drag), descendants // need the same matrix. // For resize/rotate, propagate-modifiers already includes all descendants. - // Descendants are NOT pushed into `ids` / `modifier_uuids`: tile invalidation - // via rebuild_modifier_tiles only runs for roots, which is sufficient because - // descendants always lie inside the parent's bounding box and are therefore - // covered by the parent's old/new tile ranges. + // Descendants are NOT pushed into `ids` / `modifier_uuids`: rebuild_modifier_tiles + // runs for roots, and drops the non-clipping ancestors' extrects separately. let root_pairs: Vec<(usize, skia::Matrix)> = ids .iter() .filter_map(|uuid| { @@ -289,13 +332,15 @@ impl ShapesPoolImpl { // Compute ancestors before consuming `ids` so we can move it into // `modifier_uuids` without a clone. let all_ids = shapes::all_with_ancestors(&ids, self, true); - // rebuild_modifier_tiles doesn't process every descendant individually. - self.modifier_uuids = ids; + for uuid in all_ids { if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { self.modified_shape_cache.insert(idx, OnceCell::new()); } } + + // rebuild_modifier_tiles doesn't process every descendant individually. + self.modifier_uuids = ids; } pub fn set_structure(&mut self, structure: HashMap>) { From 89a17df403cc747c1b6505b9206e30d659018c1a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 31 Aug 2026 16:06:38 +0200 Subject: [PATCH 236/298] :sparkles: Add basic WASM SVG export path (#10891) Introduce a minimal vector SVG exporter via render-wasm: solid fills, native opacity/blend wrappers, frame clip-content, FFI/frontend wiring, insta snapshots, and a visual preview gallery. Defer save_layer workarounds (shadows, blur, masks, special strokes) to follow-up PRs. --- .../src/app/main/data/exports/assets.cljs | 3 +- frontend/src/app/main/data/exports/wasm.cljs | 14 ++ frontend/src/app/render_wasm/api.cljs | 19 ++ render-wasm/Cargo.lock | 70 ++++++ render-wasm/Cargo.toml | 3 + render-wasm/preview-snapshots | 145 +++++++++++ render-wasm/src/main.rs | 16 ++ render-wasm/src/render.rs | 1 + render-wasm/src/render/svg/document.rs | 238 ++++++++++++++++++ render-wasm/src/render/svg/fixtures.rs | 71 ++++++ render-wasm/src/render/svg/frames.rs | 66 +++++ render-wasm/src/render/svg/groups.rs | 35 +++ render-wasm/src/render/svg/mod.rs | 167 ++++++++++++ ..._clipped_frame_with_overflowing_child.snap | 11 + ...roup_with_two_rects_and_group_opacity.snap | 9 + ...der__svg__tests__exports_a_solid_rect.snap | 8 + ...nclipped_frame_with_overflowing_child.snap | 10 + ...city_and_blend_mode_as_group_wrappers.snap | 8 + render-wasm/src/render/svg/tests.rs | 176 +++++++++++++ render-wasm/src/render/vector.rs | 7 +- render-wasm/src/state.rs | 4 + 21 files changed, 1078 insertions(+), 3 deletions(-) create mode 100755 render-wasm/preview-snapshots create mode 100644 render-wasm/src/render/svg/document.rs create mode 100644 render-wasm/src/render/svg/fixtures.rs create mode 100644 render-wasm/src/render/svg/frames.rs create mode 100644 render-wasm/src/render/svg/groups.rs create mode 100644 render-wasm/src/render/svg/mod.rs create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap create mode 100644 render-wasm/src/render/svg/tests.rs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index f99bdb93cf..24b290c15d 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -253,7 +253,7 @@ (rx/catch (fn [_] settle))))))))) ;; TODO: Remove once we support WASM SVG export -(def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) +(def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (defn- wasm-export-enabled? "WASM export is available when the `wasm-export/v1` feature is active AND @@ -286,6 +286,7 @@ (effect [_ _ _] (case (:type export) :pdf (wasm.exports/export-pdf export) + :svg (wasm.exports/export-svg export) (wasm.exports/export-image export))))) (defn request-simple-export diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 25d1405c8d..d2258b5e85 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -44,3 +44,17 @@ (js/queueMicrotask #(wapi/revoke-uri url)) nil)) +(defn export-svg-uri + [{:keys [scale object-id]}] + (let [bytes (wasm.api/render-shape-svg object-id (or scale 1)) + blob (wapi/create-blob bytes "image/svg+xml")] + (wapi/create-uri blob))) + +(defn export-svg + [{:keys [suffix name] :as params}] + (let [url (export-svg-uri params) + filename (str name (or suffix "") ".svg")] + (dom/trigger-download-uri filename "image/svg+xml" url) + (js/queueMicrotask #(wapi/revoke-uri url)) + nil)) + diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a1fc87a83d..aaf8112c7f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2870,6 +2870,25 @@ (mem/free) result))) +(defn render-shape-svg + [shape-id scale] + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) + offset + (h/call wasm/internal-module "_render_shape_svg" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale) + + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 4) length)] + (mem/free) + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 5d749143fd..05fbd20e6a 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -99,6 +99,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -114,6 +125,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -130,6 +147,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filetime" version = "0.2.25" @@ -222,6 +245,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.12.1" @@ -426,6 +461,7 @@ dependencies = [ "gl", "glam", "indexmap", + "insta", "macros", "skia-safe", "thiserror", @@ -519,6 +555,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "skia-bindings" version = "0.93.1" @@ -582,6 +624,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -721,6 +776,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -739,6 +800,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index b17e3aa69f..3c467c1fa2 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -37,6 +37,9 @@ skia-safe = { version = "0.93.1", default-features = false, features = [ thiserror = "2.0.18" uuid = { version = "1.11.0", features = ["v4", "js"] } +[dev-dependencies] +insta = "1" + [profile.release] opt-level = 3 lto = "fat" diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots new file mode 100755 index 0000000000..43a23421ca --- /dev/null +++ b/render-wasm/preview-snapshots @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# Visual review helper for the SVG exporter's `insta` snapshots. +# +# This script renders the insta snapshots into an HTML gallery +# so you can judge whether a change is *visually* valid +# before accepting it. +# +# Usage: +# ./preview-snapshots # build the gallery and print its path +# ./preview-snapshots --open # also open it in the default browser +# +# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies +# the bundled font into `target/svg-preview/fonts/` so the gallery renders text. +# +# When a test produced a pending change there will be a `*.snap.new` next to the +# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side. +# Once a change looks correct, accept it (rename `*.snap.new` -> `*.snap`, or +# `cargo insta accept`) and re-run the tests. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# `mod tests;` lives as `svg/tests.rs`, so insta writes under `svg/snapshots/`. +SNAP_DIR="$SCRIPT_DIR/src/render/svg/snapshots" +OUT_DIR="$SCRIPT_DIR/target/svg-preview" +OUT="$OUT_DIR/index.html" +FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf" +FONT_DIR="$OUT_DIR/fonts" + +mkdir -p "$OUT_DIR" +mkdir -p "$FONT_DIR" +cp "$FONT_SRC" "$FONT_DIR/" + +# Prints the SVG body of a snapshot file: everything after the second `---` +# line (the YAML front matter insta writes). +extract_body() { + awk 'd>=2{print} /^---$/{d++}' "$1" +} + +# Embeds a snapshot's SVG via pointing at a standalone .svg file. +# +# Snapshots must NOT be inlined together into one HTML document: each SVG +# restarts its def ids at `clip0`/`blur0`/..., so inlining several in the same +# document makes `url(#clip0)` collide and resolve to the wrong (or empty) clip +# path. Writing each SVG to its own file isolates ids per document. +# We use (not ) so linked @font-face rules and other external +# resources resolve inside the SVG document. +svg_counter=0 +read_svg_dims() { + local file="$1" + local tag + tag=$(grep -m1 '.*//') + SVG_W=$(echo "$tag" | sed -n 's/.*[[:space:]]width="\([^"]*\)".*/\1/p') + SVG_H=$(echo "$tag" | sed -n 's/.*[[:space:]]height="\([^"]*\)".*/\1/p') +} +emit_svg_box() { + local body_file="$OUT_DIR/svg-$svg_counter.svg" + local name + svg_counter=$((svg_counter + 1)) + name="$(basename "$body_file")" + extract_body "$1" > "$body_file" + read_svg_dims "$body_file" + echo "
    " +} + +{ + cat <<'HTML' + + +render-wasm SVG snapshot preview + +

    render-wasm SVG snapshot preview

    +HTML + + shopt -s nullglob + + for snap in "$SNAP_DIR"/*.snap; do + name="$(basename "$snap" .snap)" + new="$snap.new" + echo "

    $name

    " + if [ -f "$new" ]; then + echo '

    Pending change: review before accepting

    ' + echo '
    ' + echo '
    accepted (current .snap)
    ' + emit_svg_box "$snap" + echo '
    ' + echo '
    new (.snap.new)
    ' + emit_svg_box "$new" + echo '
    ' + echo '
    ' + echo '
    text diff
    '
    +            diff -u "$snap" "$new" | sed 's/&/\&/g; s//\>/g' || true
    +            echo '
    ' + else + emit_svg_box "$snap" + fi + echo '
    ' + done + + # New tests whose snapshot has never been accepted yet. + for new in "$SNAP_DIR"/*.snap.new; do + base="${new%.new}" + [ -f "$base" ] && continue + name="$(basename "$new" .snap.new)" + echo "

    $name

    " + echo '

    new snapshot (no accepted version yet)

    ' + emit_svg_box "$new" + echo '
    ' + done +} > "$OUT" + +echo "Wrote $OUT" + +if [ "${1:-}" = "--open" ]; then + xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true +fi diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 6ea370a9a5..f58ddd9492 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1036,6 +1036,22 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) - }) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state!(state, { + let data = state.render_shape_svg(&id, scale)?; + + let len = data.len() as u32; + let mut buf = Vec::with_capacity(4 + data.len()); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(&data); + Ok(mem::write_bytes(buf)) + }) +} + /// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` /// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: /// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index e5c563d03e..95bb7b0df0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -13,6 +13,7 @@ mod shadows; pub mod shape_renderer; mod strokes; mod surfaces; +pub mod svg; pub mod text; pub mod text_editor; mod ui; diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs new file mode 100644 index 0000000000..5724b61677 --- /dev/null +++ b/render-wasm/src/render/svg/document.rs @@ -0,0 +1,238 @@ +use skia_safe::{self as skia, Paint}; + +use crate::shapes::{Shape, Type}; +use crate::state::ShapesPoolRef; + +use crate::render::vector::draw_shape_geometry; + +// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a +// `save_layer`, so composite effects rendered with `save_layer` (opacity, +// blend mode, …) vanish in SVG. +// +// Instead of one canvas, the SVG path composes the document itself: leaf +// content is drawn into short-lived `skia::svg::Canvas` fragments (real +// ``/``/… vector markup), and composite effects become native SVG +// `` wrappers (`opacity`, `mix-blend-mode`, `clip-path`). + +/// Accumulates the SVG document body while drawing. +pub(crate) struct SvgLayerCanvas { + pub(super) scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, + pub(super) out: String, + pub(super) defs: String, + pending: Option, + next_id: usize, + frag_no: usize, +} + +impl SvgLayerCanvas { + pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self { + Self { + scale, + page_rect, + tx, + ty, + out: String::new(), + defs: String::new(), + pending: None, + next_id: 0, + frag_no: 0, + } + } + + pub(super) fn unique(&mut self, prefix: &str) -> String { + let id = format!("{prefix}{}", self.next_id); + self.next_id += 1; + id + } + + /// Creates a fragment canvas configured with the page transform + /// (scale + translate to the export bounds). + pub(super) fn new_fragment(&self) -> skia::svg::Canvas { + let canvas = skia::svg::Canvas::new(self.page_rect, None); + { + let cv: &skia::Canvas = &canvas; + cv.scale((self.scale, self.scale)); + cv.translate((self.tx, self.ty)); + } + canvas + } + + /// Returns the current leaf-drawing canvas, creating a fragment if needed. + pub(crate) fn canvas(&mut self) -> &skia::Canvas { + if self.pending.is_none() { + self.pending = Some(self.new_fragment()); + } + self.pending.as_deref().unwrap() + } + + /// Finalizes the pending fragment and appends its markup to `out`. + pub(super) fn flush(&mut self) { + let Some(canvas) = self.pending.take() else { + return; + }; + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + if inner.trim().is_empty() { + return; + } + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + self.out.push_str(&remap_ids(inner, &prefix)); + } + + pub(super) fn open_group(&mut self, attrs: &str) { + self.flush(); + self.out.push_str("'); + } + + pub(super) fn close_group(&mut self) { + self.flush(); + self.out.push_str(""); + } + + /// Emits a `` from a shape's geometry (in device/page space). + /// + /// A mask can be a group too. Since a group has no geometry of its own, we + /// recurse into its descendants and accumulate their geometry. + pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) { + let canvas = self.new_fragment(); + { + let cv: &skia::Canvas = &canvas; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + draw_clip_geometry(cv, shape, tree, &paint); + } + self.finish_clip_path_fragment(id, canvas); + } + + /// Finalizes a fragment canvas as a `` def. + pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { + let data = canvas.end(); + let doc = String::from_utf8_lossy(data.as_bytes()); + let inner = extract_inner_svg(&doc); + let prefix = format!("f{}_", self.frag_no); + self.frag_no += 1; + let geometry = remap_ids(inner, &prefix); + self.defs.push_str(&format!( + "{geometry}" + )); + } +} + +/// Draws a clip geometry into `cv` (already set up with the page transform). +fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) { + if let Type::Group(_) = &shape.shape_type { + for child_id in shape.children_ids_iter_forward(true) { + if let Some(child) = tree.get(child_id) { + draw_clip_geometry(cv, child, tree, paint); + } + } + return; + } + + cv.save(); + cv.concat(&shape.centered_transform()); + draw_shape_geometry(cv, shape, paint); + cv.restore(); +} + +/// Builds the `` attribute string for a shape's composite effects (opacity, +/// blend mode). Returns `None` when the shape needs no wrapper. +/// +/// Layer blur / shadows are intentionally omitted here — they need native SVG +/// filter re-emission to survive `SkSVGDevice` and land in later PRs. +pub(super) fn effect_attrs(element: &Shape) -> Option { + let mut parts: Vec = Vec::new(); + + let opacity = element.opacity(); + if opacity < 1.0 { + parts.push(format!("opacity=\"{opacity}\"")); + } + + if let Some(css) = blend_css(element.blend_mode().0) { + parts.push(format!("style=\"mix-blend-mode:{css}\"")); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None` +/// for `SrcOver` (normal) and modes without a CSS equivalent. +fn blend_css(mode: skia::BlendMode) -> Option<&'static str> { + use skia::BlendMode::*; + Some(match mode { + Multiply => "multiply", + Screen => "screen", + Overlay => "overlay", + Darken => "darken", + Lighten => "lighten", + ColorDodge => "color-dodge", + ColorBurn => "color-burn", + HardLight => "hard-light", + SoftLight => "soft-light", + Difference => "difference", + Exclusion => "exclusion", + Hue => "hue", + Saturation => "saturation", + Color => "color", + Luminosity => "luminosity", + _ => return None, + }) +} + +/// Returns the inner body of a Skia SVG document (everything between the +/// opening `` tag and the closing ``). +fn extract_inner_svg(doc: &str) -> &str { + let start = doc + .find("').map(|e| s + e + 1)); + let end = doc.rfind(""); + match (start, end) { + (Some(s), Some(e)) if s <= e => &doc[s..e], + _ => "", + } +} + +/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…` +/// references) so ids stay unique once fragments are merged into one document. +fn remap_ids(body: &str, prefix: &str) -> String { + let needle = "id=\""; + let mut ids: Vec<&str> = Vec::new(); + let mut offset = 0; + while let Some(pos) = body[offset..].find(needle) { + let start = offset + pos + needle.len(); + let Some(end_rel) = body[start..].find('"') else { + break; + }; + let id = &body[start..start + end_rel]; + if !id.is_empty() { + ids.push(id); + } + offset = start + end_rel + 1; + } + + ids.sort_unstable(); + ids.dedup(); + // Longest-first so a shorter id can't collide inside a longer one. + ids.sort_by_key(|b| std::cmp::Reverse(b.len())); + + let mut out = body.to_string(); + for id in ids { + let new_id = format!("{prefix}{id}"); + out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\"")); + out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})")); + out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\"")); + } + out +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs new file mode 100644 index 0000000000..c4cda3cff7 --- /dev/null +++ b/render-wasm/src/render/svg/fixtures.rs @@ -0,0 +1,71 @@ +//! GPU-free scene builders and render helpers for SVG export tests. + +use skia_safe as skia; + +use crate::render::RenderResources; +use crate::shapes::{Fill, Frame, Group, Rect, SolidColor, Type}; +use crate::state::ShapesPool; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; + +use super::render_tree_to_svg; + +/// Deterministic UUID from a small integer, keeping snapshots stable. +pub(super) fn uid(n: u32) -> Uuid { + uuid_from_u32_quartet(0, 0, 0, n) +} + +/// Adds a solid-filled rectangle to the pool. +pub(super) fn add_solid_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); +} + +/// Adds a solid-filled frame (board) to the pool. +pub(super) fn add_frame( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + color: skia::Color, + clip: bool, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Frame(Frame::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + shape.set_clip(clip); +} + +/// Adds an empty (unmasked) group. +pub(super) fn add_group( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + children: &[Uuid], +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Group(Group { masked: false })); + shape.set_selrect(l, t, r, b); + for child in children { + shape.add_child(*child); + } +} + +pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export"); + String::from_utf8(bytes).expect("utf8 svg") +} diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs new file mode 100644 index 0000000000..f060a66ce6 --- /dev/null +++ b/render-wasm/src/render/svg/frames.rs @@ -0,0 +1,66 @@ +use crate::error::Result; +use crate::render::shape_renderer::ShapeRenderer; +use crate::render::vector::VectorRenderer; +use crate::shapes::{Shape, Stroke}; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use crate::render::RenderResources; + +pub(super) fn render_frame( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let matrix = element.centered_transform(); + + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + let clipped = element.clip_content; + if clipped { + let clip_id = builder.unique("clip"); + builder.push_clip_path(&clip_id, element, tree); + builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); + } + + // Frame background (frame space). + if !element.fills.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale); + renderer.draw_fills(element, &element.fills)?; + canvas.restore(); + } + + // Children (absolute coords). + let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale)?; + } + + // Strokes over children (frame space). + let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale); + renderer.draw_strokes(element, &visible_strokes)?; + canvas.restore(); + } + + if clipped { + builder.close_group(); + } + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs new file mode 100644 index 0000000000..5bca7d8876 --- /dev/null +++ b/render-wasm/src/render/svg/groups.rs @@ -0,0 +1,35 @@ +use crate::error::Result; +use crate::shapes::Shape; +use crate::state::ShapesPoolRef; + +use super::document::{effect_attrs, SvgLayerCanvas}; +use super::render_tree; +use crate::render::RenderResources; + +pub(super) fn render_group( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + // Masked groups are deferred: they need an alpha `` compositor that + // will land in a later PR. For now we still emit the full child list + // (including the mask shape as normal content) so basic group opacity + // keeps working. + + let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale)?; + } + + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs new file mode 100644 index 0000000000..57207fbe22 --- /dev/null +++ b/render-wasm/src/render/svg/mod.rs @@ -0,0 +1,167 @@ +use skia_safe::{self as skia}; + +use crate::error::Result; +use crate::shapes::Type; +use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; + +use super::vector::{render_leaf_content, VectorRenderer}; +use super::RenderResources; + +/// Renders a shape tree to an SVG document and returns the raw SVG bytes. +/// +/// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted +/// as real SVG markup via short-lived Skia SVG canvases, while composite +/// effects that `SkSVGDevice` would drop (`save_layer` opacity / blend) are +/// composed as native SVG `` wrappers. Frame `clip content` uses a native +/// ``. +/// +/// Special-case re-emission for shadows, layer blur, masks, and deferred +/// strokes is intentionally out of scope for this first cut. +pub fn render_to_svg( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + render_tree_to_svg(shared, id, tree, scale) +} + +/// Core SVG export, kept as a separate entry so headless native tests can call +/// it with a GPU-free [`RenderResources`]. +pub(crate) fn render_tree_to_svg( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result> { + let shape = tree + .get(id) + .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?; + // Use the extended rect so unclipped frames grow to include overflowing + // children (and leaf effects like shadows when those land). Clipped frames + // still resolve to roughly their selrect because `extrect` skips joining + // children when `clip_content` is on. + let bounds = shape.extrect(tree, scale); + + let page_w = bounds.width() * scale; + let page_h = bounds.height() * scale; + let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + + let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; + + let mut out = String::with_capacity(body.len() + defs.len() + 256); + out.push_str("\n"); + out.push_str(&format!( + "" + )); + + if !defs.is_empty() { + out.push_str(""); + out.push_str(&defs); + out.push_str(""); + } + + out.push_str(&body); + out.push_str(""); + + Ok(out.into_bytes()) +} + +mod document; +mod frames; +mod groups; + +use document::SvgLayerCanvas; +use frames::render_frame; +use groups::render_group; + +use crate::shapes::Shape; +use document::effect_attrs; + +/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. +fn render_body( + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, + page_rect: skia::Rect, + tx: f32, + ty: f32, +) -> Result<(String, String)> { + let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty); + render_tree(&mut builder, shared, id, tree, scale)?; + builder.flush(); + Ok((builder.defs, builder.out)) +} + +fn render_tree( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let Some(element) = tree.get(id) else { + return Ok(()); + }; + if element.hidden { + return Ok(()); + } + + match &element.shape_type { + Type::Group(_) => render_group(builder, shared, element, tree, scale), + Type::Frame(_) => render_frame(builder, shared, element, tree, scale), + Type::Rect(_) + | Type::Circle + | Type::Path(_) + | Type::Bool(_) + | Type::Text(_) + | Type::SVGRaw(_) => render_leaf(builder, shared, element, scale), + } +} + +fn render_leaf( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + scale: f32, +) -> Result<()> { + let effects = effect_attrs(element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + + { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale); + render_leaf_content(&mut renderer, element)?; + canvas.restore(); + } + + if effects.is_some() { + builder.close_group(); + } + Ok(()) +} + +// =========================================================================== +// Tests +// =========================================================================== +// +// Fast, headless native tests (`cargo test --bin render_wasm`) for the SVG +// exporter. They bypass the GPU/browser stack: shapes are built into a +// `ShapesPool` and rendered through [`render_tree_to_svg`] with a headless +// [`RenderResources`]. Output is checked with `insta` snapshots. +// +// To (re)generate snapshots after a deliberate change: +// cargo insta test --accept --bin render_wasm +#[cfg(test)] +mod fixtures; + +#[cfg(test)] +mod tests; diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..547b8a2479 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap new file mode 100644 index 0000000000..86953ae155 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap new file mode 100644 index 0000000000..2eebb4cfe1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: "render(&pool, id)" +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..3a52a3acdb --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 175 +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap new file mode 100644 index 0000000000..27f46df7e4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs new file mode 100644 index 0000000000..388ef2f09b --- /dev/null +++ b/render-wasm/src/render/svg/tests.rs @@ -0,0 +1,176 @@ +use super::fixtures::*; + +use crate::shapes::BlendMode; +use crate::state::ShapesPool; +use crate::uuid::Uuid; + +use skia_safe as skia; + +#[test] +fn exports_a_solid_rect() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + + insta::assert_snapshot!(render(&pool, id)); +} + +#[test] +fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(0, 128, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_opacity(0.5); + shape.set_blend_mode(BlendMode(skia::BlendMode::Multiply)); + } + + let svg = render(&pool, id); + assert!( + svg.contains("opacity=\"0.5\""), + "missing opacity wrapper: {svg}" + ); + assert!( + svg.contains("mix-blend-mode:multiply"), + "missing blend-mode wrapper: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_group_with_two_rects_and_group_opacity() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let a = uid(2); + let b = uid(3); + + add_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 100.0), + &[a, b], + ); + { + let group = pool.get_mut(&group_id).unwrap(); + group.set_opacity(0.7); + } + + add_solid_rect( + &mut pool, + a, + group_id, + (0.0, 0.0, 90.0, 100.0), + skia::Color::from_rgb(0, 0, 255), + ); + add_solid_rect( + &mut pool, + b, + group_id, + (110.0, 0.0, 200.0, 100.0), + skia::Color::from_rgb(0, 200, 0), + ); + + let svg = render(&pool, group_id); + assert!( + svg.contains("opacity=\"0.7\""), + "missing group opacity wrapper: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_a_clipped_frame_with_overflowing_child() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_child(child); + } + + // Child extends past the frame's right/bottom edge. + add_solid_rect( + &mut pool, + child, + frame_id, + (50.0, 50.0, 150.0, 150.0), + skia::Color::from_rgb(255, 0, 0), + ); + + let svg = render(&pool, frame_id); + assert!( + svg.contains("clip-path=\"url(#"), + "missing frame clip-path wrapper: {svg}" + ); + assert!(svg.contains("(renderer: &mut R, shape: &Shape) -> Result<()> { +pub(super) fn render_leaf_content( + renderer: &mut R, + shape: &Shape, +) -> Result<()> { match &shape.shape_type { Type::Text(_) => renderer.draw_text(shape)?, Type::SVGRaw(_) => renderer.draw_svg(shape)?, @@ -1212,7 +1215,7 @@ fn transformed_skia_path(shape: &Shape) -> Option { // --------------------------------------------------------------------------- /// Draws the shape's geometry (rect/rrect/oval/path) with the given paint. -fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { +pub(super) fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { if let Some(corners) = shape.shape_type.corners() { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 6a29bef62a..09220283cf 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -105,6 +105,10 @@ impl State { crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale) } + pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result> { + crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale) + } + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to /// `format` on a CPU raster surface, no GPU/WebGL. pub fn render_shape_raster( From 73d3d63616ff0e5a8d5bca60bae9e94ffb8a6df3 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 31 Aug 2026 16:31:24 +0200 Subject: [PATCH 237/298] :sparkles: Enable a way to provide custom opencode config on starting devenv --- .opencode/commands/implement-plan.md | 5 +- .opencode/commands/review.md | 25 -- .opencode/skills/code-review/SKILL.md | 24 +- .opencode/skills/planner/SKILL.md | 238 +++++++++++++----- .serena/memories/devenv/core.md | 3 +- docker/devenv/docker-compose.opencode.yml | 10 + .../developer/agentic-devenv.md | 5 + docs/technical-guide/developer/devenv.md | 21 ++ manage.sh | 61 ++++- 9 files changed, 276 insertions(+), 116 deletions(-) delete mode 100644 .opencode/commands/review.md create mode 100644 docker/devenv/docker-compose.opencode.yml diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index 20eedb3fe5..8ecd1bd537 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -3,11 +3,8 @@ description: Execute a ready plan end-to-end — create a GitHub issue, branch i agent: build --- -# Implement Plan - This command is run once a plan is ready (for example, from plan mode). Execute -the plan already prepared in the current session context — it does not take -extra arguments. Follow these steps in order. +the plan already prepared in the current session context. Follow these steps in order. ## 1. Create the issue diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md deleted file mode 100644 index 70bd89ac90..0000000000 --- a/.opencode/commands/review.md +++ /dev/null @@ -1,25 +0,0 @@ -Act as a senior software engineer and perform a thorough review. - -## Instructions - -1. **Determine what is being reviewed** from the provided context: - - **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill. - - **If it is code** (diff, PR, code change) → load the **`code-review`** skill. - -2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing. - -3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks. - -4. Follow the loaded skill's process and produce its output format. - -## Strong Rules - -1. Do not invent problems. Every finding must be real and actionable. -2. Do not modify any code and do not create a commit — this command only reviews. -3. Be specific and constructive. "This could be better" is not helpful — explain why and how. -4. Prioritize by impact. One structural issue outweighs ten nits. -5. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation. - -## Context - -$ARGUMENTS diff --git a/.opencode/skills/code-review/SKILL.md b/.opencode/skills/code-review/SKILL.md index 7f06d90b59..7fa581efa0 100644 --- a/.opencode/skills/code-review/SKILL.md +++ b/.opencode/skills/code-review/SKILL.md @@ -106,6 +106,8 @@ For detailed security guidance, see `security-and-hardening`. | **Low:** | Minor, optional | Author may ignore — formatting, style preferences | | **Suggestion:** | Worth considering | Not required, but improves the code | +**Unique finding IDs.** Assign every finding a stable identifier: `F1`, `F2`, `F3`, … numbered in order of severity (Critical first, then High, Medium, Low, Suggestion). Use the ID everywhere the finding is mentioned — in section headers, in the verdict, in follow-up discussion. Never renumber within a review. Example: `**F3 (High)** — `app/validate.cljs:42` — duplicate branch logic…`. + For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not. Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list. @@ -122,11 +124,11 @@ Briefly explain what the code does and give an overall assessment. ### Critical and High-Priority Issues -List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful. +List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. Each finding gets its unique ID (`F1`, `F2`, …). For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful. ### Other Findings -List medium- and low-priority issues, including maintainability and design concerns. +List medium- and low-priority issues, including maintainability and design concerns. Continue the ID sequence started above (`F3`, `F4`, …). ### Suggested Refactoring @@ -148,6 +150,8 @@ Choose one: - **Approve with minor changes** — Good to merge after addressing low/medium issues - **Request changes** — Critical or high issues must be resolved before merge +List the finding IDs the verdict depends on (e.g. "Request changes: F1, F4"). + ## Change Sizing Small, focused changes are easier to review, faster to merge, and safer to deploy. @@ -231,25 +235,13 @@ For supply-chain risk triage, follow the `security-and-hardening` skill. ## Verification -After review is complete: +Before emitting the verdict, verify the change as it stands. This is the reviewer's own due diligence — it covers the state of the code at review time, not the later resolution of findings (fixing findings is the author's job; confirming them is a new review): -- [ ] All Critical issues are resolved -- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification -- [ ] Tests pass +- [ ] Tests pass — run them yourself, don't trust the claim - [ ] Build succeeds - [ ] The verification story is documented (what changed, how it was verified) - [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite -## Multi-Model Review Pattern - -Use different models for different review perspectives: - -``` -Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call -``` - -Different models have different blind spots. - ## See Also - For detailed security review guidance, see `security-and-hardening` diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md index d1802652e2..3598a0dc16 100644 --- a/.opencode/skills/planner/SKILL.md +++ b/.opencode/skills/planner/SKILL.md @@ -1,13 +1,13 @@ --- name: planner -description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-.md. +description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md. --- # Planner Read-only senior software architect role for Penpot. Produces structured -implementation plans that engineers or other agents can execute. Never writes -or modifies code. +implementation plans with task breakdowns that engineers or other agents can +execute. Never writes or modifies code. ## When to Use @@ -18,24 +18,29 @@ or modifies code. - The user asks "how would I implement X?" or "what's involved in fixing Y?". - The user is about to start non-trivial work and wants a bite-sized task breakdown. +- A task feels too large or vague to start. +- Work needs to be parallelized across multiple agents or sessions. Do **not** use this skill to actually implement anything — it is read-only. +**When NOT to use:** Single-file changes with obvious scope, or when the spec +already contains well-defined tasks. + ## Role -You are a Senior Software Architect working on Penpot, an open-source design -tool. Your sole responsibility is planning and analysis — you do NOT write or -modify code. +You help users understand the Penpot codebase, design solutions, and produce +implementation plans that other agents or developers can execute. The plan +tells them what to build and how to verify it, task by task. -You help users understand the codebase, design solutions, and create detailed -implementation plans that other agents or developers can execute. Document -everything they need to know: which files to touch for each task, code patterns, -tests, and how to verify correctness. Apply DRY and KISS principles. +The implementer reads the project's agent docs (`AGENTS.md`, project memories +such as `mem:critical-info`, `mem:testing`, and each module's core memory) +before working. Reference those memories instead of re-explaining tooling, +conventions, or test design — explain in the plan only what they do not cover. Do **not** suggest commit messages or commit names anywhere in your plans or -responses — committing is the developer's responsibility. +responses — committing is the implementer's responsibility. -## Required Reading Before Planning +## CRITICAL: Required Reading Before Planning Before drafting any plan, work through the project's own guidance: @@ -50,6 +55,8 @@ Before drafting any plan, work through the project's own guidance: Skipping this step is the #1 cause of incorrect or incomplete plans. +--- + ## The Planning Process ### Phase 1: Architecture Analysis @@ -64,16 +71,42 @@ Skipping this step is the #1 cause of incorrect or incomplete plans. ### Phase 2: Task Breakdown -Implementation order follows the monorepo's dependency graph: -`frontend -> common`, `backend -> common`, `exporter -> common`, -`frontend -> render-wasm`. Build shared foundations first, then layer -consumers on top. +#### Identify the Dependency Graph + +Map what depends on what, following the monorepo's module dependency graph: + +``` +common (shared types, schemas — no deps) + │ + ├── backend (depends common) + │ ├── RPC handlers + │ └── persistence / migrations + │ + ├── frontend (depends common, render-wasm) + │ ├── UI components + │ └── state / API integration + │ + ├── exporter (depends common) + │ + └── render-wasm (consumed by frontend) +``` + +Implementation order follows the dependency graph bottom-up: build shared +foundations first, then layer consumers on top. #### Slice Vertically Instead of building all of common, then all of backend, then all of frontend — build one complete feature path at a time: +**Bad (horizontal slicing):** +``` +Task 1: Build all common types +Task 2: Build all backend handlers +Task 3: Build all frontend components +``` + +**Good (vertical slicing):** ``` Task 1: common data types + schema ← foundation Task 2: backend RPC handler + persistence @@ -89,39 +122,58 @@ Each task follows this structure: ```markdown ## Task [N]: [Short descriptive title] -**Description:** One paragraph explaining what this task accomplishes. +**Description:** One or two paragraphs explaining what this task accomplishes. +Should be clear and concise. + +**Rationale:** Why this task exists and why this approach over the obvious +alternatives — design decisions, trade-offs, constraints discovered during +analysis. One or two sentences; skip only if genuinely trivial. + +**Code sketch (optional):** Signature-, type-, or shape-level example when the +intended interface is non-obvious. Keep it short — a skeleton that fixes the +contract (function signature, model fields, error shape), never a full +implementation. Omit when the task is mechanical. **Acceptance criteria:** - [ ] [Specific, testable condition] - [ ] [Specific, testable condition] **Verification:** -- [ ] Tests pass (module-specific test command) -- [ ] Lint/formatter passes (module-specific check command) +- [ ] Relevant tests pass (module-specific test command). +- [ ] Lint/formatter passes (module-specific check command), if applicable. +- [ ] The core flow works end-to-end, if applicable. **Dependencies:** [Task numbers this depends on, or "None"] **Files likely touched:** - `path/to/file.clj` - `path/to/file_test.clj` + +**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files] ``` Replace "module-specific test command" with the actual commands for the module -(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend, -or the commands noted in the module's core memory). +(e.g. `clojure -M:dev:test` for backend/common, +`npx shadow-cljs compile test && npx karma start` for frontend, or the +commands noted in the module's core memory). + +When possible, design each task with TDD in mind: acceptance criteria double +as a test list, and the natural first step of the task is writing those tests +before the implementation. Some tasks resist this (config, migrations, pure +wiring) — for those, keep the usual verification steps. #### Estimate Scope -| Size | Files | Scope | -|------|-------|-------| -| **XS** | 1 | Single function, config change, or schema tweak | -| **S** | 1-2 | One handler or component method | -| **M** | 3-5 | One vertical feature slice | -| **L** | 5-8 | Multi-component feature | -| **XL** | 8+ | **Too large — break it down further** | +| Size | Files | Scope | Example | +|------|-------|-------|---------| +| **XS** | 1 | Single function, config change, or schema tweak | Add a validation rule | +| **S** | 1-2 | One handler or component method | Add a new RPC endpoint | +| **M** | 3-5 | One vertical feature slice | Bookmark CRUD with tests | +| **L** | 5-8 | Multi-component feature | Search with filtering and pagination | +| **XL** | 8+ | **Too large — break it down further** | — | -If a task is L or larger, break it into smaller tasks. Agents perform best on -S and M tasks. +If a task is XL, it should be broken into smaller tasks. Agents perform best +on S and M tasks. **When to break a task down further:** - It would take more than one focused session @@ -141,11 +193,11 @@ Arrange tasks so that: Add explicit checkpoints with the relevant module commands: ```markdown -## Checkpoint: After Tasks 1-3 -- [ ] All tests pass (module-specific command) -- [ ] Lint/format passes (module-specific command) -- [ ] Core flow works end-to-end -- [ ] Review with human before proceeding +### Checkpoint: After Tasks 1-3 +- [ ] Relevant tests pass (module-specific command). +- [ ] The relevant build or compilation passes, if applicable. +- [ ] The core flow works end-to-end. +- [ ] Review with human before proceeding. ``` ## Requirements @@ -159,7 +211,7 @@ Add explicit checkpoints with the relevant module commands: - Apply DRY and KISS principles to the proposed implementation. - Define a testing strategy aligned with each affected module's tooling. - Every task must have acceptance criteria and verification steps. -- Checkpoints must exist between major phases. +- Checkpoints must exist after every 2-3 tasks. ## Constraints @@ -168,7 +220,8 @@ Add explicit checkpoints with the relevant module commands: `.opencode/plans/`. - You do **not** run builds, tests, linters, or any commands that modify state. - You do **not** create git commits or interact with version control. -- You do **not** execute shell commands beyond read-only searches. +- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, + `find`, `cat`, `bat`). - Your output is a structured plan or analysis, ready for handoff to an engineer agent or developer. @@ -188,8 +241,9 @@ slug is lowercase, hyphen-separated, and a short summary of the task (e.g. `add-batch-get-profiles-for-file-comments`). Create the `.opencode/plans/` directory if it does not exist. -Always attempt the write. If the user explicitly provides a target file path, -use that path instead of the default. +IMPORTANT: The plan agent has write permission specifically for +`.opencode/plans/` — always attempt the write. If the user explicitly provides +a target file path, use that path instead of the default. ### Plan Document Template @@ -212,41 +266,75 @@ use that path instead of the default. security implications.] ## Approach -[Step-by-step implementation plan with file paths, function names, and code -shape where applicable. Group steps into atomic, ordered tasks.] +[A short strategy summary: 3-5 sentences describing the overall approach and +the shape of the dependency graph (what depends on what, what gets built +first). High-level only — the task-by-task detail lives in the Task List.] ## Task List -### Phase 1: Foundation -- [ ] Task 1: ... -- [ ] Task 2: ... +Each task uses the full task structure defined in +[Write Tasks](#write-tasks) — description, rationale, acceptance criteria, +verification, dependencies, files, estimated scope, and optional code sketch. +Never reduce a task to a one-line checkbox; the plan must be self-contained +and executable without other context. -### Checkpoint: Phase 1 -- [ ] Tests pass, lint/formatter clean (module-specific commands) +Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks +into phases, milestones, or sprints; ordering and dependencies are already +captured per task. Insert a checkpoint after every 2-3 tasks. -### Phase 2: Core Features -- [ ] Task 3: ... -- [ ] Task 4: ... +## Task 1: [Short descriptive title] -### Checkpoint: Phase 2 -- [ ] End-to-end flow works +**Description:** [What this task accomplishes.] -### Phase 3: Polish -- [ ] Task 5: ... -- [ ] Task 6: ... +**Rationale:** [Why this approach over the alternatives.] -### Checkpoint: Complete -- [ ] All acceptance criteria met -- [ ] Ready for review +**Acceptance criteria:** +- [ ] [Specific, testable condition] -## Testing Strategy -[How to verify: which test commands to run per module, what cases to cover, -manual verification steps, lint/format checks. Consult each module's core -memory for the exact commands.] +**Verification:** +- [ ] Relevant tests pass (module-specific command). + +**Dependencies:** None + +**Files likely touched:** +- `path/to/file` + +**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files] + +**Code sketch (optional):** [Short contract-level example, only if the shape +is non-obvious.] + +## Task 2: [Short descriptive title] + +[Same structure as Task 1.] + +## Task 3: [Short descriptive title] + +[Same structure as Task 1.] + +### Checkpoint: After Tasks 1-3 +- [ ] Relevant tests pass (module-specific command). +- [ ] The relevant build or compilation passes, if applicable. +- [ ] The core flow works end-to-end. +- [ ] Review with human before proceeding. + +## Task 4: [Short descriptive title] + +[Same structure as Task 1.] + +## Task 5: [Short descriptive title] + +[Same structure as Task 1.] + +## Verification & Testing +[How to verify each task and the whole plan: the project's real test, lint, +build, and run commands (extracted during Required Reading), coverage +expectations, and manual checks. Consult each module's core memory for the +exact commands.] ## Parallelization Opportunities - **Safe to parallelize:** Independent feature slices across separate - modules, tests for already-implemented features + modules, tests for already-implemented features, documentation - **Must be sequential:** Shared common schema changes, database migrations - **Needs coordination:** Features that share a contract (define the contract first, then parallelize) @@ -259,13 +347,31 @@ When the plan is purely analytical (e.g. a code review or feasibility study with no implementation), skip the **Approach** and **Task List** sections and lead with **Findings** instead, keeping the rest of the structure. +## Common Rationalizations + +| Rationalization | Reality | +|---|---| +| "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. | +| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. | +| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. | +| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. | + +## Red Flags + +- Delivering prose without a task breakdown +- Tasks that say "implement the feature" without acceptance criteria +- No verification steps in the plan +- All tasks are XL-sized +- No checkpoints between tasks +- Dependency order isn't considered + ## Verification Checklist -Before starting implementation, confirm: +Before delivering the plan, confirm: - [ ] Every task has acceptance criteria - [ ] Every task has a verification step - [ ] Task dependencies are identified and ordered correctly -- [ ] No task touches more than ~5 files -- [ ] Checkpoints exist between major phases -- [ ] The human has reviewed and approved the plan +- [ ] No task is XL or larger — break it down instead +- [ ] Checkpoints exist after every 2-3 tasks +- [ ] The plan is ready for human review diff --git a/.serena/memories/devenv/core.md b/.serena/memories/devenv/core.md index 0651f872fe..db46542c99 100644 --- a/.serena/memories/devenv/core.md +++ b/.serena/memories/devenv/core.md @@ -6,6 +6,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par - `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`. - `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer. +- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount. - All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands. ## Source-of-truth files @@ -65,7 +66,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi ## CLI surface -- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). +- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX] [--opencode-config-dir DIR]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). `--opencode-config-dir DIR` bind-mounts DIR at `~/.config/opencode` in-container via the optional overlay above; mount applies at container creation, so changing it requires stop + re-run. - `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra. - `run-devenv`: legacy alias, ws0 non-agentic attached. - `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing. diff --git a/docker/devenv/docker-compose.opencode.yml b/docker/devenv/docker-compose.opencode.yml new file mode 100644 index 0000000000..1af29e3749 --- /dev/null +++ b/docker/devenv/docker-compose.opencode.yml @@ -0,0 +1,10 @@ +# Optional compose overlay, included by manage.sh's instance-compose ONLY +# when PENPOT_OPENCODE_CONFIG_DIR is set (run-devenv --opencode-config-dir +# DIR). Bind-mounts a host directory over the container's opencode global +# config dir (~/.config/opencode) so personal agents/prompts/skills kept in +# a separate repo are available inside the devenv without committing them +# here. Without the flag this file is never referenced. +services: + main: + volumes: + - "${PENPOT_OPENCODE_CONFIG_DIR}:/home/penpot/.config/opencode:z" diff --git a/docs/technical-guide/developer/agentic-devenv.md b/docs/technical-guide/developer/agentic-devenv.md index 55b03c1492..932f555d6f 100644 --- a/docs/technical-guide/developer/agentic-devenv.md +++ b/docs/technical-guide/developer/agentic-devenv.md @@ -148,10 +148,15 @@ automatically, so regular users never run this. ```bash ./manage.sh run-devenv --agentic \ [--ws N] [--sync] [--serena-context CTX] \ + [--opencode-config-dir DIR] \ [--git-user-name NAME] [--git-user-email EMAIL] ``` Brings one agentic instance up. Errors out if the target is already running. +`--opencode-config-dir DIR` bind-mounts DIR over the container's +`~/.config/opencode` so personal agents/prompts/skills kept in a separate +repository are available to the coding agent; see the +[Dev environment guide](./devenv.md#personal-opencode-config-inside-the-container). `--ws N` (N ≥ 1) brings that workspace up independently — workspaces can be started and stopped in any order. Per-instance ports diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index 922ed24f1d..d6cc65e89a 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -139,6 +139,27 @@ until you set an identity. The values are applied every time `run-devenv` brings an instance up (idempotent), so re-running with different flags is the way to change the in-container identity. +### Personal opencode config inside the container + +`run-devenv --opencode-config-dir DIR` bind-mounts a host directory over the +container's `~/.config/opencode` (opencode's global config dir). This is how +you keep personal agents, prompts, and skills in a separate repository and +use them inside the devenv without committing them here or leaving untracked +files in the repo: + +```bash +./manage.sh run-devenv --agentic --opencode-config-dir ../penpot-opencode +``` + +The path must be an existing directory; `~` is expanded and the value is +resolved to an absolute path automatically. The mount is applied at container +creation, so changing it requires stopping and re-running `run-devenv` for +that instance, and it applies only to instances brought up with the flag — +other workspaces mount nothing. The directory is shared read-write with the +container (same UID mapping as the source tree). Opencode's own state +(sessions, `auth.json`) lives in `~/.local/share/opencode`, which stays in +the container's data volume regardless of this flag. + ### Shared state and workers All instances share one Penpot database and one MinIO bucket; users, teams, diff --git a/manage.sh b/manage.sh index be6cc078b1..efa4da7601 100755 --- a/manage.sh +++ b/manage.sh @@ -223,8 +223,10 @@ function ensure-devenv-network { # those stale values would leak into substitution. And because Docker Compose # gives shell-env precedence over --env-file, the re-injected per-instance # overrides cleanly override the defaults.env baseline. Re-injected: HOME/PATH -# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), and the -# instance-env-overrides block. +# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), the +# optional PENPOT_OPENCODE_CONFIG_DIR (set only by run-devenv's +# --opencode-config-dir within this process), and the instance-env-overrides +# block. function infra-compose { env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ docker compose -p penpotdev-infra \ @@ -245,14 +247,26 @@ function instance-compose { # Per-instance overrides apply to all workspaces uniformly. mapfile -t overrides < <(instance-env-overrides "$instance") + # Optional personal-opencode-config overlay: the extra -f and variable + # are only present when run-devenv --opencode-config-dir resolved a host + # directory into PENPOT_OPENCODE_CONFIG_DIR; when unset neither the file + # nor the variable is referenced, so default behaviour is unchanged. + local -a compose_files=(-f docker/devenv/docker-compose.main.yml) + local -a opencode_env=() + if [[ -n "${PENPOT_OPENCODE_CONFIG_DIR:-}" ]]; then + compose_files+=(-f docker/devenv/docker-compose.opencode.yml) + opencode_env=("PENPOT_OPENCODE_CONFIG_DIR=${PENPOT_OPENCODE_CONFIG_DIR}") + fi + env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \ CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \ PENPOT_SOURCE_PATH="$source_path" \ DEVENV_TAG="$DEVENV_TAG" \ + "${opencode_env[@]}" \ "${overrides[@]}" \ docker compose -p "penpotdev-${instance}" \ --env-file "$DEVENV_DEFAULTS_FILE" \ - -f docker/devenv/docker-compose.main.yml \ + "${compose_files[@]}" \ "$@" } @@ -658,6 +672,26 @@ function parse-ws-integer { echo "ws$raw" } +# Strict parser for --opencode-config-dir. Resolves the value to an absolute +# host directory (docker compose resolves relative bind-mount sources against +# the compose file's directory, not $PWD, so relative values would be +# misinterpreted) and verifies it exists. Echoes the absolute path; anything +# else fails fast. +function parse-opencode-config-dir { + local raw="$1" + if [[ -z "$raw" ]]; then + echo "Invalid --opencode-config-dir: value is empty." >&2 + return 1 + fi + raw="${raw/#\~/$HOME}" + local abs + if ! abs=$(realpath -e "$raw" 2>/dev/null) || [[ ! -d "$abs" ]]; then + echo "Invalid --opencode-config-dir: '$raw' is not an existing directory." >&2 + return 1 + fi + echo "$abs" +} + # Bring a single instance up: compose up + detached tmux start. When agentic # is true (the default) the tmux session gets MCP + Serena enabled; when false @@ -764,6 +798,7 @@ function run-devenv { local serena_context="desktop-app" local git_user_name="" local git_user_email="" + local opencode_config_dir="" local -a extra_env_args=() while [[ $# -gt 0 ]]; do @@ -778,6 +813,8 @@ function run-devenv { do_attach=true; shift;; --serena-context) serena_context="$2"; shift 2;; + --opencode-config-dir) + opencode_config_dir="$(parse-opencode-config-dir "$2")" || return 1; shift 2;; --git-user-name) git_user_name="$2"; shift 2;; --git-user-email) @@ -787,13 +824,16 @@ function run-devenv { -e*) extra_env_args+=(-e "${1#-e}"); shift;; -h|--help) - echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" + echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--opencode-config-dir DIR] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]" echo " Bring a single workspace up." echo " --ws N target workspace (default: 0)." echo " --sync re-seed the wsN clone from the live repo (forbidden on ws0)." echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." echo " --serena-context CTX context passed to Serena (default: desktop-app)." + echo " --opencode-config-dir DIR bind-mount DIR at ~/.config/opencode inside the" + echo " container (personal agents/prompts/skills kept in a" + echo " separate repo). Applied at container creation." echo " --git-user-name NAME git author name inside the container (default: host git config)." echo " --git-user-email EMAIL git author email inside the container." echo " -e KEY=VAL forward env var to docker exec on attach." @@ -863,6 +903,14 @@ function run-devenv { write-instance-mcp-configs "$target" fi + # Scope the personal opencode config to this process only: instance-compose + # includes the overlay compose file and the variable just when it is set, + # so instances brought up without the flag mount nothing. + if [[ -n "$opencode_config_dir" ]]; then + echo "[$target] mounting personal opencode config: $opencode_config_dir -> ~/.config/opencode" + export PENPOT_OPENCODE_CONFIG_DIR="$opencode_config_dir" + fi + echo "Starting $target..." start-instance "$target" "$serena_context" "$git_user_name" "$git_user_email" "$agentic" print-instance-info "$target" @@ -1338,6 +1386,11 @@ function usage { echo " --attach attach to the tmux session after startup." echo " --agentic enable MCP + Serena (AI-agent mode)." echo " --serena-context CTX passed to Serena (default: desktop-app)." + echo " --opencode-config-dir DIR" + echo " bind-mount DIR over the container's" + echo " ~/.config/opencode (personal opencode" + echo " agents/prompts/skills kept outside this" + echo " repo; applied at container creation)." echo " -e KEY=VAL forwarded to 'docker exec' on attach." echo " --git-user-name NAME / --git-user-email EMAIL" echo " identity wired into the container's git config" From 5b3a1d93603cb14ce0f584cb462050895a6c5c3a Mon Sep 17 00:00:00 2001 From: Pablo Alba <pablo.alba@kaleidos.net> Date: Mon, 31 Aug 2026 16:47:17 +0200 Subject: [PATCH 238/298] :sparkles: Add check version menu entry (#11411) * :sparkles: Add check version menu entry * :sparkles: MR fixes * :sparkles: MR changes 2 --- common/src/app/common/version.cljc | 22 ++ .../app/main/ui/dashboard/check_updates.cljs | 307 ++++++++++++++++++ .../app/main/ui/dashboard/check_updates.scss | 175 ++++++++++ .../src/app/main/ui/dashboard/sidebar.cljs | 43 ++- .../src/app/main/ui/dashboard/sidebar.scss | 20 ++ frontend/test/frontend_tests/runner.cljs | 2 + .../frontend_tests/ui/check_updates_test.cljs | 70 ++++ frontend/translations/en.po | 45 +++ frontend/translations/es.po | 45 +++ 9 files changed, 725 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/main/ui/dashboard/check_updates.cljs create mode 100644 frontend/src/app/main/ui/dashboard/check_updates.scss create mode 100644 frontend/test/frontend_tests/ui/check_updates_test.cljs diff --git a/common/src/app/common/version.cljc b/common/src/app/common/version.cljc index b5ef8f2c0e..1b6a646f10 100644 --- a/common/src/app/common/version.cljc +++ b/common/src/app/common/version.cljc @@ -7,6 +7,7 @@ (ns app.common.version "A version parsing helper." (:require + [app.common.data :as d] [cuerdas.core :as str])) (def version-re #"^(([A-Za-z]+)\-?)?((\d+)\.(\d+)\.(\d+))(\-?((RC|DEV)(\d+)?))?(\-?(\d+))?(\-?g(\w+))?$") @@ -49,3 +50,24 @@ :else nil)) +(defn- version-components + [version] + (let [{:keys [major minor patch]} (or (parse version) {})] + [(d/parse-integer major 0) + (d/parse-integer minor 0) + (d/parse-integer patch 0)])) + +(defn compare-versions + "Compare two X.Y.Z base versions. Returns negative if a < b, zero if + equal, positive if a > b." + [version-a version-b] + (let [[major-a minor-a patch-a] (version-components version-a) + [major-b minor-b patch-b] (version-components version-b)] + (or (when (not= major-a major-b) (- major-a major-b)) + (when (not= minor-a minor-b) (- minor-a minor-b)) + (- patch-a patch-b)))) + +(defn newer? + [version-a version-b] + (pos? (compare-versions version-a version-b))) + diff --git a/frontend/src/app/main/ui/dashboard/check_updates.cljs b/frontend/src/app/main/ui/dashboard/check_updates.cljs new file mode 100644 index 0000000000..407b6b434d --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/check_updates.cljs @@ -0,0 +1,307 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.dashboard.check-updates + (:require-macros [app.main.style :as stl]) + (:require + [app.common.version :as v] + [app.config :as cf] + [app.main.data.event :as ev] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.dom :as dom] + [app.util.http :as http] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [clojure.string :as cstr] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private telemetry-origin + "check-updates-modal") + +(def ^:private highlights-md-url + "https://raw.githubusercontent.com/penpot/penpot/refs/heads/staging/HIGHLIGHTS.md") + +(def ^:private changelog-url + "https://github.com/penpot/penpot/blob/staging/CHANGES.md") + +(def ^:private release-notes-url + "https://penpot.app/release-notes") + +(def ^:private version-heading-re + #"(?m)^##\s+(\d+\.\d+\.\d+)(.*)$") + +(def ^:private bullet-re + #"^- (.+)$") + +(defn- unreleased-suffix? + [suffix] + (str/includes? (str/lower (or suffix "")) "unreleased")) + +(defn- parse-section-items + [section] + (->> (cstr/split-lines section) + (keep (fn [line] + (when-let [[_ item] (re-matches bullet-re (str/trim line))] + item))) + vec)) + +(defn parse-highlights + "Parse HIGHLIGHTS.md into released version sections with bullet items. + Skips Unreleased headings. Preserves file order (newest first)." + [markdown] + (if-not (string? markdown) + [] + (->> (str/split markdown #"(?m)(?=^##\s+\d+\.\d+\.\d+)") + (keep (fn [part] + (when-let [[_ version suffix] (re-find version-heading-re part)] + (when-not (unreleased-suffix? suffix) + {:version version + :items (parse-section-items part)})))) + vec))) + +(defn parse-latest-released-version + "Return the first non-unreleased `## X.Y.Z` heading from a highlights body." + [markdown] + (some-> (parse-highlights markdown) first :version)) + +(defn highlights-until-installed + "Keep released sections newer than the installed version (major, minor, + patch). Stops at the installed version or any older section." + [highlights installed] + (into [] + (take-while #(v/newer? (:version %) installed)) + highlights)) + +(defn- show-available-dialog + [{:keys [installed latest highlights]}] + (st/emit! (modal/show {:type :check-updates-available + :installed installed + :latest latest + :highlights highlights}))) + +(defn- show-uptodate-dialog + [version] + (st/emit! (modal/show {:type :check-updates-uptodate + :version version}))) + +(defn- show-unable-dialog + [] + (st/emit! (modal/show {:type :check-updates-unable}))) + +(defn- handle-highlights + [installed body] + (let [sections (parse-highlights body) + latest (some-> sections first :version)] + (cond + (nil? latest) + (show-unable-dialog) + + (not (v/newer? latest installed)) + (show-uptodate-dialog installed) + + :else + (show-available-dialog + {:installed installed + :latest latest + :highlights (highlights-until-installed sections installed)})))) + +(defn check-for-updates! + ([current-version] + (check-for-updates! current-version nil)) + ([current-version {:keys [on-start on-finish]}] + (when on-start (on-start)) + (->> (http/send! {:method :get + :mode :cors + :omit-default-headers true + :uri highlights-md-url + :response-type :text}) + (rx/subs! + (fn [response] + (when on-finish (on-finish)) + (if (http/success? response) + (handle-highlights current-version (:body response)) + (show-unable-dialog))) + (fn [_cause] + (when on-finish (on-finish)) + (show-unable-dialog)))))) + +(mf/defc check-updates-unable-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-unable} + [_] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide))) + + on-try-again + (mf/use-fn + (fn [] + (st/emit! (modal/hide)) + (check-for-updates! (:base cf/version))))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/msg-neutral + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.unable-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.unable-message")] + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.unable-hint")]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "primary" + :on-click on-try-again} + (tr "dashboard.check-updates.try-again")]]]])) + +(mf/defc check-updates-uptodate-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-uptodate} + [{:keys [version]}] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/tick + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.uptodate-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.uptodate-message") + " " + [:span {:class (stl/css :version)} version]]] + + [:div {:class (stl/css :modal-footer)} + [:> button* {:variant "secondary" + :on-click on-close} + (tr "labels.close")]]]])) + +(mf/defc check-updates-available-modal* + {::mf/register modal/components + ::mf/register-as :check-updates-available} + [{:keys [installed latest highlights]}] + (let [on-close + (mf/use-fn #(st/emit! (modal/hide))) + + on-changelog + (mf/use-fn + (mf/deps installed) + (fn [] + (st/emit! (ev/event {::ev/name "explore-changelog-click" + ::ev/origin telemetry-origin + :version installed})) + (dom/open-new-window changelog-url))) + + on-release-notes + (mf/use-fn + (mf/deps installed) + (fn [] + (st/emit! (ev/event {::ev/name "explore-product-updates-click" + ::ev/origin telemetry-origin + :version installed})) + (dom/open-new-window release-notes-url)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container :modal-container-available)} + [:div {:class (stl/css :modal-header)} + [:div {:class (stl/css :modal-title-row)} + [:> icon* {:icon-id i/info + :class (stl/css :modal-title-icon) + :aria-hidden true}] + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :modal-title)} + (tr "dashboard.check-updates.available-title")]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/close + :class (stl/css :modal-close-btn)}]] + + [:div {:class (stl/css :version-bar)} + [:span {:class (stl/css :version-bar-side)} + (tr "dashboard.check-updates.installed-version") + " " + [:span {:class (stl/css :version)} installed]] + [:> icon* {:icon-id i/arrow-right + :class (stl/css :version-bar-arrow) + :size "s" + :aria-hidden true}] + [:span {:class (stl/css :version-bar-side)} + (tr "dashboard.check-updates.latest-version") + " " + [:span {:class (stl/css :version :version-accent)} latest]]] + + [:div {:class (stl/css :modal-content)} + [:> text* {:as "p" + :typography t/body-large + :class (stl/css :modal-msg)} + (tr "dashboard.check-updates.available-message")] + + [:> text* {:as "h3" + :typography t/headline-small + :class (stl/css :highlights-title)} + (tr "dashboard.check-updates.highlights-title")] + + [:div {:class (stl/css :highlights-scroll)} + (for [section highlights] + (let [version (:version section) + items (:items section)] + [:div {:key version + :class (stl/css :highlights-section)} + [:div {:class (stl/css :highlights-version)} version] + [:ul {:class (stl/css :highlights-list)} + (for [item items] + [:li {:key item + :class (stl/css :highlights-item)} + item])]]))]] + + [:div {:class (stl/css :modal-footer :modal-footer-available)} + [:> button* {:variant "secondary" + :on-click on-changelog} + (tr "dashboard.check-updates.view-changelog")] + [:> button* {:variant "primary" + :on-click on-release-notes} + (tr "dashboard.check-updates.view-release-notes")]]]])) diff --git a/frontend/src/app/main/ui/dashboard/check_updates.scss b/frontend/src/app/main/ui/dashboard/check_updates.scss new file mode 100644 index 0000000000..26f5855a09 --- /dev/null +++ b/frontend/src/app/main/ui/dashboard/check_updates.scss @@ -0,0 +1,175 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as *; + +.modal-overlay { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset: 0; + z-index: var(--z-index-set); + background-color: var(--color-overlay-default); +} + +.modal-container { + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-xxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-inline-size: $sz-364; + max-inline-size: $sz-512; +} + +.modal-container-available { + max-inline-size: $sz-480; + max-block-size: min(90vh, $sz-712); +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-m); + padding-inline-end: var(--sp-xl); +} + +.modal-title-row { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.modal-title-icon { + flex-shrink: 0; + color: var(--color-foreground-primary); +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-m); + inset-inline-end: var(--sp-m); +} + +.version-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-m); + padding: var(--sp-m) var(--sp-l); + border-radius: $br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-secondary); +} + +.version-bar-side { + @include use-typography("body-large"); +} + +.version-bar-arrow { + flex-shrink: 0; + color: var(--color-foreground-secondary); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-s); + min-block-size: 0; +} + +.modal-msg { + margin: 0; + color: var(--color-foreground-secondary); +} + +.version { + color: var(--color-foreground-primary); + font-weight: 700; +} + +.version-accent { + color: var(--color-accent-primary); +} + +.highlights-title { + margin: var(--sp-m) 0 0; + color: var(--color-foreground-secondary); + text-transform: uppercase; +} + +.highlights-scroll { + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin-block-start: var(--sp-s); + padding-inline-end: var(--sp-s); + max-block-size: $sz-284; + overflow-y: auto; +} + +.highlights-section { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.highlights-version { + @include use-typography("headline-small"); + + color: var(--color-foreground-primary); + font-weight: 700; +} + +.highlights-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.highlights-item { + @include use-typography("body-large"); + + position: relative; + padding-inline-start: var(--sp-l); + color: var(--color-foreground-secondary); + + &::before { + content: ""; + position: absolute; + inset-block-start: 0.55em; + inset-inline-start: 0; + inline-size: $sz-6; + block-size: $sz-6; + border-radius: $br-circle; + background-color: var(--color-accent-primary); + } +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--sp-s); +} + +.modal-footer-available { + flex-shrink: 0; +} diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index a2d8e0d27f..f3e0673ceb 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -26,6 +26,7 @@ dropdown-menu-item*]] [app.main.ui.components.link :refer [link*]] [app.main.ui.components.organization-avatar :refer [organization-avatar*]] + [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.project-menu :refer [project-menu*]] @@ -1256,8 +1257,11 @@ (mf/defc about-penpot-menu* {::mf/private true} - [{:keys [on-close on-pointer-enter on-pointer-leave]}] - (let [version cf/version + [{:keys [on-close on-close-profile on-pointer-enter on-pointer-leave]}] + (let [version cf/version + checking* (mf/use-state false) + checking? (deref checking*) + show-release-notes (mf/use-fn (fn [event] @@ -1275,7 +1279,22 @@ (dom/get-data "eventname"))] (st/emit! (ev/event {::ev/name eventname ::ev/origin "menu:in-app"})) - (dom/open-new-window url))))] + (dom/open-new-window url)))) + + check-for-updates + (mf/use-fn + (mf/deps on-close-profile version) + (fn [event] + (dom/stop-propagation event) + (when-not @checking* + (st/emit! (ev/event {::ev/name "check-for-updates" + ::ev/origin "menu:in-app" + :version (:base version)})) + (dcu/check-for-updates! + (:base version) + {:on-start #(reset! checking* true) + :on-finish #(do (reset! checking* false) + (on-close-profile))}))))] [:> dropdown-menu* {:show true :class (stl/css :sub-menu :about) @@ -1297,7 +1316,22 @@ :data-url "https://penpot.app/terms" :on-click handle-click-url :data-eventname "explore-terms-service-click"} - (tr "auth.terms-of-service")]])) + (tr "auth.terms-of-service")] + (when-not (contains? cf/flags :air-gapped-conf) + [:* + [:hr {:role "separator" :class (stl/css :submenu-separator)}] + [:> dropdown-menu-item* {:class (stl/css-case :submenu-item true + :checking checking?) + :aria-disabled checking? + :can-focus (not checking?) + :on-click check-for-updates} + (if checking? + (tr "labels.checking-for-updates") + (tr "labels.check-for-updates")) + (when checking? + [:> icon* {:icon-id i/reload + :class (stl/css :checking-icon) + :size "s"}])]])])) (mf/defc profile-section* [{:keys [profile team]}] @@ -1512,6 +1546,7 @@ :about-penpot [:> about-penpot-menu* {:on-close close-sub-menu + :on-close-profile on-close :on-pointer-enter on-sub-menu-pointer-enter :on-pointer-leave on-menu-pointer-leave}] nil))])) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 0bf4b474e3..826d7db7c4 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -525,6 +525,26 @@ &:hover { color: var(--menu-foreground-color-hover); } + + &.checking { + color: var(--color-foreground-secondary); + pointer-events: none; + + &:hover { + color: var(--color-foreground-secondary); + background-color: transparent; + } + } +} + +.checking-icon { + flex-shrink: 0; + animation: spin-animation 1s infinite linear; +} + +.submenu-separator { + border-top: $b-1 solid var(--color-background-quaternary); + margin: 0; } .about-penpot { diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 629e6c9477..033d18b354 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -75,6 +75,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.workspace-tokens-remap-test] + [frontend-tests.ui.check-updates-test] [frontend-tests.ui.colorpicker-token-set-order-test] [frontend-tests.ui.comments-clustering-test] [frontend-tests.ui.comments-position-modifier-test] @@ -177,6 +178,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.workspace-tokens-remap-test + 'frontend-tests.ui.check-updates-test 'frontend-tests.ui.colorpicker-token-set-order-test 'frontend-tests.ui.comments-clustering-test 'frontend-tests.ui.comments-position-modifier-test diff --git a/frontend/test/frontend_tests/ui/check_updates_test.cljs b/frontend/test/frontend_tests/ui/check_updates_test.cljs new file mode 100644 index 0000000000..7e51105cae --- /dev/null +++ b/frontend/test/frontend_tests/ui/check_updates_test.cljs @@ -0,0 +1,70 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.ui.check-updates-test + (:require + [app.common.version :as v] + [app.main.ui.dashboard.check-updates :as dcu] + [cljs.test :as t :include-macros true])) + +(def ^:private sample-highlights + (str "# HIGHLIGHTS\n" + "\n" + "## 2.18.0 (Unreleased)\n" + "\n" + "- To do\n" + "\n" + "## 2.17.2\n" + "\n" + "- Background blur is here\n" + "- WebGL rendering gets stronger\n" + "\n" + "## 2.17.1\n" + "\n" + "- MCP connection status and more\n" + "- Design tokens: more visible, more user-friendly\n")) + +(t/deftest parse-latest-released-version-skips-unreleased + (t/is (= "2.17.2" (dcu/parse-latest-released-version sample-highlights)))) + +(t/deftest parse-latest-released-version-first-released + (t/is (= "2.17.2" + (dcu/parse-latest-released-version + "## 2.17.2\n\n- Fix\n\n## 2.17.1\n\n- Fix\n")))) + +(t/deftest parse-latest-released-version-only-unreleased + (t/is (nil? (dcu/parse-latest-released-version + "## 2.18.0 (Unreleased)\n\n- WIP\n")))) + +(t/deftest parse-latest-released-version-empty + (t/is (nil? (dcu/parse-latest-released-version ""))) + (t/is (nil? (dcu/parse-latest-released-version "# HIGHLIGHTS\n")))) + +(t/deftest parse-highlights-skips-unreleased-and-collects-bullets + (t/is (= [{:version "2.17.2" + :items ["Background blur is here" + "WebGL rendering gets stronger"]} + {:version "2.17.1" + :items ["MCP connection status and more" + "Design tokens: more visible, more user-friendly"]}] + (dcu/parse-highlights sample-highlights)))) + +(t/deftest version-compare + (t/is (zero? (v/compare-versions "2.17.1" "2.17.1"))) + (t/is (pos? (v/compare-versions "2.17.2" "2.17.1"))) + (t/is (neg? (v/compare-versions "2.17.1" "2.17.2"))) + (t/is (pos? (v/compare-versions "3.0.0" "2.99.99"))) + (t/is (neg? (v/compare-versions "2.17.2" "2.17.10")))) + +(t/deftest highlights-until-installed + (let [sections (dcu/parse-highlights sample-highlights)] + (t/is (= [{:version "2.17.2" + :items ["Background blur is here" + "WebGL rendering gets stronger"]}] + (dcu/highlights-until-installed sections "2.17.1"))) + (t/is (= [] (dcu/highlights-until-installed sections "2.17.2"))) + (t/is (= sections (dcu/highlights-until-installed sections "2.16.0"))) + (t/is (= [] (dcu/highlights-until-installed sections "2.17.10"))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index f3e5468b10..128f102aa1 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -3680,6 +3680,51 @@ msgstr "Variant" msgid "labels.version-notes" msgstr "Version %s notes" +msgid "labels.check-for-updates" +msgstr "Check for updates" + +msgid "labels.checking-for-updates" +msgstr "Checking for updates..." + +msgid "dashboard.check-updates.available-title" +msgstr "A NEW PENPOT VERSION IS AVAILABLE" + +msgid "dashboard.check-updates.available-message" +msgstr "Newer releases include performance improvements, new capabilities, and fixes that keep your installation current." + +msgid "dashboard.check-updates.installed-version" +msgstr "Installed version" + +msgid "dashboard.check-updates.latest-version" +msgstr "Latest version" + +msgid "dashboard.check-updates.highlights-title" +msgstr "What's new - Highlights" + +msgid "dashboard.check-updates.view-changelog" +msgstr "View full changelog" + +msgid "dashboard.check-updates.view-release-notes" +msgstr "View release notes" + +msgid "dashboard.check-updates.unable-title" +msgstr "UNABLE TO CHECK FOR UPDATES" + +msgid "dashboard.check-updates.unable-message" +msgstr "This installation could not reach the Penpot releases service." + +msgid "dashboard.check-updates.unable-hint" +msgstr "Check your network or instance configuration and try again." + +msgid "dashboard.check-updates.try-again" +msgstr "Try again" + +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT IS UP TO DATE" + +msgid "dashboard.check-updates.uptodate-message" +msgstr "You're running the latest stable version:" + #: src/app/main/ui/workspace/sidebar/sitemap.cljs:298 msgid "labels.view-only" msgstr "View only" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index fe2f03aa3e..6d586c8bea 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -3570,6 +3570,51 @@ msgstr "Variante" msgid "labels.version-notes" msgstr "Notas versión %s" +msgid "labels.check-for-updates" +msgstr "Comprobar actualizaciones" + +msgid "labels.checking-for-updates" +msgstr "Comprobando actualizaciones..." + +msgid "dashboard.check-updates.available-title" +msgstr "HAY UNA NUEVA VERSIÓN DE PENPOT DISPONIBLE" + +msgid "dashboard.check-updates.available-message" +msgstr "Las versiones más recientes incluyen mejoras de rendimiento, nuevas capacidades y correcciones que mantienen tu instalación al día." + +msgid "dashboard.check-updates.installed-version" +msgstr "Versión instalada" + +msgid "dashboard.check-updates.latest-version" +msgstr "Última versión" + +msgid "dashboard.check-updates.highlights-title" +msgstr "Novedades - Destacados" + +msgid "dashboard.check-updates.view-changelog" +msgstr "Ver changelog completo" + +msgid "dashboard.check-updates.view-release-notes" +msgstr "Ver notas de la versión" + +msgid "dashboard.check-updates.unable-title" +msgstr "NO HA SIDO POSIBLE COMPROBAR LAS ACTUALIZACIONES" + +msgid "dashboard.check-updates.unable-message" +msgstr "Esta instalación no pudo contactar con el servicio de versiones de Penpot." + +msgid "dashboard.check-updates.unable-hint" +msgstr "Comprueba tu red o la configuración de la instancia e inténtalo de nuevo." + +msgid "dashboard.check-updates.try-again" +msgstr "Intentar de nuevo" + +msgid "dashboard.check-updates.uptodate-title" +msgstr "PENPOT ESTÁ ACTUALIZADO" + +msgid "dashboard.check-updates.uptodate-message" +msgstr "Estás usando la última versión estable:" + #: src/app/main/ui/workspace/sidebar/sitemap.cljs:298 msgid "labels.view-only" msgstr "Solo lectura" From 92c2079ae2822d4199b8260c659aab77e0814c02 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:44:06 +0200 Subject: [PATCH 239/298] :bug: Add configurable limits for ZIP entry count and object size in v3 import (#11022) * :bug: Add configurable limits for ZIP entry count and object size in v3 import Add binfile-import-max-zip-entries (default 500,000) and binfile-import-max-object-size (default 100 MiB) config entries. Both are configurable via PENPOT_BINFILE_IMPORT_MAX_ZIP_ENTRIES and PENPOT_BINFILE_IMPORT_MAX_OBJECT_SIZE env vars. Entry count is checked before processing begins. Per-object size is checked after each storage object content is resolved. AI-assisted-by: mimo-v2.5-pro * :bug: Enforce actual decompressed byte limits on v3 import The previous object-size check trusted the ZIP entry header's declared size (ZipEntry.getSize()), which a malicious zip-bomb can forge. The check would pass, then the full decompressed payload would be read anyway during hashing and storage persistence. Add size-limiting-stream, a FilterInputStream wrapper that counts actual bytes read and raises :validation :max-file-size-reached when the configured limit is exceeded. Wire it into zip-entry-storage-content so both the hash calculation and storage write paths are bounded by real decompressed bytes, not declared header size. Also wire import limits into management.clj (clone-template) and debug.clj (import-handler + clone path) for defense-in-depth, and add a test that exercises the object-size limit with a real storage object in the exported ZIP. AI-assisted-by: mimo-v2.5-pro --- backend/src/app/binfile/v3.clj | 62 +++++++++++++-- backend/src/app/config.clj | 10 ++- backend/src/app/http/debug.clj | 8 +- backend/src/app/rpc/commands/binfile.clj | 4 +- backend/src/app/rpc/commands/management.clj | 4 +- backend/test/backend_tests/binfile_test.clj | 86 +++++++++++++++++++++ 6 files changed, 161 insertions(+), 13 deletions(-) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index eab49e1eb3..436be3b943 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -42,6 +42,7 @@ [datoteka.io :as io]) (:import java.io.File + java.io.FilterInputStream java.io.InputStream java.io.OutputStreamWriter java.lang.AutoCloseable @@ -430,6 +431,31 @@ [^ZipFile input ^ZipEntry entry] (.getInputStream input entry)) +(defn- size-limiting-stream + "Wraps an InputStream to enforce a maximum number of decompressed bytes. + Raises :validation :max-file-size-reached when the limit is exceeded." + ^InputStream + [^InputStream input ^long max-size] + (let [counter (atom 0)] + (proxy [FilterInputStream] [input] + (read + ([] + (let [b (.read input)] + (when (pos? b) + (when (> (swap! counter inc) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + b)) + ([buf off len] + (let [n (.read input buf off len)] + (when (pos? n) + (when (> (swap! counter + (long n)) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + n)))))) + (defn- zip-entry-reader [^ZipFile input ^ZipEntry entry] (-> (zip-entry-stream input entry) @@ -438,10 +464,12 @@ (defn- zip-entry-storage-content "Wraps a ZipFile and ZipEntry into a penpot storage compatible object and avoid creating temporal objects" - [input entry] - (let [hash (delay (->> entry - (zip-entry-stream input) - (sto.impl/calculate-hash)))] + [input entry & {:keys [max-size]}] + (let [stream-fn (fn [] + (cond-> (zip-entry-stream input entry) + max-size (size-limiting-stream max-size))) + hash (delay (->> (stream-fn) + (sto.impl/calculate-hash)))] (reify sto.impl/IContentObject (get-size [_] @@ -458,7 +486,7 @@ (throw (UnsupportedOperationException. "not implemented"))) (make-input-stream [_ _] - (zip-entry-stream input entry)) + (stream-fn)) (make-output-stream [_ _] (throw (UnsupportedOperationException. "not implemented")))))) @@ -846,9 +874,9 @@ ext (cmedia/mtype->extension (:content-type object)) path (str "objects/" id ext) - content (->> path - (get-zip-entry input) - (zip-entry-storage-content input))] + content (zip-entry-storage-content input + (get-zip-entry input path) + :max-size (::bfc/import-max-object-size cfg))] (when (not= (:size object) (sto/get-size content)) (ex/raise :type :validation @@ -858,6 +886,15 @@ :expected-size (:size object) :found-size (sto/get-size content))) + (when-let [max (::bfc/import-max-object-size cfg)] + (when (> (sto/get-size content) max) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "storage object exceeds maximum size: " (sto/get-size content)) + :path path + :max max + :found (sto/get-size content)))) + (when-let [hash (get object :hash)] (when (not= hash (sto/get-hash content)) (ex/raise :type :validation @@ -940,6 +977,15 @@ (let [manifest (-> (read-manifest input) (validate-manifest)) entries (read-zip-entries input) + + _ (when-let [max (::bfc/import-max-zip-entries cfg)] + (when (> (count entries) max) + (ex/raise :type :validation + :code :too-many-zip-entries + :hint (str "zip file has too many entries: " (count entries)) + :max max + :found (count entries)))) + cfg (-> cfg (assoc ::entries entries) (assoc ::manifest manifest) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 3979f399d7..d514c5c9f4 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -94,7 +94,11 @@ ;; SSRF protection :ssrf-allowed-hosts #{} - :ssrf-extra-blocked-cidrs #{}}) + :ssrf-extra-blocked-cidrs #{} + + ;; Binfile import limits + :binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB + :binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000 (def schema:config (do #_sm/optional-keys @@ -151,6 +155,10 @@ [:media-processing-service-uri {:optional true} ::sm/uri] [:media-processing-service-timeout {:optional true} ::sm/int] + ;; Binfile import limits (PENPOT_BINFILE_IMPORT_*) + [:binfile-import-max-object-size {:optional true} ::sm/int] + [:binfile-import-max-zip-entries {:optional true} ::sm/int] + [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index e86cc1ffda..69a8366cdc 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -322,7 +322,9 @@ ::bfc/overwrite false ::bfc/profile-id profile-id ::bfc/project-id project-id - ::bfc/input path)] + ::bfc/input path + ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) + ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] (bf.v3/import-files! cfg) {::yres/status 200 ::yres/headers {"content-type" "text/plain"} @@ -358,7 +360,9 @@ ::bfc/profile-id profile-id ::bfc/project-id project-id ::bfc/input path - ::bfc/features (cfeat/get-team-enabled-features cf/flags team))] + ::bfc/features (cfeat/get-team-enabled-features cf/flags team) + ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) + ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] (if (= format :binfile-v3) (bf.v3/import-files! cfg) diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index 44b7014968..fee8a98557 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -93,7 +93,9 @@ (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) - (assoc ::bfc/name name)) + (assoc ::bfc/name name) + (assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)) + (assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))) input-path (:path file) owned? (some? upload-id) diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 41931f53ec..902ff49a50 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -426,7 +426,9 @@ (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) (assoc ::bfc/input template) - (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))) + (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) + (assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)) + (assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))) result (if (= format :binfile-v3) (bf.v3/import-files! cfg) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index c0e45d5429..a57624fa00 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -23,6 +23,7 @@ [app.storage :as sto] [app.storage.tmp :as tmp] [backend-tests.helpers :as th] + [backend-tests.storage-test :as stt] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] @@ -252,3 +253,88 @@ ;; With the guard, it raises :validation :max-file-size-reached. (t/is (= :validation (:type out))) (t/is (= :max-file-size-reached (:code out)))))))) + +(t/deftest import-rejects-too-many-zip-entries + ;; import must reject ZIP files exceeding max-zip-entries + (let [profile (th/create-profile* 1) + file (prepare-simple-file profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + + ;; Import with max-zip-entries=1 — the exported ZIP has more entries + (let [cfg (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (assoc ::bfc/import-max-zip-entries 1)) + out (try + (v3/import-files! cfg) + :no-error + (catch Throwable e + (let [d (or (ex-data e) (some-> (ex-cause e) ex-data))] + d)))] + (t/is (= :validation (:type out))) + (t/is (= :too-many-zip-entries (:code out)))))) + +(defn- prepare-file-with-media + "Creates a file with a media object backed by a real storage object, + so that v3 export produces objects/ entries." + [profile] + (let [storage (-> (:app.storage/storage th/*system*) + (stt/configure-storage-backend)) + + sobject (sto/put-object! storage {::sto/content (sto/content "media-bytes") + :content-type "image/svg+xml" + :bucket "file-media-object"}) + + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + + mobj (th/create-file-media-object* {:file-id (:id file) + :is-local true + :media-id (:id sobject)})] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-media + :object mobj}]) + + (dissoc file :data))) + +(t/deftest import-rejects-oversized-object + ;; import must reject storage objects exceeding max-object-size + (let [profile (th/create-profile* 1) + file (prepare-file-with-media profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream output)) + + ;; Import with max-object-size=1 — the media object will exceed this + (let [cfg (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (assoc ::bfc/import-max-object-size 1)) + out (try + (v3/import-files! cfg) + :no-error + (catch Throwable e + (let [d (or (ex-data e) (some-> (ex-cause e) ex-data))] + d)))] + (t/is (= :validation (:type out))) + (t/is (= :max-file-size-reached (:code out)))))) From 3df039abc321d56bb8f05e4384b4c1f1ed1a7607 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:46:28 +0200 Subject: [PATCH 240/298] :sparkles: Add several improvements for demo profile creation mechanism (#11257) * :zap: Optimize demo user setup for performance tests Use UUID-based demo emails to prevent concurrent profile collisions.\nUse fast PBKDF2 hashing for demo profiles while keeping regular user hashing unchanged.\nAdd focused coverage for hashing, email uniqueness, and the feature flag.\n\nAI-assisted-by: gpt-5.6-luna * :bug: Harden font upload test setup Report upload-session errors before chunk validation. Skip chunk uploads when the session ID is invalid. Remove unnecessary Mockery state from the foreign-font test. AI-assisted-by: gpt-5.6-luna * :sparkles: Add demo profile purge task Schedule delayed deletion for demo profiles through the worker system. Restore normal profile filtering and cover the purge handler with tests. AI-assisted-by: gpt-5.6-luna --- backend/src/app/auth.clj | 11 +++ backend/src/app/main.clj | 5 ++ backend/src/app/rpc/commands/demo.clj | 19 ++-- backend/src/app/rpc/commands/profile.clj | 4 +- backend/src/app/tasks/demo_purge.clj | 41 +++++++++ backend/test/backend_tests/demo_test.clj | 47 ++++++++++ backend/test/backend_tests/rpc_demo_test.clj | 40 +++++++++ backend/test/backend_tests/rpc_font_test.clj | 95 ++++++++++---------- 8 files changed, 207 insertions(+), 55 deletions(-) create mode 100644 backend/src/app/tasks/demo_purge.clj create mode 100644 backend/test/backend_tests/demo_test.clj create mode 100644 backend/test/backend_tests/rpc_demo_test.clj diff --git a/backend/src/app/auth.clj b/backend/src/app/auth.clj index 1f978f357c..efc508936a 100644 --- a/backend/src/app/auth.clj +++ b/backend/src/app/auth.clj @@ -14,10 +14,21 @@ :iterations 3 :parallelism 2}) +(def ^:private weak-options + {:alg :pbkdf2+sha256 + :iterations 100}) + (defn derive-password [password] (hashers/derive password default-options)) +(defn derive-password-weak + "Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations). + Intended for demo users only — they are already gated behind the + `demo-users` config flag which is disabled in production." + [password] + (hashers/derive password weak-options)) + (defn verify-password [attempt password] (try diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 743a17804b..d0ffd1cf58 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -392,6 +392,8 @@ :delete-object (ig/ref :app.tasks.delete-object/handler) + :demo-purge + (ig/ref :app.tasks.demo-purge/handler) :process-webhook-event (ig/ref ::webhooks/process-event-handler) :run-webhook @@ -429,6 +431,9 @@ :app.tasks.delete-object/handler {::db/pool (ig/ref ::db/pool)} + :app.tasks.demo-purge/handler + {::db/pool (ig/ref ::db/pool)} + :app.tasks.file-gc/handler {::db/pool (ig/ref ::db/pool) ::sto/storage (ig/ref ::sto/storage)} diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 13b7a2f374..56baa1916b 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -7,9 +7,9 @@ (ns app.rpc.commands.demo "A demo specific mutations." (:require - [app.auth :refer [derive-password]] + [app.auth :refer [derive-password-weak]] [app.common.exceptions :as ex] - [app.common.time :as ct] + [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.loggers.audit :as audit] @@ -17,6 +17,7 @@ [app.rpc.commands.auth :as auth] [app.rpc.doc :as-alias doc] [app.util.services :as sv] + [app.worker :as wrk] [buddy.core.codecs :as bc] [buddy.core.nonce :as bn])) @@ -34,8 +35,8 @@ :code :demo-users-not-allowed :hint "Demo users are disabled by config.")) - (let [sem (System/currentTimeMillis) - email (str "demo-" sem ".demo@example.com") + (let [sem (uuid/next) + email (str "demo-" sem "@demo.example.com") fullname (str "Demo User " sem) password (-> (bn/random-bytes 16) @@ -46,13 +47,17 @@ :fullname fullname :is-active true :is-demo true - :deleted-at (ct/in-future (cf/get-deletion-delay)) - :password (derive-password password) + :password (derive-password-weak password) :props {}} profile (db/tx-run! cfg (fn [cfg] (->> (auth/create-profile cfg params) (auth/create-profile-rels cfg))))] + + (wrk/submit! (-> cfg + (assoc ::wrk/task :demo-purge) + (assoc ::wrk/delay (cf/get-deletion-delay)) + (assoc ::wrk/params {:profile-id (:id profile)}))) + (with-meta {:email email :password password} {::audit/profile-id (:id profile)}))) - diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 4027efc6a4..22c7e3dfa5 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -141,9 +141,7 @@ (defn get-profile "Get profile by id. Throws not-found exception if no profile found." [conn id & {:as opts}] - ;; NOTE: We need to set ::db/remove-deleted to false because demo profiles - ;; are created with a set deleted-at value - (-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false)) + (-> (db/get-by-id conn :profile id opts) (decode-row))) ;; --- MUTATION: Update Profile (own) diff --git a/backend/src/app/tasks/demo_purge.clj b/backend/src/app/tasks/demo_purge.clj new file mode 100644 index 0000000000..429816c053 --- /dev/null +++ b/backend/src/app/tasks/demo_purge.clj @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.tasks.demo-purge + "Task handler for delayed demo profile deletion. Submitted at demo + creation time with a delay matching the configured deletion-delay." + (:require + [app.common.logging :as l] + [app.common.time :as ct] + [app.db :as db] + [app.worker :as wrk] + [integrant.core :as ig])) + +(defmethod ig/assert-key ::handler + [_ params] + (assert (db/pool? (::db/pool params)) "expected a valid database pool")) + +(defmethod ig/init-key ::handler + [_ cfg] + (fn [{:keys [props]}] + (let [profile-id (get props :profile-id) + now (ct/now)] + + (l/trc :hint "demo-purge" :profile-id (str profile-id)) + + ;; Mark the profile for immediate deletion + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + (db/update! conn :profile + {:deleted-at now} + {:id profile-id} + {::db/return-keys false}) + (wrk/submit! + (-> cfg + (assoc ::wrk/task :delete-object) + (assoc ::wrk/params {:object :profile + :deleted-at now + :id profile-id})))))))) diff --git a/backend/test/backend_tests/demo_test.clj b/backend/test/backend_tests/demo_test.clj new file mode 100644 index 0000000000..da1cc342c9 --- /dev/null +++ b/backend/test/backend_tests/demo_test.clj @@ -0,0 +1,47 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.demo-test + (:require + [app.common.time :as ct] + [app.db :as db] + [app.rpc.commands.profile :as profile] + [app.tasks.demo-purge :as demo-purge] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.test :as t] + [integrant.core :as ig])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest demo-profile-created-without-deleted-at + (let [profile (th/create-profile* 999 {:is-demo true})] + (t/is (true? (:is-demo profile))) + (t/is (nil? (:deleted-at profile))) + (t/is (some? (:id profile))))) + +(t/deftest get-profile-finds-demo-user-without-override + (let [profile (th/create-profile* 998 {:is-demo true}) + found (db/run! th/*pool* + (fn [{:keys [::db/conn]}] + (profile/get-profile conn (:id profile))))] + (t/is (some? found)) + (t/is (= (:id profile) (:id found))))) + +(t/deftest demo-purge-handler-submits-delete-object + (let [profile (th/create-profile* 996 {:is-demo true}) + handler (ig/init-key :app.tasks.demo-purge/handler + {::db/pool th/*pool*}) + submitted (atom nil)] + (with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}] + (reset! submitted {:task task :params params}))] + (handler {:props {:profile-id (:id profile) + :deleted-at (ct/now)}})) + (t/is (= :delete-object (:task @submitted))) + (t/is (= :profile (:object (:params @submitted)))) + (t/is (= (:id profile) (:id (:params @submitted)))) + (t/is (some? (:deleted-at (:params @submitted)))))) diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj new file mode 100644 index 0000000000..d990ec8a32 --- /dev/null +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -0,0 +1,40 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-demo-test + (:require + [app.auth :as auth] + [app.config :as cf] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;; Capture the real verifier before the shared test fixture replaces it. +(def verify-password* auth/verify-password) + +(t/deftest weak-password-hash-verifies + (let [password "DemoPassword123!" + hashed (auth/derive-password-weak password)] + (t/is (:valid (verify-password* password hashed))))) + +(t/deftest create-demo-profile-uses-unique-uuid-email + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [first-result (th/command! {::th/type :create-demo-profile}) + second-result (th/command! {::th/type :create-demo-profile}) + first-profile (:result first-result) + second-profile (:result second-result)] + (t/is (nil? (:error first-result))) + (t/is (nil? (:error second-result))) + (t/is (re-matches #"demo-[0-9a-fA-F-]+@demo\.example\.com" + (:email first-profile))) + (t/is (not= (:email first-profile) (:email second-profile)))))) + +(t/deftest create-demo-profile-requires-feature-flag + (with-redefs [cf/flags (disj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile})] + (t/is (th/ex-of-code? error :demo-users-not-allowed))))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 0f86a64cb2..106b2c498d 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -59,8 +59,13 @@ (let [out (th/command! {::th/type :create-upload-session ::rpc/profile-id (:id prof) :total-chunks total-chunks})] - (t/is (nil? (:error out))) - (:session-id (:result out)))) + (let [session-id (:session-id (:result out))] + (t/is (nil? (:error out)) + (str "create-upload-session failed: " + (some-> (:error out) ex-data))) + (t/is (uuid? session-id) + (str "create-upload-session returned an invalid session-id: " session-id)) + session-id))) (defn- upload-font-chunked! "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload @@ -68,14 +73,15 @@ [prof ^bytes font-bytes mtype chunk-size] (let [chunks (split-bytes-into-chunks font-bytes chunk-size) session-id (create-upload-session! prof (count chunks))] - (doseq [[idx chunk-data] (map-indexed vector chunks)] - (let [mfile (make-chunk-mfile chunk-data mtype) - out (th/command! {::th/type :upload-chunk - ::rpc/profile-id (:id prof) - :session-id session-id - :index idx - :content mfile})] - (t/is (nil? (:error out))))) + (when (uuid? session-id) + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out)))))) session-id)) (defn- assert-font-variant-result @@ -613,43 +619,42 @@ ;; N2-07: A user with edit permissions on their own team must not be ;; able to create a font variant using a font-id that already belongs ;; to another team (BOLA / CWE-639). - (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] - (let [prof1 (th/create-profile* 1 {:is-active true}) - prof2 (th/create-profile* 2 {:is-active true}) - team1 (:default-team-id prof1) - team2 (:default-team-id prof2) - font-id (uuid/custom 10 999) - data (-> (io/resource "backend_tests/test_files/font-1.ttf") - (io/read*))] + (let [prof1 (th/create-profile* 1 {:is-active true}) + prof2 (th/create-profile* 2 {:is-active true}) + team1 (:default-team-id prof1) + team2 (:default-team-id prof2) + font-id (uuid/custom 10 999) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") + (io/read*))] - ;; prof1 creates a font variant in team1 with font-id - (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof1) - :team-id team1 - :font-id font-id - :font-family "SharedFont" - :font-weight 400 - :font-style "normal" - :uploads {"font/ttf" session-id}} - out (th/command! params)] - (t/is (nil? (:error out)))) + ;; prof1 creates a font variant in team1 with font-id + (let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof1) + :team-id team1 + :font-id font-id + :font-family "SharedFont" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (nil? (:error out)))) - ;; prof2 tries to create a variant using the same font-id but - ;; in team2, which must be rejected because font-id belongs to team1 - (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) - params {::th/type :create-font-variant - ::rpc/profile-id (:id prof2) - :team-id team2 - :font-id font-id - :font-family "SharedFont" - :font-weight 700 - :font-style "normal" - :uploads {"font/ttf" session-id}} - out (th/command! params)] - (t/is (some? (:error out))) - (t/is (= :not-found (-> out :error ex-data :type))) - (t/is (= :object-not-found (-> out :error ex-data :code))))))) + ;; prof2 tries to create a variant using the same font-id but + ;; in team2, which must be rejected because font-id belongs to team1 + (let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof2) + :team-id team2 + :font-id font-id + :font-family "SharedFont" + :font-weight 700 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :not-found (-> out :error ex-data :type))) + (t/is (= :object-not-found (-> out :error ex-data :code)))))) (t/deftest get-font-variants-nonexistent-file (let [prof (th/create-profile* 1 {:is-active true}) From 15195b3bbbbf2911b24007ad42453d264a2774d4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:46:59 +0200 Subject: [PATCH 241/298] :bug: Filter share-link tokens in get-view-only-bundle response (#11286) * :bug: Filter share-link tokens in get-view-only-bundle response The get-view-only-bundle RPC command returned all share-link tokens for a file, allowing an anonymous holder of a restrictive share-link to enumerate and use more permissive tokens. When authenticating via a share-link, the response now only includes the share-link used for authentication, preventing token disclosure and scope escalation. Implemented using TDD: - RED: Test demonstrates vulnerability (all tokens visible) - GREEN: Filter share-links when (:type perms) = :share-link - Verified all existing tests still pass Closes #11285 AI-assisted-by: qwen3.7-plus * :bug: Add membership-side test for share-link token visibility Add test coverage for the allow side of the share-link token filtering: team members and file owners should still see all share-links, while anonymous share-link holders only see their own token. This protects the (:type perms) = :share-link guard from accidental regression that could break the owner's share-link management dialog. AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/viewer.clj | 24 +++--- .../test/backend_tests/rpc_viewer_test.clj | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index 9333800af6..81161fa45f 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -56,7 +56,7 @@ (assoc :can-read true))) (defn- get-view-only-bundle - [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id ::perms] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}] (let [file (bfc/get-file cfg file-id) project (db/get conn :project @@ -89,16 +89,18 @@ (mapv (fn [{:keys [id] :as lib}] (merge lib (bfc/get-file cfg id))))) - links (->> (db/query conn :share-link {:file-id file-id}) - (mapv (fn [row] - (-> row - (update :pages db/decode-pgarray #{}) - ;; NOTE: the flags are deprecated but are still present - ;; on the table on old rows. The flags are pgarray and - ;; for avoid decoding it (because they are no longer used - ;; on frontend) we just dissoc the column attribute from - ;; row. - (dissoc :flags))))) + links (cond->> (->> (db/query conn :share-link {:file-id file-id}) + (mapv (fn [row] + (-> row + (update :pages db/decode-pgarray #{}) + ;; NOTE: the flags are deprecated but are still present + ;; on the table on old rows. The flags are pgarray and + ;; for avoid decoding it (because they are no longer used + ;; on frontend) we just dissoc the column attribute from + ;; row. + (dissoc :flags))))) + (= :share-link (:type perms)) + (filterv #(= (:id %) share-id))) fonts (db/query conn :team-font-variant {:team-id (:id team) diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 14040aeacb..7e157a524e 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -128,3 +128,80 @@ (let [result (:result out)] (t/is (contains? result :file)) (t/is (contains? result :project))))))) + +(t/deftest share-link-token-disclosure + (let [owner (th/create-profile* 1 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + ;; Add a second page to the file + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + ;; Create Link A: restrictive (no pages, team-only comments/inspect) + link-a (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{} + :who-comment "team" + :who-inspect "team"}) + link-a-id (get-in link-a [:result :id]) + + ;; Create Link B: permissive (all pages, all can comment/inspect) + link-b (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a page-b} + :who-comment "all" + :who-inspect "all"}) + link-b-id (get-in link-b [:result :id])] + + (t/testing "restrictive share-link holder cannot see other share-link tokens" + (let [out (th/command! {::th/type :get-view-only-bundle + :share-id link-a-id + :file-id (:id file)}) + err (:error out) + result (:result out) + share-links (:share-links result)] + + ;; Should not error + (t/is (nil? err)) + + ;; Should only see the share-link used for authentication + (t/is (= 1 (count share-links))) + (t/is (= link-a-id (:id (first share-links)))) + + ;; Should NOT see Link B's token + (t/is (not (some #(= link-b-id (:id %)) share-links))))) + + (t/testing "team member still sees all share-links" + (let [out (th/command! {::th/type :get-view-only-bundle + ::rpc/profile-id (:id owner) + :file-id (:id file)}) + err (:error out) + result (:result out) + share-links (:share-links result)] + + ;; Should not error + (t/is (nil? err)) + + ;; Team member should see both share-links + (t/is (= 2 (count share-links))) + (t/is (some #(= link-a-id (:id %)) share-links)) + (t/is (some #(= link-b-id (:id %)) share-links)))))) From 326d83e780ae120b45ccd6f3d7bd24922b54461c Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:47:46 +0200 Subject: [PATCH 242/298] :bug: Block IPv6 transition addresses in SSRF guard (#11320) * :bug: Block IPv6 transition addresses in SSRF guard The outbound HTTP SSRF blocklist did not classify NAT64 (64:ff9b::/96), 6to4 (2002::/16) or Teredo (2001:0000::/32) addresses, whose embedded IPv4 target is invisible to the JVM InetAddress predicates, so URLs resolving to them could reach cloud metadata, loopback or RFC 1918 hosts from webhook delivery and media import. Transition ranges are now rejected outright and any embedded IPv4 is re-checked against the full blocklist, including operator-supplied extra blocked CIDRs. Closes #11319 * :recycle: Remove dead embedded-IPv4 re-check from SSRF guard The previous commit added a recursive re-check of the IPv4 embedded in NAT64/6to4/Teredo addresses, but the `or` in `blocked-address?` short-circuits on the truthy keyword returned by `transition-prefix`, so the embedded-IPv4 branch was unreachable. The transition ranges are already rejected outright (fail-closed), making the re-check both unnecessary and untested. Remove `transition-embedded-ipv4`, simplify the IPv6 branch to a plain prefix check, and correct the docstrings and tests to match what the code actually does. AI-assisted-by: glm-5.3-flash --- backend/src/app/util/ssrf.clj | 34 ++++++++++++++++--- backend/test/backend_tests/util_ssrf_test.clj | 18 ++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj index 2dc68afb98..a49c317441 100644 --- a/backend/src/app/util/ssrf.clj +++ b/backend/src/app/util/ssrf.clj @@ -5,7 +5,12 @@ ;; Copyright (c) KALEIDOS INC Sucursal en España SL (ns app.util.ssrf - "URL/host validation to prevent Server-Side Request Forgery." + "URL/host validation to prevent Server-Side Request Forgery. + + The blocklist covers the standard JVM InetAddress classifications plus + explicit ranges: IPv6 ULA, IPv4-mapped loopback, cloud metadata, + operator-supplied CIDRs and the IPv6 transition mechanisms NAT64, 6to4 + and Teredo." (:require [app.common.exceptions :as ex] [app.common.logging :as l] @@ -122,6 +127,20 @@ ;; Check the embedded IPv4 is loopback (127.x.x.x) (= (bit-and (aget bs 12) 0xFF) 127)))) +(defn- transition-prefix + "Classify a 16-byte IPv6 address into its transition mechanism: + :nat64 (64:ff9b::/96), :6to4 (2002::/16), :teredo (2001:0000::/32) or nil." + [^bytes bs] + (let [b0 (bit-and (aget bs 0) 0xFF) + b1 (bit-and (aget bs 1) 0xFF) + b2 (bit-and (aget bs 2) 0xFF) + b3 (bit-and (aget bs 3) 0xFF)] + (cond + (and (= b0 0x00) (= b1 0x64) (= b2 0xFF) (= b3 0x9B)) :nat64 + (and (= b0 0x20) (= b1 0x02)) :6to4 + (and (= b0 0x20) (= b1 0x01) (= b2 0x00) (= b3 0x00)) :teredo + :else nil))) + (defn- blocked-address? "Check if an InetAddress should be blocked. Returns true if blocked." [^InetAddress addr] @@ -141,12 +160,15 @@ ;; Cloud metadata IPs (exact match) (contains? cloud-metadata-ips (.getHostAddress addr)) - ;; Extra blocked CIDRs (IPv4 only) + ;; Extra blocked CIDRs (IPv4 only) and IPv6 transition mechanisms (let [bs (.getAddress addr)] (if (= (alength bs) 4) (or (some #(in-cidr4? bs %) extra-blocked-ranges) (some #(in-cidr4? bs %) extra-blocked-cidrs)) - false)))) + ;; IPv6 transition mechanisms (NAT64/6to4/Teredo): the range is + ;; rejected outright. + (boolean (when (= (alength bs) 16) + (transition-prefix bs))))))) (defn resolve-host "Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName @@ -163,8 +185,10 @@ - host must resolve to at least one address, and - **every** resolved address must NOT be in the blocklist (loopback, link-local, site-local, multicast, any-local, - cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped - IPv6 of any blocked IPv4, plus operator-supplied CIDRs). + cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv6 transition + mechanisms NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo + 2001:0000::/32, IPv4-mapped IPv6 of any blocked IPv4, + plus operator-supplied CIDRs). When the host is an IP literal (decimal/octal/hex/IPv6) it is normalized via `com.google.common.net.InetAddresses` before the check. diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj index c3b5b435bb..c8c4d6a4ea 100644 --- a/backend/test/backend_tests/util_ssrf_test.clj +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -80,6 +80,24 @@ (t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo"))) (t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo")))) +(t/deftest validate-url-blocks-nat64-encoded-metadata + ;; 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (cloud metadata) + (t/is (false? (ssrf/safe-url? "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")))) + +(t/deftest validate-url-blocks-nat64-encoded-loopback + ;; 64:ff9b::7f00:0001 embeds 127.0.0.1 + (t/is (false? (ssrf/safe-url? "http://[64:ff9b::7f00:1]/foo")))) + +(t/deftest validate-url-blocks-6to4-encoded-private + ;; 2002:a00:1:: embeds 10.0.0.1; 2002:c0a8:101:: embeds 192.168.1.1 + (t/is (false? (ssrf/safe-url? "http://[2002:a00:1::1]/foo"))) + (t/is (false? (ssrf/safe-url? "http://[2002:c0a8:101::1]/foo")))) + +(t/deftest validate-url-blocks-teredo-encoded-addresses + ;; Teredo server prefix 2001:0000::/32 + (t/is (false? (ssrf/safe-url? + "http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]/foo")))) + (t/deftest validate-url-blocks-encoded-loopback ;; Decimal encoding of 127.0.0.1 = 2130706433 ;; InetAddress normalizes this to 127.0.0.1 From 45f0153e8fb1d9b70f2cb121b15780ba2c2085c4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:48:09 +0200 Subject: [PATCH 243/298] :bug: Prevent admin from removing team owner in delete-team-member (#11368) Add owner protection to ::delete-team-member RPC command. Previously, a team admin could remove the team owner, permanently locking them out of their team and all resources. Changes: - Fetch target member data before deletion - Validate member exists (return :not-found if not) - Reject removal if target is owner and caller is not owner This mirrors the existing protection in update-team-member-role. Closes #11367 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/teams.clj | 15 +++- backend/test/backend_tests/rpc_team_test.clj | 80 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 17f23fcf9c..728e97bef1 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -944,8 +944,10 @@ ::sm/params schema:delete-team-member ::db/transaction true} [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}] - (let [team (get-team conn :profile-id profile-id :team-id team-id) - perms (get-permissions conn profile-id team-id)] + (let [team (get-team conn :profile-id profile-id :team-id team-id) + perms (get-permissions conn profile-id team-id) + members (get-team-members conn team-id) + member (d/seek #(= member-id (:id %)) members)] (when-not (or (:is-owner perms) (:is-admin perms)) (ex/raise :type :validation @@ -955,6 +957,15 @@ (ex/raise :type :validation :code :cant-remove-yourself)) + (when-not member + (ex/raise :type :not-found + :code :member-does-not-exist)) + + (when (and (:is-owner member) + (not (:is-owner perms))) + (ex/raise :type :validation + :code :cant-remove-owner)) + (db/delete! conn :team-profile-rel {:profile-id member-id :team-id team-id}) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index bf803e57c7..3d755d8231 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1328,3 +1328,83 @@ out (th/command! data)] (t/is (th/success? out)) (t/is (= 1 (:call-count @mock))))))) + +(t/deftest admin-cannot-remove-team-owner + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id admin) + :team-id (:id team) + :member-id (:id owner)})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :cant-remove-owner))))) + +(t/deftest owner-can-remove-another-owner + (let [owner1 (th/create-profile* 1 {:is-active true}) + owner2 (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner1)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id owner2) + :role :owner}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner1) + :team-id (:id team) + :member-id (:id owner2)})] + (t/is (th/success? out))))) + +(t/deftest owner-can-remove-admin + (let [owner (th/create-profile* 1 {:is-active true}) + admin (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id (:id admin)})] + (t/is (th/success? out))))) + +(t/deftest admin-can-remove-admin + (let [owner (th/create-profile* 1 {:is-active true}) + admin1 (th/create-profile* 2 {:is-active true}) + admin2 (th/create-profile* 3 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)})] + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin1) + :role :admin}) + + (th/create-team-role* {:team-id (:id team) + :profile-id (:id admin2) + :role :admin}) + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id admin1) + :team-id (:id team) + :member-id (:id admin2)})] + (t/is (th/success? out))))) + +(t/deftest delete-nonexistent-member-returns-not-found + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + fake-id (uuid/next)] + + (let [out (th/command! {::th/type :delete-team-member + ::rpc/profile-id (:id owner) + :team-id (:id team) + :member-id fake-id})] + (t/is (not (th/success? out))) + (t/is (th/ex-of-type? (:error out) :not-found)) + (t/is (th/ex-of-code? (:error out) :member-does-not-exist))))) From 29376d033b4e9919bdbe59c1e858b2704667831e Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:48:34 +0200 Subject: [PATCH 244/298] :bug: Include tenant in library summary cache keys (#11408) The redis cache keys for the file library summary were built as penpot.library-summary.<file-id>, missing the tenant component used by the rest of the shared-infrastructure keys (rlimit, msgbus, worker queues). Build the key with a new file-summary-cache-key function that appends the configured tenant, following the penpot.library-summary.<tenant>.<file-id> pattern. Stale keys in the old format expire on their own with the existing 30-day TTL. Closes #11407 AI-assisted-by: glm-5.3-flash --- backend/src/app/rpc/commands/files.clj | 9 ++++++--- backend/src/app/rpc/commands/files_update.clj | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 412da68667..e6a6e71012 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -515,8 +515,11 @@ (def ^:private file-summary-cache-key-ttl (ct/duration {:days 30})) -(def file-summary-cache-key-prefix - "penpot.library-summary.") +(defn file-summary-cache-key + "Build the redis cache key for the file library summary. The tenant is + included to prevent key collisions between tenants sharing a redis instance" + [id] + (str "penpot.library-summary." (cf/get :tenant) "." id)) (defn- get-file-with-summary "Get a file without data with a summary of its local library content" @@ -545,7 +548,7 @@ (rds/build-set-args {:ex file-summary-cache-key-ttl})))] (if (contains? cf/flags :redis-cache) - (let [cache-key (str file-summary-cache-key-prefix id)] + (let [cache-key (file-summary-cache-key id)] (or (rds/run! cfg get-from-cache cache-key) (let [file (calculate-from-db)] (rds/run! cfg persist-to-cache (:library-summary file) cache-key) diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index 8313c61f63..519a32ba07 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -322,7 +322,7 @@ (defn- invalidate-caches! [cfg {:keys [id] :as file}] (rds/run! cfg (fn [{:keys [::rds/conn]}] - (let [key (str files/file-summary-cache-key-prefix id)] + (let [key (files/file-summary-cache-key id)] (rds/del conn key))))) (defn- attach-snapshot From 6d9f411fab28a8f60cf807728f4fac2ecb646ca6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:48:55 +0200 Subject: [PATCH 245/298] :bug: Enforce share-link comment permissions and page scope (#11371) Fix two security vulnerabilities in comment RPCs when accessed via share-links: - GHSA-4p97-v4wg-jxfx: Share-link holders with who-comment=team could bypass the restriction and comment. The check-comment-permissions! function treated can-read as sufficient, but share-links always set can-read=true. - GHSA-fwm4-hm9f-rmcp: Comment query RPCs returned threads from all pages, ignoring the share-link's :pages restriction. Changes: - files.clj: Differentiate :membership vs :share-link in check-comment-permissions!. For share-links, require has-comment-permissions? only (who-comment=all). - comments.clj: Filter threads by (:pages perms) for share-link access in get-comment-threads, get-comment-thread, and get-comments. Closes #11370 AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/comments.clj | 28 ++- backend/src/app/rpc/commands/files.clj | 19 +- .../test/backend_tests/rpc_comment_test.clj | 193 ++++++++++++++++++ 3 files changed, 226 insertions(+), 14 deletions(-) diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj index 6a926d1e98..b9c6002eb4 100644 --- a/backend/src/app/rpc/commands/comments.clj +++ b/backend/src/app/rpc/commands/comments.clj @@ -231,8 +231,11 @@ ::sm/params schema:get-comment-threads} [cfg {:keys [::rpc/profile-id file-id share-id] :as params}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-comment-permissions! cfg profile-id file-id share-id) - (get-comment-threads conn profile-id file-id)))) + (let [perms (files/check-comment-permissions! cfg profile-id file-id share-id) + threads (get-comment-threads conn profile-id file-id)] + (if (= :share-link (:type perms)) + (filterv #(contains? (:pages perms) (:page-id %)) threads) + threads))))) (defn- get-comment-threads-sql [where] @@ -329,9 +332,15 @@ ::sm/params schema:get-comment-thread} [cfg {:keys [::rpc/profile-id file-id id share-id] :as params}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-comment-permissions! cfg profile-id file-id share-id) - (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) - (decode-row))))) + (let [perms (files/check-comment-permissions! cfg profile-id file-id share-id) + thread (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id]) + (decode-row))] + (when (and thread (= :share-link (:type perms))) + (when-not (contains? (:pages perms) (:page-id thread)) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found"))) + thread)))) ;; --- COMMAND: Retrieve Comments @@ -348,8 +357,13 @@ ::sm/params schema:get-comments} [cfg {:keys [::rpc/profile-id thread-id share-id]}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [{:keys [file-id]} (get-comment-thread conn thread-id)] - (files/check-comment-permissions! cfg profile-id file-id share-id) + (let [{:keys [file-id page-id]} (get-comment-thread conn thread-id) + perms (files/check-comment-permissions! cfg profile-id file-id share-id)] + (when (and (= :share-link (:type perms)) + (not (contains? (:pages perms) page-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "not found")) (get-comments conn thread-id))))) (def sql:get-comments diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 323ddf6c3f..435b83afff 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -95,18 +95,23 @@ (def check-read-permissions! (perms/make-check-fn has-read-permissions?)) -;; A user has comment permissions if she has read permissions, or -;; explicit comment permissions through the share-id +;; A user has comment permissions if: +;; - For :membership type: they have read permissions OR explicit comment permissions +;; - For :share-link type: they must have explicit comment permissions (who-comment=all) +;; This prevents share-link holders with who-comment=team from bypassing the restriction (defn check-comment-permissions! [cfg profile-id file-id share-id] - (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) - can-read (has-read-permissions? perms) - can-comment (has-comment-permissions? perms)] - (when-not (or can-read can-comment) + (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) + allowed? (if (= :share-link (:type perms)) + (has-comment-permissions? perms) + (or (has-read-permissions? perms) + (has-comment-permissions? perms)))] + (when-not allowed? (ex/raise :type :not-found :code :object-not-found - :hint "not found")))) + :hint "not found")) + perms)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; QUERY COMMANDS diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj index 8724cbfdfa..dd99cb73fc 100644 --- a/backend/test/backend_tests/rpc_comment_test.clj +++ b/backend/test/backend_tests/rpc_comment_test.clj @@ -285,3 +285,196 @@ (let [threads (th/db-query :comment-thread {:file-id (:id file-1)})] (t/is (= 0 (count threads))))))))) + +(t/deftest share-link-who-comment-team-cannot-comment + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-id} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "outsider with who-comment=team share-link cannot get-comment-threads" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "outsider with who-comment=team share-link cannot create-comment-thread" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "outsider comment" + :frame-id uuid/zero + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))))) + +(t/deftest share-link-who-comment-all-can-comment + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-id} + :who-comment "all" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "outsider with who-comment=all share-link can get-comment-threads" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id})] + (t/is (th/success? out)))) + + (t/testing "outsider with who-comment=all share-link can create-comment-thread" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "outsider comment" + :frame-id uuid/zero + :share-id share-id})] + (t/is (th/success? out)))))) + +(t/deftest share-link-page-scope-enforced + (let [owner (th/create-profile* 1 {:is-active true}) + outsider (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + thread-a (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-a + :position (gpt/point 0) + :content "comment on page A" + :frame-id uuid/zero}) + thread-b (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-b + :position (gpt/point 0) + :content "comment on page B" + :frame-id uuid/zero}) + + thread-a-id (get-in thread-a [:result :id]) + thread-b-id (get-in thread-b [:result :id]) + + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "all" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder can get-comment-threads for shared page only" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :share-id share-id}) + result (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= page-a (:page-id (first result)))))) + + (t/testing "share-link holder cannot get-comment-thread for unshared page" + (let [out (th/command! {::th/type :get-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :id thread-b-id + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))) + + (t/testing "share-link holder can get-comment-thread for shared page" + (let [out (th/command! {::th/type :get-comment-thread + ::rpc/profile-id (:id outsider) + :file-id (:id file) + :id thread-a-id + :share-id share-id})] + (t/is (th/success? out)))) + + (t/testing "share-link holder cannot get-comments for thread on unshared page" + (let [out (th/command! {::th/type :get-comments + ::rpc/profile-id (:id outsider) + :thread-id thread-b-id + :share-id share-id})] + (t/is (not (th/success? out))) + (t/is (= :not-found (th/ex-type (:error out)))))))) + +(t/deftest membership-can-still-comment + (let [owner (th/create-profile* 1 {:is-active true}) + member (th/create-profile* 2 {:is-active true}) + + team (th/create-team* 1 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id member) + :role :editor}) + project (th/create-project* 1 {:team-id (:id team) + :profile-id (:id owner)}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project)}) + page-id (get-in file [:data :pages 0])] + + (t/testing "team member can get-comment-threads without share-id" + (let [out (th/command! {::th/type :get-comment-threads + ::rpc/profile-id (:id member) + :file-id (:id file)})] + (t/is (th/success? out)))) + + (t/testing "team member can create-comment-thread without share-id" + (let [out (th/command! {::th/type :create-comment-thread + ::rpc/profile-id (:id member) + :file-id (:id file) + :page-id page-id + :position (gpt/point 0) + :content "member comment" + :frame-id uuid/zero})] + (t/is (th/success? out)))))) From 810f8ef671cdbf7e79e7b18a2471ecd8fdd94713 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:49:18 +0200 Subject: [PATCH 246/298] :bug: Cascade profile deletion in objects-gc task (#11395) The objects-gc task was performing a hard delete on profiles without cascading the soft-delete to owned teams, projects, and files. This left orphaned objects that were never cleaned up. Now the task invokes delete-object before the hard delete, ensuring all owned resources are properly marked for deletion and cleaned up in subsequent GC iterations. AI-assisted-by: qwen3.7-plus --- backend/src/app/tasks/objects_gc.clj | 6 ++++ .../test/backend_tests/rpc_profile_test.clj | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/backend/src/app/tasks/objects_gc.clj b/backend/src/app/tasks/objects_gc.clj index ac457d47b0..02a9ac62f4 100644 --- a/backend/src/app/tasks/objects_gc.clj +++ b/backend/src/app/tasks/objects_gc.clj @@ -13,6 +13,7 @@ [app.db :as db] [app.features.fdata :as fdata] [app.storage :as sto] + [app.tasks.delete-object :as dobj] [integrant.core :as ig])) (def ^:private sql:get-profiles @@ -33,6 +34,11 @@ ;; Mark as deleted the storage object (some->> photo-id (sto/touch-object! storage)) + ;; Cascade soft-delete to owned teams, projects, files, etc. + (dobj/delete-object cfg {:object :profile + :id id + :deleted-at timestamp}) + (let [affected (-> (db/delete! conn :profile {:id id}) (db/get-update-count))] (+ total affected))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index de9ae0aacd..ab10c91f4c 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -424,6 +424,38 @@ (let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))] (t/is (= 0 count-after))))) +(t/deftest profile-deletion-via-gc-cascades + (let [prof (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id prof) + :project-id (:default-project-id prof) + :is-shared false}) + team-id (:default-team-id prof) + project-id (:default-project-id prof) + file-id (:id file) + + deleted-at (ct/minus (ct/now) (ct/duration {:days 1}))] + + (th/db-update! :profile + {:deleted-at deleted-at} + {:id (:id prof)}) + + (let [team-before (th/db-get :team {:id team-id} {::db/remove-deleted false})] + (t/is (nil? (:deleted-at team-before)))) + + (let [result (th/run-task! :objects-gc {:min-age 0})] + (t/is (pos? (:processed result)))) + + (let [profile-after (th/db-get :profile {:id (:id prof)} {::db/remove-deleted false})] + (t/is (nil? profile-after))) + + (let [team-after (th/db-get :team {:id team-id} {::db/remove-deleted false})] + (t/is (nil? team-after))) + + (let [project-after (th/db-get :project {:id project-id} {::db/remove-deleted false})] + (t/is (nil? project-after))) + + (let [file-after (th/db-get :file {:id file-id} {::db/remove-deleted false})] + (t/is (nil? file-after))))) (t/deftest email-blacklist-1 (t/is (false? (email.blacklist/enabled? th/*system*))) From 23869f3b1bb7e42d54bfa71424bf227217d95df4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 08:50:05 +0200 Subject: [PATCH 247/298] :bug: Gate MCP REPL server behind isDevEnv check (#11282) * :bug: Gate MCP REPL server behind isDevEnv check The ReplServer was starting unconditionally on every MCP server instance, regardless of configuration. This exposed an unauthenticated POST /execute endpoint that forwarded arbitrary JavaScript to connected Penpot plugins. Gate ReplServer creation, startup, and shutdown behind isDevEnv(), consistent with how CljsReplTool and other dev tools are already protected. Log an info message when the REPL server is disabled. Consolidate the dev-env check into a single static isDevEnvEnabled() method that isDevEnv() delegates to, avoiding duplicate logic. Add PluginBridge.close() for proper WebSocket server cleanup on shutdown. Add regression tests that construct PenpotMcpServer and verify hasReplServer() returns the correct value based on the dev-env flag. AI-assisted-by: mimo-v2.5-pro * :sparkles: Add PENPOT_MCP_REPL_ENABLE env var for explicit REPL control Allow the REPL server to be enabled independently of the devenv setting via a new PENPOT_MCP_REPL_ENABLE environment variable. When set to "true", the REPL server starts regardless of PENPOT_MCP_DEVENV; when set to any other value, it is disabled. When unset, the previous isDevEnv fallback applies. Addresses review feedback on PR #11282. AI-assisted-by: mimo-v2.5-pro --- mcp/README.md | 1 + .../server/src/PenpotMcpServer.test.ts | 149 ++++++++++++++++++ mcp/packages/server/src/PenpotMcpServer.ts | 61 ++++++- mcp/packages/server/src/PluginBridge.ts | 12 ++ 4 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 mcp/packages/server/src/PenpotMcpServer.test.ts diff --git a/mcp/README.md b/mcp/README.md index 9b4ac77038..1b8dc3ea29 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -265,6 +265,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | +| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | | `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts new file mode 100644 index 0000000000..5c04e50400 --- /dev/null +++ b/mcp/packages/server/src/PenpotMcpServer.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PenpotMcpServer } from "./PenpotMcpServer"; + +// ── Pure function tests ──────────────────────────────────────── + +test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is not set", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({}), false); +}); + +test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is 'false'", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "false" }), false); +}); + +test("isDevEnvEnabled returns true when PENPOT_MCP_DEVENV is 'true'", () => { + assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "true" }), true); +}); + +// ── Pure function tests: isReplEnabled ────────────────────────── + +test("isReplEnabled returns false when neither env var is set", () => { + assert.equal(PenpotMcpServer.isReplEnabled({}), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_DEVENV is 'true' (fallback)", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_DEVENV: "true" }), true); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true'", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + +test("isReplEnabled returns false when PENPOT_MCP_REPL_ENABLE is 'false' even if DEVENV is true", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "false", PENPOT_MCP_DEVENV: "true" }), false); +}); + +test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true' regardless of DEVENV", () => { + assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true); +}); + +// ── Integration tests: constructor gating ────────────────────── +// +// Each test uses unique ports to avoid conflicts when tests run +// in the same process. The server is stopped in the finally block +// to release the WebSocket port. + +let portCounter = 14_500; +function uniquePorts() { + const base = portCounter; + portCounter += 10; + return { server: base, ws: base + 1, repl: base + 2 }; +} + +test("constructor does not create ReplServer when PENPOT_MCP_DEVENV is unset", async () => { + const prev = process.env.PENPOT_MCP_DEVENV; + const prevPorts = setUniqueEnv(); + delete process.env.PENPOT_MCP_DEVENV; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), false); + } finally { + await server?.stop(); + restoreEnv(prev, prevPorts); + } +}); + +test("constructor creates ReplServer when PENPOT_MCP_DEVENV is 'true'", async () => { + const prev = process.env.PENPOT_MCP_DEVENV; + const prevPorts = setUniqueEnv(); + process.env.PENPOT_MCP_DEVENV = "true"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), true); + } finally { + await server?.stop(); + restoreEnv(prev, prevPorts); + } +}); + +test("constructor creates ReplServer when PENPOT_MCP_REPL_ENABLE is 'true' without DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + delete process.env.PENPOT_MCP_DEVENV; + process.env.PENPOT_MCP_REPL_ENABLE = "true"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), true); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + +test("constructor does not create ReplServer when PENPOT_MCP_REPL_ENABLE is 'false' even with DEVENV", async () => { + const prevDevEnv = process.env.PENPOT_MCP_DEVENV; + const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE; + const prevPorts = setUniqueEnv(); + process.env.PENPOT_MCP_DEVENV = "true"; + process.env.PENPOT_MCP_REPL_ENABLE = "false"; + let server: PenpotMcpServer | undefined; + try { + server = new PenpotMcpServer(false); + assert.equal(server.hasReplServer(), false); + } finally { + await server?.stop(); + restoreEnv(prevDevEnv, prevPorts); + restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable); + } +}); + +// ── Helpers ──────────────────────────────────────────────────── + +function setUniqueEnv() { + const ports = uniquePorts(); + const prevServer = process.env.PENPOT_MCP_SERVER_PORT; + const prevWs = process.env.PENPOT_MCP_WEBSOCKET_PORT; + const prevRepl = process.env.PENPOT_MCP_REPL_PORT; + process.env.PENPOT_MCP_SERVER_PORT = String(ports.server); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(ports.ws); + process.env.PENPOT_MCP_REPL_PORT = String(ports.repl); + return { prevServer, prevWs, prevRepl }; +} + +function restoreEnv( + devEnv: string | undefined, + ports: { prevServer: string | undefined; prevWs: string | undefined; prevRepl: string | undefined } +) { + if (devEnv !== undefined) { + process.env.PENPOT_MCP_DEVENV = devEnv; + } else { + delete process.env.PENPOT_MCP_DEVENV; + } + restoreOrDelete("PENPOT_MCP_SERVER_PORT", ports.prevServer); + restoreOrDelete("PENPOT_MCP_WEBSOCKET_PORT", ports.prevWs); + restoreOrDelete("PENPOT_MCP_REPL_PORT", ports.prevRepl); +} + +function restoreOrDelete(key: string, value: string | undefined) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index bd992ec108..09849c9316 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -56,6 +56,30 @@ export class PenpotMcpServer { */ private static readonly SESSION_TIMEOUT_MINUTES = 60; + /** + * Determines whether the server is running in a Penpot development + * environment, based on the given environment variables. + * + * Returns ``true`` only when ``PENPOT_MCP_DEVENV`` is ``"true"``. + */ + public static isDevEnvEnabled(env: Record<string, string | undefined>): boolean { + return env.PENPOT_MCP_DEVENV === "true"; + } + + /** + * Determines whether the REPL server should be enabled. + * + * If ``PENPOT_MCP_REPL_ENABLE`` is set, its value controls the result + * (``"true"`` enables, any other value disables). When the variable is + * not set, the result falls back to {@link isDevEnvEnabled}. + */ + public static isReplEnabled(env: Record<string, string | undefined>): boolean { + if (env.PENPOT_MCP_REPL_ENABLE !== undefined) { + return env.PENPOT_MCP_REPL_ENABLE === "true"; + } + return PenpotMcpServer.isDevEnvEnabled(env); + } + /** * Returns a short, non-reversible fingerprint of a user token, suitable for * correlating log lines without exposing the full credential. @@ -83,7 +107,7 @@ export class PenpotMcpServer { public readonly configLoader: ConfigurationLoader; private app: any; public readonly pluginBridge: PluginBridge; - private readonly replServer: ReplServer; + private readonly replServer: ReplServer | null; private apiDocs: ApiDocs; private readonly penpotHighLevelOverview: string; private readonly connectionInstructions: string; @@ -149,7 +173,12 @@ export class PenpotMcpServer { } this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge); - this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); + + if (PenpotMcpServer.isReplEnabled(process.env)) { + this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); + } else { + this.replServer = null; + } } /** @@ -190,7 +219,18 @@ export class PenpotMcpServer { * additional developer tools such as ClojureScript expression evaluation are exposed. */ public isDevEnv(): boolean { - return process.env.PENPOT_MCP_DEVENV === "true"; + return PenpotMcpServer.isDevEnvEnabled(process.env); + } + + /** + * Indicates whether the REPL server was created. + * + * The REPL server is created when {@link isReplEnabled} returns true, + * which means either ``PENPOT_MCP_REPL_ENABLE=true`` or, when that + * variable is unset, ``PENPOT_MCP_DEVENV=true``. + */ + public hasReplServer(): boolean { + return this.replServer !== null; } /** @@ -421,8 +461,14 @@ export class PenpotMcpServer { this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); - // start the REPL server and session timeout checker - await this.replServer.start(); + // start the REPL server (devenv only) and session timeout checker + if (this.replServer) { + await this.replServer.start(); + } else { + this.logger.info( + "REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)" + ); + } this.startSessionTimeoutChecker(); resolve(); @@ -438,8 +484,11 @@ export class PenpotMcpServer { public async stop(): Promise<void> { this.logger.info("Stopping Penpot MCP Server..."); clearInterval(this.sessionTimeoutInterval); + await this.pluginBridge.close(); await this.redisBridge?.close(); - await this.replServer.stop(); + if (this.replServer) { + await this.replServer.stop(); + } this.logger.info("Penpot MCP Server stopped"); } } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index a362faef36..6a1e07f9c5 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -467,4 +467,16 @@ export class PluginBridge { task.rejectWithError(error instanceof Error ? error : new Error(String(error))); } } + + /** + * Closes the WebSocket server and all connected client sockets. + */ + public async close(): Promise<void> { + return new Promise((resolve) => { + this.wsServer.close(() => { + this.logger.info("WebSocket server closed"); + resolve(); + }); + }); + } } From 5c4b35e35093a2726879875ae1c810cb7db83de6 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Tue, 1 Sep 2026 10:56:53 +0200 Subject: [PATCH 248/298] :bug: Fix top spacing on left sidebar (#11446) --- frontend/src/app/main/ui/workspace/sidebar/layers.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index d329a3d2fb..db63e18534 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -23,7 +23,7 @@ align-items: center; justify-content: space-between; gap: var(--sp-xs); - margin: var(--sp-m) var(--sp-m) 0 var(--sp-m); + margin: px2rem(6) var(--sp-m) 0 var(--sp-m); } .tool-window-bar-title { From a1079cf788862a9facb768390a88e4d504c3b687 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 12:01:35 +0200 Subject: [PATCH 249/298] :arrow_up: Update JVM, pnpm and node dependencies (#11404) * :arrow_up: Update pnpm and its deps * :arrow_up: Update JVM dependencies in backend and common Update several JVM dependencies across backend and common: - passay 1.6.6 -> 2.0.0 (package reorg, ctor-based rules) - siphash 2.0.0 -> 3.0.0 (SipHasher* renamed to SipHash*) - lettuce-core, guava, sqlite-jdbc, jsoup, lz4-java, markdown-clj, awssdk s3/sts, selmer, jackson-core/databind, shadow-cljs Adapt passay validation to the new API (moved packages, constructor configuration) and siphash to the renamed classes. Add tests for password validation and UUID advisory-lock hashing. AI-assisted-by: deepseek-v4-flash * :arrow_up: Update node on docker images * :paperclip: Minor fixes related to pnpm12 compatibility --- backend/deps.edn | 20 +- backend/src/app/auth/passwords.clj | 13 +- backend/src/app/db.clj | 10 +- backend/test/backend_tests/db_test.clj | 11 + backend/test/backend_tests/passwords_test.clj | 51 + common/deps.edn | 8 +- common/package.json | 6 +- common/pnpm-lock.yaml | 121 +- docker/devenv/Dockerfile | 2 +- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.mcp | 2 +- docker/images/Dockerfile.media-processor | 2 +- docs/package.json | 4 +- docs/pnpm-lock.yaml | 235 ++- exporter/deps.edn | 2 +- exporter/package.json | 6 +- exporter/pnpm-lock.yaml | 121 +- frontend/deps.edn | 4 +- frontend/package.json | 46 +- frontend/packages/draft-js/package.json | 2 +- frontend/packages/mousetrap/package.json | 2 +- frontend/packages/tokenscript/package.json | 2 +- frontend/packages/ui/package.json | 10 +- frontend/pnpm-lock.yaml | 1608 ++++++++++------- frontend/src/app/plugins/utils.cljs | 3 +- frontend/text-editor/package.json | 4 +- library/package.json | 4 +- library/pnpm-lock.yaml | 111 +- mcp/package.json | 4 +- mcp/packages/common/package.json | 2 +- mcp/packages/plugin/vite.release.config.ts | 2 +- mcp/packages/server/package.json | 2 +- mcp/pnpm-lock.yaml | 1046 ++++++----- mcp/pnpm-workspace.yaml | 11 +- media-processor/package.json | 12 +- media-processor/pnpm-lock.yaml | 709 ++++---- .../apps/colors-to-tokens-plugin/package.json | 2 +- .../apps/composable-test-suite/package.json | 4 +- plugins/apps/contrast-plugin/package.json | 2 +- .../apps/create-palette-plugin/package.json | 2 +- plugins/apps/e2e/package.json | 2 +- plugins/apps/example-styles/package.json | 2 +- plugins/apps/icons-plugin/package.json | 2 +- plugins/apps/lorem-ipsum-plugin/package.json | 2 +- .../apps/plugin-api-test-suite/package.json | 2 +- plugins/apps/poc-state-plugin/package.json | 2 +- plugins/apps/poc-tokens-plugin/package.json | 2 +- .../apps/rename-layers-plugin/package.json | 2 +- plugins/apps/table-plugin/package.json | 2 +- plugins/libs/plugin-types/package.json | 2 +- plugins/libs/plugins-runtime/package.json | 2 +- plugins/libs/plugins-styles/package.json | 2 +- plugins/package.json | 62 +- plugins/pnpm-lock.yaml | 1471 ++++++++------- render-wasm/package.json | 6 +- render-wasm/pnpm-lock.yaml | 329 ++-- 56 files changed, 3713 insertions(+), 2389 deletions(-) create mode 100644 backend/test/backend_tests/passwords_test.clj diff --git a/backend/deps.edn b/backend/deps.edn index 1450f4de58..5963271594 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -17,7 +17,7 @@ io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"} - io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "7.7.0.RELEASE"} ;; Minimal dependencies required by lettuce, we need to include them ;; explicitly because clojure dependency management does not support ;; yet the BOM format. @@ -25,7 +25,7 @@ io.micrometer/micrometer-observation {:mvn/version "1.14.2"} java-http-clj/java-http-clj {:mvn/version "0.4.3"} - com.google.guava/guava {:mvn/version "33.6.0-jre"} + com.google.guava/guava {:mvn/version "33.7.1-jre"} funcool/yetti {:git/tag "v11.10" @@ -40,32 +40,32 @@ nrepl/nrepl {:mvn/version "1.7.0"} org.postgresql/postgresql {:mvn/version "42.7.13"} - org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"} + org.xerial/sqlite-jdbc {:mvn/version "3.53.4.0"} com.zaxxer/HikariCP {:mvn/version "7.1.0"} - io.whitfin/siphash {:mvn/version "2.0.0"} + io.whitfin/siphash {:mvn/version "3.0.0"} buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} - org.passay/passay {:mvn/version "1.6.6"} + org.passay/passay {:mvn/version "2.0.0"} com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} - org.jsoup/jsoup {:mvn/version "1.23.1"} + org.jsoup/jsoup {:mvn/version "1.23.2"} at.yawk.lz4/lz4-java - {:mvn/version "1.11.1"} + {:mvn/version "1.11.2"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} dawran6/emoji {:mvn/version "0.2.0"} - markdown-clj/markdown-clj {:mvn/version "1.12.8"} + markdown-clj/markdown-clj {:mvn/version "1.12.9"} ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.50.1"} - software.amazon.awssdk/sts {:mvn/version "2.50.1"}} + software.amazon.awssdk/s3 {:mvn/version "2.54.5"} + software.amazon.awssdk/sts {:mvn/version "2.54.5"}} :paths ["src" "resources" "target/classes"] :aliases diff --git a/backend/src/app/auth/passwords.clj b/backend/src/app/auth/passwords.clj index cc75b202b7..c047b66b5a 100644 --- a/backend/src/app/auth/passwords.clj +++ b/backend/src/app/auth/passwords.clj @@ -9,7 +9,9 @@ (:require [app.common.exceptions :as ex]) (:import - [org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData])) + [org.passay PasswordData] + [org.passay.data EnglishCharacterData] + [org.passay.rule CharacterCharacteristicsRule CharacterRule])) (defonce ^:private passay-code->translation-key {"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase" @@ -18,12 +20,13 @@ "INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"}) (defonce ^:private character-characteristics-rule - (doto (CharacterCharacteristicsRule.) - (.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1) + (CharacterCharacteristicsRule. + 4 + (into-array org.passay.rule.CharacterRule + [(CharacterRule. EnglishCharacterData/LowerCase 1) (CharacterRule. EnglishCharacterData/UpperCase 1) (CharacterRule. EnglishCharacterData/Digit 1) - (CharacterRule. EnglishCharacterData/Special 1)]) - (.setNumberOfCharacteristics 4))) + (CharacterRule. EnglishCharacterData/Special 1)]))) (defn validate-password "Validates password strength. diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index f10b0089a0..07f4d9aeb7 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -31,8 +31,8 @@ com.zaxxer.hikari.HikariDataSource com.zaxxer.hikari.HikariPoolMXBean com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory - io.whitfin.siphash.SipHasher - io.whitfin.siphash.SipHasherContainer + io.whitfin.siphash.SipHash + io.whitfin.siphash.SipHashContext java.io.InputStream java.io.OutputStream java.sql.Connection @@ -701,12 +701,12 @@ ;; --- Locks (def ^:private siphash-state - (SipHasher/container - (uuid/get-bytes uuid/zero))) + (SipHash/context + (uuid/get-bytes uuid/zero))) (defn uuid->hash-code [o] - (.hash ^SipHasherContainer siphash-state + (.hash ^SipHashContext siphash-state ^bytes (uuid/get-bytes o))) (defn- xact-check-param diff --git a/backend/test/backend_tests/db_test.clj b/backend/test/backend_tests/db_test.clj index 3d228bc8c1..94125a32b7 100644 --- a/backend/test/backend_tests/db_test.clj +++ b/backend/test/backend_tests/db_test.clj @@ -6,6 +6,7 @@ (ns backend-tests.db-test (:require + [app.common.uuid :as uuid] [app.db :as db] [backend-tests.helpers :as th] [clojure.test :as t]) @@ -41,3 +42,13 @@ (t/testing "maximum pool size is reasonable" (t/is (pos? (:maximum-pool-size stats)))))) + +(t/deftest uuid->hash-code-is-deterministic + (t/is (= (db/uuid->hash-code uuid/zero) + (db/uuid->hash-code uuid/zero)))) + +(t/deftest uuid->hash-code-returns-long + (t/is (instance? Long (db/uuid->hash-code uuid/zero)))) + +(t/deftest uuid->hash-code-stable-for-zero-uuid + (t/is (= 3659997967308761462 (db/uuid->hash-code uuid/zero)))) diff --git a/backend/test/backend_tests/passwords_test.clj b/backend/test/backend_tests/passwords_test.clj new file mode 100644 index 0000000000..75a880a5de --- /dev/null +++ b/backend/test/backend_tests/passwords_test.clj @@ -0,0 +1,51 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.passwords-test + (:require + [app.auth.passwords :as passwords] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(defn- run-validation + [password] + (try + (passwords/validate-password password) + nil + (catch Throwable e + e))) + +(t/deftest validate-password-accepts-strong-password + (t/is (nil? (run-validation "Str0ng!Pass")))) + +(t/deftest validate-password-rejects-too-short-password + (let [error (run-validation "Ab1!x")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.too-short"] (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-lowercase + (let [error (run-validation "ABCDEFG1!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-lowercase"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-uppercase + (let [error (run-validation "abcdefg1!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-uppercase"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-digit + (let [error (run-validation "Abcdefgh!")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-digits"] + (:details (ex-data error)))))) + +(t/deftest validate-password-rejects-missing-special + (let [error (run-validation "Abcdefgh1")] + (t/is (th/ex-of-code? error :weak-password)) + (t/is (= ["errors.weak-password.insufficient-special"] + (:details (ex-data error)))))) \ No newline at end of file diff --git a/common/deps.edn b/common/deps.edn index edffb487f0..1a409e0d12 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -17,14 +17,14 @@ org.slf4j/slf4j-api {:mvn/version "2.0.18"} pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"} - selmer/selmer {:mvn/version "1.13.4"} + selmer/selmer {:mvn/version "1.13.5"} criterium/criterium {:mvn/version "0.4.6"} metosin/jsonista {:mvn/version "1.0.0" :exclusions [com.fasterxml.jackson.core/jackson-core com.fasterxml.jackson.core/jackson-databind]} - com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"} - com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"} + com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.2"} + com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.2"} metosin/malli {:mvn/version "0.20.1"} @@ -60,7 +60,7 @@ {:dev {:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.1"} - thheller/shadow-cljs {:mvn/version "3.4.11"} + thheller/shadow-cljs {:mvn/version "3.5.0"} com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} com.bhauman/rebel-readline {:mvn/version "0.1.11"} criterium/criterium {:mvn/version "0.4.6"} diff --git a/common/package.json b/common/package.json index c05a294ca5..acc9432642 100644 --- a/common/package.json +++ b/common/package.json @@ -4,18 +4,18 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "type": "module", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "devDependencies": { - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "nodemon": "^3.1.14", "prettier": "3.9.6", "source-map-support": "^0.5.21", - "ws": "^8.21.2" + "ws": "^8.21.3" }, "dependencies": { "date-fns": "^4.4.0" diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 0f0edb681d..77f173a685 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -13,8 +114,8 @@ importers: version: 4.4.0 devDependencies: concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -25,8 +126,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 ws: - specifier: ^8.21.2 - version: 8.21.2 + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -73,8 +174,8 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -234,8 +335,8 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.2: - resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -303,7 +404,7 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -439,7 +540,7 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.2: {} + ws@8.21.3: {} y18n@5.0.8: {} diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 5a493a862f..fb9e54fb63 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -66,7 +66,7 @@ RUN set -ex; \ FROM base AS setup-node -ENV NODE_VERSION=v24.19.0 \ +ENV NODE_VERSION=v24.20.0 \ PATH=/opt/node/bin:$PATH RUN set -eux; \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 97189bc5be..46fbb0041d 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.19.0-debian13-dev +FROM dhi.io/node:24.20.0-debian13-dev LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ diff --git a/docker/images/Dockerfile.mcp b/docker/images/Dockerfile.mcp index 8d943c7e27..bad957b07c 100644 --- a/docker/images/Dockerfile.mcp +++ b/docker/images/Dockerfile.mcp @@ -1,4 +1,4 @@ -FROM dhi.io/node:24.18.1-debian13-dev AS build +FROM dhi.io/node:24.20.0-debian13-dev AS build LABEL maintainer="Penpot <docker@penpot.app>" ENV DEBIAN_FRONTEND=noninteractive diff --git a/docker/images/Dockerfile.media-processor b/docker/images/Dockerfile.media-processor index bc83e9e5a5..3a1c15e9ae 100644 --- a/docker/images/Dockerfile.media-processor +++ b/docker/images/Dockerfile.media-processor @@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>" ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v24.18.0 \ + NODE_VERSION=v24.20.0 \ DEBIAN_FRONTEND=noninteractive \ PATH=/opt/node/bin:$PATH diff --git a/docs/package.json b/docs/package.json index 9af9a87dba..7d498ee015 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,7 +29,7 @@ "@11ty/eleventy-plugin-rss": "^3.0.0", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "@tigersway/eleventy-plugin-ancestry": "^1.0.3", - "@types/markdown-it": "14.1.2", + "@types/markdown-it": "14.2.0", "elasticlunr": "^0.9.5", "eleventy-plugin-metagen": "^1.8.4", "eleventy-plugin-nesting-toc": "^1.3.0", @@ -39,5 +39,5 @@ "markdown-it-anchor": "^9.2.1", "markdown-it-plantuml": "^1.4.1" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index c557631422..5938203566 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -24,8 +125,8 @@ importers: specifier: ^1.0.3 version: 1.0.3(@11ty/eleventy@3.1.6) '@types/markdown-it': - specifier: 14.1.2 - version: 14.1.2 + specifier: 14.2.0 + version: 14.2.0 elasticlunr: specifier: ^0.9.5 version: 0.9.5 @@ -46,7 +147,7 @@ importers: version: 15.0.0 markdown-it-anchor: specifier: ^9.2.1 - version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0) + version: 9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0) markdown-it-plantuml: specifier: ^1.4.1 version: 1.4.1 @@ -64,8 +165,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@11ty/eleventy-fetch@5.1.2': - resolution: {integrity: sha512-YxDARdR3S9UT4gOGRWgGNyokYT9jkCAjJge3OVKFdNMv1cyvWg1NHCvj9NVvK9XHenCLFy3cwvM/YYpZZTZopw==} + '@11ty/eleventy-fetch@5.1.3': + resolution: {integrity: sha512-4HS6QB/mVWTVlE6kjCKPkjkC1Z0YOqizJaHR05zK+E7a2b4EUC3T+wLktIy7wEl76ZoarkY45EKLmTw1XuR8fQ==} engines: {node: '>=18'} '@11ty/eleventy-navigation@1.0.5': @@ -102,8 +203,8 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} - '@rgrove/parse-xml@4.2.0': - resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} + '@rgrove/parse-xml@4.2.3': + resolution: {integrity: sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==} engines: {node: '>=14.0.0'} '@sindresorhus/slugify@2.2.1': @@ -122,8 +223,8 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/markdown-it@14.1.2': - resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -135,8 +236,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -150,8 +251,8 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - argparse@3.0.0: - resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==} + argparse@3.0.1: + resolution: {integrity: sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==} asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -165,8 +266,8 @@ packages: bcp-47-normalize@2.3.0: resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==} - bcp-47@2.1.0: - resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} @@ -363,8 +464,8 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} @@ -439,12 +540,12 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - iso-639-1@3.1.5: - resolution: {integrity: sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==} + iso-639-1@3.1.6: + resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==} engines: {node: '>=6.0'} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true js-yaml@4.3.1: @@ -469,8 +570,8 @@ packages: linkify-it@6.1.0: resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} - liquidjs@10.28.0: - resolution: {integrity: sha512-b6tmBXYMQTuGPnM5vB0CuZMo5kvmKMtSB/gvUWP6RFn2pIB5s+bJkBfIyJnattkc5bgeAiflrZVptx3iaLLioQ==} + liquidjs@10.29.0: + resolution: {integrity: sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==} engines: {node: '>=16'} hasBin: true @@ -598,8 +699,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} please-upgrade-node@3.2.0: @@ -634,8 +735,8 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} readdirp@3.6.0: @@ -652,8 +753,8 @@ packages: semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -729,8 +830,8 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -746,7 +847,7 @@ snapshots: '@11ty/dependency-tree-esm@2.0.4': dependencies: '@11ty/eleventy-utils': 2.0.7 - acorn: 8.17.0 + acorn: 8.18.0 dependency-graph: 1.0.0 normalize-path: 3.0.0 @@ -767,18 +868,18 @@ snapshots: send: 1.2.1 ssri: 11.0.0 urlpattern-polyfill: 10.1.0 - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@11ty/eleventy-fetch@5.1.2': + '@11ty/eleventy-fetch@5.1.3': dependencies: '@11ty/eleventy-utils': 2.0.7 - '@rgrove/parse-xml': 4.2.0 + '@rgrove/parse-xml': 4.2.3 debug: 4.4.3 - flatted: 3.4.2 + flatted: 3.4.4 p-queue: 6.6.2 transitivePeerDependencies: - supports-color @@ -829,21 +930,21 @@ snapshots: entities: 6.0.1 filesize: 10.1.6 gray-matter: 4.0.3 - iso-639-1: 3.1.5 + iso-639-1: 3.1.6 js-yaml: 4.3.1 kleur: 4.1.5 - liquidjs: 10.28.0 + liquidjs: 10.29.0 luxon: 3.7.2 markdown-it: 14.3.0 minimist: 1.2.8 moo: 0.5.2 node-retrieve-globals: 6.0.1 nunjucks: 3.2.4(chokidar@3.6.0) - picomatch: 4.0.4 + picomatch: 4.0.7 please-upgrade-node: 3.2.0 posthtml: 0.16.7 posthtml-match-helper: 2.0.3(posthtml@0.16.7) - semver: 7.8.4 + semver: 7.8.5 slugify: 1.6.9 tinyglobby: 0.2.17 transitivePeerDependencies: @@ -867,7 +968,7 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 - '@rgrove/parse-xml@4.2.0': {} + '@rgrove/parse-xml@4.2.3': {} '@sindresorhus/slugify@2.2.1': dependencies: @@ -884,7 +985,7 @@ snapshots: '@types/linkify-it@5.0.0': {} - '@types/markdown-it@14.1.2': + '@types/markdown-it@14.2.0': dependencies: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 @@ -895,9 +996,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} anymatch@3.1.3: dependencies: @@ -910,7 +1011,7 @@ snapshots: argparse@2.0.1: {} - argparse@3.0.0: {} + argparse@3.0.1: {} asap@2.0.6: {} @@ -920,10 +1021,10 @@ snapshots: bcp-47-normalize@2.3.0: dependencies: - bcp-47: 2.1.0 + bcp-47: 2.1.1 bcp-47-match: 2.0.3 - bcp-47@2.1.0: + bcp-47@2.1.1: dependencies: is-alphabetical: 2.0.1 is-alphanumerical: 2.0.1 @@ -1060,7 +1161,7 @@ snapshots: eleventy-plugin-youtube-embed@1.13.2: dependencies: - '@11ty/eleventy-fetch': 5.1.2 + '@11ty/eleventy-fetch': 5.1.3 deepmerge: 4.3.1 lite-youtube-embed: 0.3.4 string-replace-async: 3.0.2 @@ -1096,7 +1197,7 @@ snapshots: esm-import-transformer@3.0.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 esprima@4.0.1: {} @@ -1110,9 +1211,9 @@ snapshots: dependencies: is-extendable: 0.1.1 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 filesize@10.1.6: {} @@ -1132,7 +1233,7 @@ snapshots: transitivePeerDependencies: - supports-color - flatted@3.4.2: {} + flatted@3.4.4: {} fresh@2.0.0: {} @@ -1147,7 +1248,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.15.0 + js-yaml: 3.15.1 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -1207,9 +1308,9 @@ snapshots: is-number@7.0.0: {} - iso-639-1@3.1.5: {} + iso-639-1@3.1.6: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 @@ -1232,7 +1333,7 @@ snapshots: dependencies: uc.micro: 3.0.0 - liquidjs@10.28.0: + liquidjs@10.29.0: dependencies: commander: 10.0.1 @@ -1242,9 +1343,9 @@ snapshots: luxon@3.7.2: {} - markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0): + markdown-it-anchor@9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0): dependencies: - '@types/markdown-it': 14.1.2 + '@types/markdown-it': 14.2.0 markdown-it: 15.0.0 markdown-it-plantuml@1.4.1: {} @@ -1260,7 +1361,7 @@ snapshots: markdown-it@15.0.0: dependencies: - argparse: 3.0.0 + argparse: 3.0.1 entities: 8.0.0 linkify-it: 6.1.0 mdurl: 2.1.0 @@ -1299,7 +1400,7 @@ snapshots: node-retrieve-globals@6.0.1: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk: 8.3.5 esm-import-transformer: 3.0.5 @@ -1351,7 +1452,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.7: {} please-upgrade-node@3.2.0: dependencies: @@ -1380,7 +1481,7 @@ snapshots: punycode.js@2.3.1: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} readdirp@3.6.0: dependencies: @@ -1395,7 +1496,7 @@ snapshots: semver-compare@1.0.0: {} - semver@7.8.4: {} + semver@7.8.5: {} send@1.2.1: dependencies: @@ -1408,7 +1509,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -1433,8 +1534,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 to-regex-range@5.0.1: dependencies: @@ -1458,4 +1559,4 @@ snapshots: whatwg-mimetype@4.0.0: {} - ws@8.21.0: {} + ws@8.21.3: {} diff --git a/exporter/deps.edn b/exporter/deps.edn index 9495142719..4d8391f6de 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -14,7 +14,7 @@ :dev {:extra-deps - {thheller/shadow-cljs {:mvn/version "3.4.11"}}} + {thheller/shadow-cljs {:mvn/version "3.5.0"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"] diff --git a/exporter/package.json b/exporter/package.json index e52fb9fd12..1937c23c0c 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -21,12 +21,12 @@ "playwright": "1.62.1", "raw-body": "^4.0.0", "source-map-support": "^0.5.21", - "undici": "^8.9.0", + "undici": "^8.10.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.21.1" + "ws": "^8.21.3" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 3764b5fb63..02f1974302 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -45,8 +146,8 @@ importers: specifier: ^0.5.21 version: 0.5.21 undici: - specifier: ^8.9.0 - version: 8.9.0 + specifier: ^8.10.0 + version: 8.10.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -55,8 +156,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -420,15 +521,15 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@8.9.0: - resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -802,11 +903,11 @@ snapshots: tsscmp@1.0.6: {} - undici@8.9.0: {} + undici@8.10.0: {} util-deprecate@1.0.2: {} - ws@8.21.1: {} + ws@8.21.3: {} xml-js@1.6.11: dependencies: diff --git a/frontend/deps.edn b/frontend/deps.edn index a662b7086d..8688b4a99a 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -3,7 +3,7 @@ {penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.2"} + org.clojure/clojure {:mvn/version "1.12.5"} binaryage/devtools {:mvn/version "RELEASE"} metosin/reitit-core {:mvn/version "0.10.1"} funcool/okulary {:mvn/version "2022.04.11-16"} @@ -50,7 +50,7 @@ "--enable-native-access=ALL-UNNAMED"] :extra-deps - {thheller/shadow-cljs {:mvn/version "3.4.11"} + {thheller/shadow-cljs {:mvn/version "3.5.0"} com.bhauman/rebel-readline {:mvn/version "RELEASE"} org.clojure/tools.namespace {:mvn/version "RELEASE"} criterium/criterium {:mvn/version "0.4.6"}}} diff --git a/frontend/package.json b/frontend/package.json index e61395d4f9..daffb1739f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "browserslist": [ "defaults" ], @@ -59,33 +59,33 @@ "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.62.1", - "@storybook/addon-docs": "10.5.6", - "@storybook/addon-themes": "10.5.6", - "@storybook/addon-vitest": "10.5.6", - "@storybook/react-vite": "10.5.6", + "@storybook/addon-docs": "10.5.10", + "@storybook/addon-themes": "10.5.10", + "@storybook/addon-vitest": "10.5.10", + "@storybook/react-vite": "10.5.10", "@tokens-studio/sd-transforms": "2.0.3", - "@types/node": "^26.1.2", - "@vitest/browser": "4.1.10", - "@vitest/browser-playwright": "4.1.10", - "@vitest/coverage-v8": "4.1.10", + "@types/node": "^26.4.0", + "@vitest/browser": "4.1.11", + "@vitest/browser-playwright": "4.1.11", + "@vitest/coverage-v8": "4.1.11", "@zip.js/zip.js": "2.8.34", "autoprefixer": "^10.5.4", "compression": "^1.8.1", - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "date-fns": "^4.4.0", - "esbuild": "^0.28.1", - "eventsource-parser": "^3.1.0", + "esbuild": "^0.28.2", + "eventsource-parser": "^4.1.0", "express": "^5.1.0", "fancy-log": "^2.0.0", "getopts": "^2.3.0", "gettext-parser": "^9.1.1", - "highlight.js": "^11.10.0", + "highlight.js": "^11.12.0", "js-beautify": "^2.0.3", "jsdom": "^30.0.1", "lodash": "^4.18.1", "lodash.debounce": "^4.0.8", "map-stream": "0.0.7", - "marked": "^18.0.9", + "marked": "^18.0.11", "mkdirp": "^3.0.1", "mustache": "^4.2.0", "nodemon": "^3.1.14", @@ -93,7 +93,7 @@ "opentype.js": "^2.0.0", "p-limit": "^7.3.1", "playwright": "1.62.1", - "postcss": "^8.5.25", + "postcss": "^8.5.26", "postcss-clean": "^1.2.2", "postcss-modules": "^9.0.1", "postcss-scss": "^4.0.9", @@ -103,27 +103,27 @@ "randomcolor": "^0.6.2", "react": "19.2.8", "react-dom": "19.2.8", - "react-error-boundary": "^6.1.2", + "react-error-boundary": "^6.1.3", "react-virtualized": "^9.22.6", "rimraf": "^6.1.3", "rxjs": "8.0.0-alpha.14", - "sass": "^1.102.0", - "sass-embedded": "^1.100.0", + "sass": "^1.103.1", + "sass-embedded": "^1.103.1", "sax": "^1.6.1", "scheduler": "^0.27.0", "source-map-support": "^0.5.21", - "storybook": "10.5.6", - "style-dictionary": "5.5.0", + "storybook": "10.5.10", + "style-dictionary": "5.5.2", "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", "stylelint-plugin-logical-css": "^2.1.0", "stylelint-scss": "^7.2.0", "svg-sprite": "^2.0.4", - "tdigest": "^0.1.2", + "tdigest": "^0.1.3", "tinycolor2": "^1.6.0", "typescript": "^6.0.2", - "vite": "^8.2.0", - "vitest": "^4.1.10", + "vite": "^8.2.2", + "vitest": "^4.1.11", "wait-on": "^9.1.0", "watcher": "^2.3.1", "workerpool": "^10.0.3", diff --git a/frontend/packages/draft-js/package.json b/frontend/packages/draft-js/package.json index e2955437f2..682dfeb8d0 100644 --- a/frontend/packages/draft-js/package.json +++ b/frontend/packages/draft-js/package.json @@ -4,7 +4,7 @@ "description": "Penpot Draft-JS Wrapper", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/mousetrap/package.json b/frontend/packages/mousetrap/package.json index 56b3d9abc2..ece42204ef 100644 --- a/frontend/packages/mousetrap/package.json +++ b/frontend/packages/mousetrap/package.json @@ -4,7 +4,7 @@ "description": "Simple library for handling keyboard shortcuts", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Craig Campbell", "license": "Apache-2.0 WITH LLVM-exception" } diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json index c00cc0082a..4689f02e86 100644 --- a/frontend/packages/tokenscript/package.json +++ b/frontend/packages/tokenscript/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "author": "Andrey Antukh", "license": "MPL-2.0", "dependencies": { diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index 9fe6715c77..131a0e6d85 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -20,20 +20,20 @@ "devDependencies": { "@babel/core": "^8.0.1", "@babel/preset-react": "^8.0.1", - "@storybook/react": "10.5.6", - "@storybook/react-vite": "10.5.6", + "@storybook/react": "10.5.10", + "@storybook/react-vite": "10.5.10", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", "babel-plugin-react-compiler": "^1.0.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "react-compiler-runtime": "^1.0.0", - "storybook": "10.5.6", + "storybook": "10.5.10", "vite-plugin-dts": "^5.0.3" }, "dependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index bf162c0db6..01866c4118 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -58,53 +159,53 @@ importers: specifier: 1.62.1 version: 1.62.1 '@storybook/addon-docs': - specifier: 10.5.6 - version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@storybook/addon-themes': - specifier: 10.5.6 - version: 10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + specifier: 10.5.10 + version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) '@storybook/addon-vitest': - specifier: 10.5.6 - version: 10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10) + specifier: 10.5.10 + version: 10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11) '@storybook/react-vite': - specifier: 10.5.6 - version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@tokens-studio/sd-transforms': specifier: 2.0.3 - version: 2.0.3(style-dictionary@5.5.0(tslib@2.8.1)) + version: 2.0.3(style-dictionary@5.5.2(tslib@2.8.1)) '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.4.0 + version: 26.4.0 '@vitest/browser': - specifier: 4.1.10 - version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/browser-playwright': - specifier: 4.1.10 - version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) '@zip.js/zip.js': specifier: 2.8.34 version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.25) + version: 10.5.4(postcss@8.5.26) compression: specifier: ^1.8.1 version: 1.8.1(supports-color@5.5.0) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 date-fns: specifier: ^4.4.0 version: 4.4.0 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 eventsource-parser: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^4.1.0 + version: 4.1.0 express: specifier: ^5.1.0 version: 5.2.1(supports-color@5.5.0) @@ -118,8 +219,8 @@ importers: specifier: ^9.1.1 version: 9.1.1 highlight.js: - specifier: ^11.10.0 - version: 11.11.1 + specifier: ^11.12.0 + version: 11.12.0 js-beautify: specifier: ^2.0.3 version: 2.0.3 @@ -136,8 +237,8 @@ importers: specifier: 0.0.7 version: 0.0.7 marked: - specifier: ^18.0.9 - version: 18.0.9 + specifier: ^18.0.11 + version: 18.0.11 mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -160,17 +261,17 @@ importers: specifier: 1.62.1 version: 1.62.1 postcss: - specifier: ^8.5.25 - version: 8.5.25 + specifier: ^8.5.26 + version: 8.5.26 postcss-clean: specifier: ^1.2.2 version: 1.2.2 postcss-modules: specifier: ^9.0.1 - version: 9.0.1(postcss@8.5.25) + version: 9.0.1(postcss@8.5.26) postcss-scss: specifier: ^4.0.9 - version: 4.0.9(postcss@8.5.25) + version: 4.0.9(postcss@8.5.26) prettier: specifier: 3.9.6 version: 3.9.6 @@ -190,8 +291,8 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) react-error-boundary: - specifier: ^6.1.2 - version: 6.1.2(react@19.2.8) + specifier: ^6.1.3 + version: 6.1.3(@types/react@19.2.18)(react@19.2.8) react-virtualized: specifier: ^9.22.6 version: 9.22.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -202,11 +303,11 @@ importers: specifier: 8.0.0-alpha.14 version: 8.0.0-alpha.14 sass: - specifier: ^1.102.0 - version: 1.102.0 + specifier: ^1.103.1 + version: 1.103.1 sass-embedded: - specifier: ^1.100.0 - version: 1.100.0 + specifier: ^1.103.1 + version: 1.103.1 sax: specifier: ^1.6.1 version: 1.6.1 @@ -217,17 +318,17 @@ importers: specifier: ^0.5.21 version: 0.5.21 storybook: - specifier: 10.5.6 - version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.10 + version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) style-dictionary: - specifier: 5.5.0 - version: 5.5.0(tslib@2.8.1) + specifier: 5.5.2 + version: 5.5.2(tslib@2.8.1) stylelint: specifier: ^17.14.1 version: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + version: 17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-plugin-logical-css: specifier: ^2.1.0 version: 2.1.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) @@ -238,8 +339,8 @@ importers: specifier: ^2.0.4 version: 2.0.4 tdigest: - specifier: ^0.1.2 - version: 0.1.2 + specifier: ^0.1.3 + version: 0.1.3 tinycolor2: specifier: ^1.6.0 version: 1.6.0 @@ -247,11 +348,11 @@ importers: specifier: ^6.0.2 version: 6.0.3 vite: - specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) wait-on: specifier: ^9.1.0 version: 9.1.0(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) @@ -279,7 +380,7 @@ importers: devDependencies: esbuild: specifier: ^0.28.1 - version: 0.28.1 + version: 0.28.2 packages/mousetrap: {} @@ -308,26 +409,26 @@ importers: specifier: ^8.0.1 version: 8.0.1(@babel/core@8.0.1) '@storybook/react': - specifier: 10.5.6 - version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) '@storybook/react-vite': - specifier: 10.5.6 - version: 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: 10.5.10 + version: 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 '@testing-library/react': specifier: 16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: ^19.2.18 version: 19.2.18 '@types/react-dom': - specifier: ^19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': - specifier: ^6.0.5 - version: 6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + specifier: ^6.1.0 + version: 6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -347,11 +448,11 @@ importers: specifier: ^1.0.0 version: 1.0.0(react@19.2.8) storybook: - specifier: 10.5.6 - version: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + specifier: 10.5.10 + version: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) vite-plugin-dts: specifier: ^5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + version: 5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) text-editor: devDependencies: @@ -360,22 +461,22 @@ importers: version: 1.62.1 '@types/node': specifier: ^26.1.2 - version: 26.1.2 + version: 26.4.0 '@vitest/browser': specifier: ^4.1.10 - version: 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + version: 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/coverage-v8': specifier: ^4.1.10 - version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) '@vitest/ui': - specifier: ^4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) canvas: specifier: ^3.2.3 version: 3.2.3 esbuild: specifier: ^0.28.0 - version: 0.28.1 + version: 0.28.2 jsdom: specifier: ^30.0.1 version: 30.0.1(canvas@3.2.3) @@ -387,10 +488,10 @@ importers: version: 3.9.6 vite: specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) packages: @@ -704,8 +805,8 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@emnapi/core@1.11.2': - resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -713,8 +814,8 @@ packages: '@emnapi/core@2.0.0-alpha.3': resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -731,158 +832,158 @@ packages: '@emnapi/wasi-threads@2.0.1': resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1311,106 +1412,109 @@ packages: '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@oxc-resolver/binding-android-arm-eabi@11.24.2': - resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': + resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.24.2': - resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + '@oxc-resolver/binding-android-arm64@11.21.2': + resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.24.2': - resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + '@oxc-resolver/binding-darwin-arm64@11.21.2': + resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.24.2': - resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + '@oxc-resolver/binding-darwin-x64@11.21.2': + resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.24.2': - resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + '@oxc-resolver/binding-freebsd-x64@11.21.2': + resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': - resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': + resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': - resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': + resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': - resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': + resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': - resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': + resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': - resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': + resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': - resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': + resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': - resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': + resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': - resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': + resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': - resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': + resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.24.2': - resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + '@oxc-resolver/binding-linux-x64-musl@11.21.2': + resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.24.2': - resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.24.2': - resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + '@oxc-resolver/binding-wasm32-wasi@11.21.2': + resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': - resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': + resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': - resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': + resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==} cpu: [x64] os: [win32] @@ -1597,36 +1701,72 @@ packages: resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} engines: {node: '>= 10'} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.2.1': resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.2.1': resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.2.1': resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.2.1': resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.2.1': resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1634,6 +1774,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.2.1': resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1641,6 +1788,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.2.1': resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1648,6 +1802,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.1': resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1655,6 +1816,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.1': resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1662,6 +1830,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.2.1': resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1669,12 +1844,25 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.2.1': resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.2.1': resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1685,12 +1873,24 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.1': resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1884,27 +2084,27 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.5.6': - resolution: {integrity: sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==} + '@storybook/addon-docs@10.5.10': + resolution: {integrity: sha512-06JoK3/a7FWI/6GzuidJP9iHp1/Vejboe6lzS1jW+d8ItpecriBt+oXh1VNmUM7i7PjI6pZnet+j51QnLyeOoQ==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 + storybook: ^10.5.10 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-themes@10.5.6': - resolution: {integrity: sha512-fIzu2f6xPh/mtOBUU9TnjSoO3qUIF57AVES9HX4dfhjtbz9lWd+EhNd65nZfmTXxKsS32x/7/aXDTHtuP/ObwQ==} + '@storybook/addon-themes@10.5.10': + resolution: {integrity: sha512-XNTjmIBwJ0TUcIV11STGY6hcynCy6imgYCDZeDaDyNEQsCoYAlY25fYvWNfrz5jOu1MGACiaA/E0ypk03whDdQ==} peerDependencies: - storybook: ^10.5.6 + storybook: ^10.5.10 - '@storybook/addon-vitest@10.5.6': - resolution: {integrity: sha512-oxq7Qi4Vujc8Etoi1TZBurMs4RiKoGnvAOCXePOLglXSJMpy95gEb7iu/hvj8E21lV+vVtQYmFvj9Z7gJeMtdg==} + '@storybook/addon-vitest@10.5.10': + resolution: {integrity: sha512-JNQ9DSkLfxC8qqytBCej91zBExIZ7z97B410U2zgfQPki4HkI9Ffz97a15f5yhVZ79ppIrZ/ssI+WcyWn0ykXQ==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.6 + storybook: ^10.5.10 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -1916,18 +2116,18 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.5.6': - resolution: {integrity: sha512-Ts8EohKPj8okDPCkueeKVN+IRGNpI3LuddsFGupqraRvK6aRWawDKA28uc0PlsLCLWbMkMsGVw+IpFXfmoLJgQ==} + '@storybook/builder-vite@10.5.10': + resolution: {integrity: sha512-O4GgIP0tKLRueom3EmU3OaBUHKjNYj+jkOvmTIkn3PYTiWVkCuHqSKEs4ADvRyaQuLH+peHhFe4JtkNC9KbtrQ==} peerDependencies: - storybook: ^10.5.6 + storybook: ^10.5.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.6': - resolution: {integrity: sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==} + '@storybook/csf-plugin@10.5.10': + resolution: {integrity: sha512-TaCLBrqVEr767+w58QDotDUiCTuE5cyRJuRcDlsKQyUIyGBv+lYD3lu8wBiVCYxgIjB/gu9HmqiC+0yx1rHzaw==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.5.6 + storybook: ^10.5.10 vite: '*' webpack: '*' peerDependenciesMeta: @@ -1948,40 +2148,40 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.6': - resolution: {integrity: sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==} + '@storybook/react-dom-shim@10.5.10': + resolution: {integrity: sha512-rbu62ILo/VE3iXKmu+kWXFpD1H1Lwi0f19q/x7JnDsD2dxKS9w5znLEqPIq2qxpzi/wjjIb2iUP1cRG1d/9W5A==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 + storybook: ^10.5.10 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.6': - resolution: {integrity: sha512-DCTfNZWhQUH4Zf8LDE4zdLn6+C26QNW0KETdnFUkdrqRADlwS6oExtGWC5f/uP/GDbKb9jrGbC+/ap8nWEH/vQ==} + '@storybook/react-vite@10.5.10': + resolution: {integrity: sha512-xOztxefUnqKeuyvcnjspqmlDnER4cExL+liltrpdXLPJVqfFNr9lgM49FyEPajzsUVG9W/vHJWjbaQGGu1UsYQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 + storybook: ^10.5.10 typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.5.6': - resolution: {integrity: sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==} + '@storybook/react@10.5.10': + resolution: {integrity: sha512-4MBV5e1SXIMfPynLHzr+Mp0dwGv/FW1bklWAsS4ynBOAbC98W9p/I9vqBnUctsvE3BJkhzHQQyPwMHL5tTcHVA==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.6 + storybook: ^10.5.10 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -2085,14 +2285,19 @@ packages: '@types/mdx@2.0.14': resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} @@ -2102,35 +2307,38 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@vitejs/plugin-react@6.0.5': - resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + '@vitejs/plugin-react@6.1.0': + resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': optional: true babel-plugin-react-compiler: optional: true + oxc-transform-react: + optional: true - '@vitest/browser-playwright@4.1.10': - resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} peerDependencies: playwright: 1.62.1 - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/browser@4.1.10': - resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2138,11 +2346,11 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2158,22 +2366,28 @@ packages: '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@4.1.10': resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.10': - resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -2181,6 +2395,9 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2375,7 +2592,7 @@ packages: engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} @@ -2607,6 +2824,9 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + colorjs.io@0.7.1: + resolution: {integrity: sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2641,8 +2861,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -3015,8 +3235,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -3102,6 +3322,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3148,9 +3369,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} + eventsource-parser@4.1.0: + resolution: {integrity: sha512-+DHvQ1wLO//MK+1OZgcuXCbZFKgu3YjKPJt7n98rxX8vezL0ni+7s3ZQiM8bJkUEOk7MsuCycrPLj0FzUNf7Og==} + engines: {node: '>=22.12'} expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} @@ -3447,8 +3668,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} hookified@1.15.1: @@ -3495,7 +3716,7 @@ packages: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3997,8 +4218,8 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} - marked@18.0.9: - resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==} + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} engines: {node: '>= 20'} hasBin: true @@ -4110,8 +4331,8 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4255,8 +4476,8 @@ packages: resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.24.2: - resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxc-resolver@11.21.2: + resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==} p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} @@ -4341,6 +4562,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@0.2.1: + resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4400,31 +4624,31 @@ packages: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-local-by-default@4.2.0: resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-scope@3.2.1: resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules-values@4.0.0: resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-modules@9.0.1: resolution: {integrity: sha512-BrSXxWSls23TzqMuplpeMRL5VHnDOLh2H9EiHNTMIdLBFumJcurDIi47TBuvkn9GsoTLAoPjv2wLzAt1wdQ2aQ==} engines: {node: '>=20.6'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-resolve-nested-selector@0.1.6: resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} @@ -4439,7 +4663,7 @@ packages: resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} engines: {node: '>=12.0'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} @@ -4451,8 +4675,12 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@7.0.39: + resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} + engines: {node: '>=6.0.0'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -4584,10 +4812,14 @@ packages: peerDependencies: react: ^19.2.8 - react-error-boundary@6.1.2: - resolution: {integrity: sha512-3DpCr5HVdZ0caUjYE/kIHBEJN0mNP3ZCgf16c48uJ5TbWjorKVp+YG8W3XqlJ7vJAVNw6wNIImyPXmFydwmyng==} + react-error-boundary@6.1.3: + resolution: {integrity: sha512-GnSKpCohFi2nQmJCWwP8O8wub7zexlePvpsejvQr35vS5RTouS1+utTNOmyc540yw5vyOXnSL1rBWsCQDmkyUA==} peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4694,6 +4926,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.61.1: resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4741,130 +4978,125 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass-embedded-all-unknown@1.100.0: - resolution: {integrity: sha512-auFtXY/kwYILmSVjtBDwyj0axcLbYYiffOKWoaXHnI5bsYwiRbBh3EneR1rpbX2ZIZCrwX93i5pxKLTZF/662Q==} + sass-embedded-all-unknown@1.103.1: + resolution: {integrity: sha512-MwX2ap06TbImAqlbnH6EndgBBejk6Eq2QB6Ae2hukCn9wmXf9cRu27um3mz3tyB4oJOOku0xfHqFH/+ckYj6YA==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.100.0: - resolution: {integrity: sha512-W+Ru9JwTnfU0UX3jSZcbqFdtKFMcYdfFwytc57h2DgnqCOIiAqI2E06mABZBZC+r3LwXCBuS5GbXAGeVgvVDkA==} + sass-embedded-android-arm64@1.103.1: + resolution: {integrity: sha512-jBXWMksyz55XeLOWvQs64BqiG5DcqflmMxcvMoA2oHtQT/7XZ02hBn502qpcF/q8Qd+qqG/hU5ccM+hGXrbQZw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.100.0: - resolution: {integrity: sha512-70f3HgX2pFNmzpGQ86n5e6QfWn2fP4QUQGfFQK0P1XH73ZLIzLo2YqygrGKGKeeqtc5eU2Wl1/xQzhzuKnO4kw==} + sass-embedded-android-arm@1.103.1: + resolution: {integrity: sha512-p36WHpsu5HEo2+NVNbI2Nmb8VgVH0xwOQyzghvZhF7ikGCzEVYI5BP28vHUZlCcgj2TUvMEJKI2lV336a+F/sg==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.100.0: - resolution: {integrity: sha512-icU3o0V/uCSytSpf+tX5Lf51BvyQEbLzDUJfUi9etSauYBGHpPKkdtdZH0si4v98phq11Kl8rSV1SggksxF1Hg==} + sass-embedded-android-riscv64@1.103.1: + resolution: {integrity: sha512-rzrH7RNntk0rx+zCE9xOiAIUt37D1cQKvFfcoX0xiePdGbNHBN8DWKaWN9vaCRfbgKCmxPjevzaqFNI/G/8E1A==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.100.0: - resolution: {integrity: sha512-mevF9VQk6gEYByy8+jusaHGmd7Usb2ytX/DsEOd0JtOGCtcf1kh575xJ6OUBDIcJ15uLnbau/0iy1eP6WVBvWA==} + sass-embedded-android-x64@1.103.1: + resolution: {integrity: sha512-EQglAJXJutuIcqgZf7YOwzao2ND6ow/OgqN+cM48/Qaeb6AimYmZiUJmM9FFcmS3Q2n1k6/2OU3l3Hlin7bFpw==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.100.0: - resolution: {integrity: sha512-1PVlYi61POo93IT/FfrG1mc1tAHxeSTyUALF2aOFmXGWjVXr3bQzEQiBGCOvQbj/ix+5hNyXFXcEMEyKvtUJJA==} + sass-embedded-darwin-arm64@1.103.1: + resolution: {integrity: sha512-rlaBeCul8pLbDRKdBAxdDxSBwFU5fbX42ci6CxW2Vbs34jWTMQ72+nE2v2KkGGce+JnYbb7UV6xDP/4uDWWi+w==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.100.0: - resolution: {integrity: sha512-x97o3JnGyImZNCIVs9wQHJUE5QCvmVIKaH1cwrz/5dK7OT1FpeNiW+u9TUomP9hG6Ekjd8EL8NBHpxTfIhdjmg==} + sass-embedded-darwin-x64@1.103.1: + resolution: {integrity: sha512-VSKFfhI//AOct6ppFmAaSH7tlW2LOFECMGs77jIoWf0ZjvGch/3sc9lUTTdIaYIOnTS4qpZck+8OcI2D0G92QA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.100.0: - resolution: {integrity: sha512-Dwjmj8Z6VRy7rAi53JAdEwIyUjpfl7PhpSc2/LpQPQx+aO5Dp7Spaipkax0ufJl1SoDUdchCsM4y/88YaluorQ==} + sass-embedded-linux-arm64@1.103.1: + resolution: {integrity: sha512-rC+80/Xr9svo0ka2Zr/sjD+N1zEHTw2Urm/nsEjaVp5HSH2i22jwhTKTIqie2OLl6ti+qdYnbI0sSfxOYRtgrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: glibc - sass-embedded-linux-arm@1.100.0: - resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} + sass-embedded-linux-arm@1.103.1: + resolution: {integrity: sha512-tJRLPUtBwXHTnBnG7I39gQvCLreuOHLLCMyKHPR1hQ7aFNwiZjADYzKMRQPNMUqti/Os9z+6I7V13FqgQHfuzg==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: glibc - sass-embedded-linux-musl-arm64@1.100.0: - resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} + sass-embedded-linux-musl-arm64@1.103.1: + resolution: {integrity: sha512-jMgG/C/VMo7+ShMFdk0xjJmQmYkj1PbJu/yrQiaQHpuo5pE2G+vnkEgsuM5EM3lTQ4MbMfnf715qy1fkbNPiUQ==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] libc: musl - sass-embedded-linux-musl-arm@1.100.0: - resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} + sass-embedded-linux-musl-arm@1.103.1: + resolution: {integrity: sha512-N8L/kgVzXpnH+8d6ErIzqvb0l8kHODR3OfPaf0Azw6DOLGtEjwDRUbHno+G7iogGzyPOjKsM8OdyqHJj/bpTdw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] libc: musl - sass-embedded-linux-musl-riscv64@1.100.0: - resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} + sass-embedded-linux-musl-riscv64@1.103.1: + resolution: {integrity: sha512-LZm8rEvI6sKU87qPOlODsYeAs7CmWy0B4ShYkjG4OMcGCcV4Ffxn25XW4TFqmruThITQvJ8WT+WGblzdoxYgbA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: musl - sass-embedded-linux-musl-x64@1.100.0: - resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} + sass-embedded-linux-musl-x64@1.103.1: + resolution: {integrity: sha512-rmKzgk4t6RpaDhSefbbnk5yrA4pk1nHlVhJECGay8yMfzK8eglATeY9fzPgt89xCuQk6rkOiy11e+m8tLYOW5Q==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: musl - sass-embedded-linux-riscv64@1.100.0: - resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} + sass-embedded-linux-riscv64@1.103.1: + resolution: {integrity: sha512-xydQmtxla31uMrmN6z+mAogVGdEMDmkUElVq2+udomX/TPZDYJBkApEyJl237M2VLWVv29v1N4U4i2Z4xa7SDA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] libc: glibc - sass-embedded-linux-x64@1.100.0: - resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} + sass-embedded-linux-x64@1.103.1: + resolution: {integrity: sha512-Rln1oWm0MWKzzemOgQWrRFzfVBARwS9qxAidZPpCQHSohzPnWizFkmY1CUJad/YJNsA7sscueMdX63OEvxoM8w==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] libc: glibc - sass-embedded-unknown-all@1.100.0: - resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} + sass-embedded-unknown-all@1.103.1: + resolution: {integrity: sha512-p7kI4US8+n+YguKczXh+4KcodJnk+8cwv+QFdvMaz3OfPrdHV0EnhPsaVDWD9qXb1XXCEdUhpp6vQwGk3eKc3A==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.100.0: - resolution: {integrity: sha512-iE+yxj+hUXwwbqpHkXxgAWTzeRfcWxJ7SSTQEPMk48lwq3oCrWLlz5sQuWHbuTK/i0GKQfROdP+hOmPi89yjUg==} + sass-embedded-win32-arm64@1.103.1: + resolution: {integrity: sha512-/0renOj3SpZGu4TA8CZJ6qZbTxGZk1khkPfiBTmV1uFB40ESYM5VsdXNj76P07bsAjIj9X2fCjTafLQFLSd9Nw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.100.0: - resolution: {integrity: sha512-qI4F8MI7/KYoy9NdjJfhSspG42WPkADSNDvwEV7qWvCSFC83koJssRsKO2/PfY+niZz6BG65Ic/D+A11h959hw==} + sass-embedded-win32-x64@1.103.1: + resolution: {integrity: sha512-fIg60j9u5YlLUU3W8FvySMR4HHIo9R12HHFQTW7njyQ8nXqgR+t+XfCzGJloQka8g583BlAfu31Q76Ljr5o+9g==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.100.0: - resolution: {integrity: sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==} - engines: {node: '>=16.0.0'} - hasBin: true - - sass@1.100.0: - resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==} + sass-embedded@1.103.1: + resolution: {integrity: sha512-UVIuFZPzz+4DXYbHzQ5gKaKkyP2IH3Sc/WoF63N2V7QIfnC2E9UpQAtiKlCVJTfBe43HRgHDBLnXpZkYAEBsNQ==} engines: {node: '>=20.19.0'} hasBin: true - sass@1.102.0: - resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + sass@1.103.1: + resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} engines: {node: '>=20.19.0'} hasBin: true @@ -5035,8 +5267,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.5.6: - resolution: {integrity: sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==} + storybook@10.5.10: + resolution: {integrity: sha512-Rz8k9ejFHsi7lbtJTaxZlhCUz4GkbJIKEoKDjXeLfr/ZhXip73E6keKxW0KH8iGeKiCqHAbJCV4YIQrxTOLiig==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5136,8 +5368,8 @@ packages: stubborn-fs@1.2.5: resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} - style-dictionary@5.5.0: - resolution: {integrity: sha512-AGkOZtAc3OTz99wlzstrmj5OM5BWOW2IbmXD74sf0MXFPi271TGBdywokgd7bS3L0tKOk9M0FR+R9gnbXRSSfg==} + style-dictionary@5.5.2: + resolution: {integrity: sha512-OPXsfLzy+8YZrUmlgOGDmTlykL8xWLvhb70bHUvBKkPCY75eQabQsl+Jd4JO8nC/uuz/6UdikCHxagiBdUugtw==} engines: {node: '>=22.0.0'} hasBin: true @@ -5145,7 +5377,7 @@ packages: resolution: {integrity: sha512-x5DVehzJudcwF0od3sGpgkln2PLLranFE7twwbp7dqDINCyZvwzFkMc6TLhNOvazRiVBJYATQLouJY0xPGB8WA==} engines: {node: '>=20'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 stylelint: ^17.0.0 peerDependenciesMeta: postcss: @@ -5161,7 +5393,7 @@ packages: resolution: {integrity: sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==} engines: {node: '>=20'} peerDependencies: - postcss: ^8.5.10 + postcss: ^8.4.31 stylelint: ^17.0.0 peerDependenciesMeta: postcss: @@ -5248,8 +5480,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tdigest@0.1.2: - resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + tdigest@0.1.3: + resolution: {integrity: sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==} text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -5517,13 +5749,13 @@ packages: vite: optional: true - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5560,20 +5792,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5701,18 +5933,6 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.2: resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} engines: {node: '>=10.0.0'} @@ -6241,7 +6461,7 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@emnapi/core@1.11.2': + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 @@ -6259,7 +6479,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 optional: true @@ -6289,82 +6509,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(supports-color@10.2.2))': @@ -6459,11 +6679,11 @@ snapshots: dependencies: '@swc/helpers': 0.5.23 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 @@ -6628,24 +6848,24 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@microsoft/api-extractor-model@7.32.2(@types/node@26.1.2)': + '@microsoft/api-extractor-model@7.32.2(@types/node@26.4.0)': dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) transitivePeerDependencies: - '@types/node' optional: true - '@microsoft/api-extractor@7.56.2(@types/node@26.1.2)': + '@microsoft/api-extractor@7.56.2(@types/node@26.4.0)': dependencies: - '@microsoft/api-extractor-model': 7.32.2(@types/node@26.1.2) + '@microsoft/api-extractor-model': 7.32.2(@types/node@26.4.0) '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) '@rushstack/rig-package': 0.6.0 - '@rushstack/terminal': 0.21.0(@types/node@26.1.2) - '@rushstack/ts-command-line': 5.2.0(@types/node@26.1.2) + '@rushstack/terminal': 0.21.0(@types/node@26.4.0) + '@rushstack/ts-command-line': 5.2.0(@types/node@26.4.0) diff: 8.0.4 lodash: 4.18.1 minimatch: 10.2.5 @@ -6668,10 +6888,10 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 '@tybys/wasm-util': 0.10.3 optional: true @@ -6769,67 +6989,70 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.142.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.24.2': + '@oxc-project/types@0.142.0': optional: true - '@oxc-resolver/binding-android-arm64@11.24.2': + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.21.2': optional: true - '@oxc-resolver/binding-darwin-arm64@11.24.2': + '@oxc-resolver/binding-android-arm64@11.21.2': optional: true - '@oxc-resolver/binding-darwin-x64@11.24.2': + '@oxc-resolver/binding-darwin-arm64@11.21.2': optional: true - '@oxc-resolver/binding-freebsd-x64@11.24.2': + '@oxc-resolver/binding-darwin-x64@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + '@oxc-resolver/binding-freebsd-x64@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.24.2': + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.24.2': + '@oxc-resolver/binding-linux-x64-musl@11.21.2': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.24.2': + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.21.2': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': optional: true '@parcel/watcher-android-arm64@2.6.0': @@ -6960,42 +7183,81 @@ snapshots: '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + '@rolldown/binding-android-arm64@1.2.1': optional: true + '@rolldown/binding-android-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-arm64@1.2.1': optional: true + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-x64@1.2.1': optional: true + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + '@rolldown/binding-freebsd-x64@1.2.1': optional: true + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-musl@1.2.1': optional: true + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + '@rolldown/binding-openharmony-arm64@1.2.1': optional: true + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + '@rolldown/binding-wasm32-wasi@1.2.1': dependencies: '@emnapi/core': 2.0.0-alpha.3 @@ -7006,9 +7268,15 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.61.1)': @@ -7096,7 +7364,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/node-core-library@5.19.1(@types/node@26.1.2)': + '@rushstack/node-core-library@5.19.1(@types/node@26.4.0)': dependencies: ajv: 8.20.0 ajv-draft-04: 1.0.0(ajv@8.20.0) @@ -7107,12 +7375,12 @@ snapshots: resolve: 1.22.12 semver: 7.5.4 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true - '@rushstack/problem-matcher@0.1.1(@types/node@26.1.2)': + '@rushstack/problem-matcher@0.1.1(@types/node@26.4.0)': optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true '@rushstack/rig-package@0.6.0': @@ -7121,18 +7389,18 @@ snapshots: strip-json-comments: 3.1.1 optional: true - '@rushstack/terminal@0.21.0(@types/node@26.1.2)': + '@rushstack/terminal@0.21.0(@types/node@26.4.0)': dependencies: - '@rushstack/node-core-library': 5.19.1(@types/node@26.1.2) - '@rushstack/problem-matcher': 0.1.1(@types/node@26.1.2) + '@rushstack/node-core-library': 5.19.1(@types/node@26.4.0) + '@rushstack/problem-matcher': 0.1.1(@types/node@26.4.0) supports-color: 8.1.1 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 optional: true - '@rushstack/ts-command-line@5.2.0(@types/node@26.1.2)': + '@rushstack/ts-command-line@5.2.0(@types/node@26.4.0)': dependencies: - '@rushstack/terminal': 0.21.0(@types/node@26.1.2) + '@rushstack/terminal': 0.21.0(@types/node@26.4.0) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -7149,15 +7417,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/addon-docs@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) - '@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 optionalDependencies: '@types/react': 19.2.18 @@ -7168,43 +7436,43 @@ snapshots: - vite - webpack - '@storybook/addon-themes@10.5.6(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/addon-themes@10.5.10(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - '@storybook/addon-vitest@10.5.6(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.10)': + '@storybook/addon-vitest@10.5.10(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vitest@4.1.11)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/runner': 4.1.10 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/builder-vite@10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@storybook/csf-plugin': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + '@storybook/csf-plugin': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/csf-plugin@10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) '@storybook/global@5.0.0': {} @@ -7212,30 +7480,39 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/react-dom-shim@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': + '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-dom-shim@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) + + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@storybook/react': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 - react-docgen: 8.0.3(supports-color@10.2.2) + react-docgen: 8.0.3(supports-color@5.5.0) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7246,21 +7523,21 @@ snapshots: - supports-color - webpack - '@storybook/react-vite@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@rollup/pluginutils': 5.4.0(rollup@4.61.1) - '@storybook/builder-vite': 10.5.6(esbuild@0.28.1)(rollup@4.61.1)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@storybook/react': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3) + '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.61.1)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@storybook/react': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 - react-docgen: 8.0.3(supports-color@5.5.0) + react-docgen: 8.0.3(supports-color@10.2.2) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7271,15 +7548,15 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': + '@storybook/react@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 - react-docgen: 8.0.3(supports-color@10.2.2) + react-docgen: 8.0.3(supports-color@5.5.0) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -7287,18 +7564,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react@10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@5.5.0)(typescript@6.0.3)': + '@storybook/react@10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) + '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8)) react: 19.2.8 - react-docgen: 8.0.3(supports-color@5.5.0) + react-docgen: 8.0.3(supports-color@10.2.2) react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) + storybook: 10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7327,7 +7604,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 @@ -7335,13 +7612,13 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 - '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.5.0(tslib@2.8.1))': + '@tokens-studio/sd-transforms@2.0.3(style-dictionary@5.5.2(tslib@2.8.1))': dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/postcss-calc-ast-parser': 0.1.6 @@ -7349,7 +7626,7 @@ snapshots: colorjs.io: 0.5.2 expr-eval-fork: 3.0.3 is-mergeable-object: 1.1.1 - style-dictionary: 5.5.0(tslib@2.8.1) + style-dictionary: 5.5.2(tslib@2.8.1) '@tokens-studio/tokenscript-interpreter@0.26.0': dependencies: @@ -7412,13 +7689,18 @@ snapshots: '@types/mdx@2.0.14': {} - '@types/node@26.1.2': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 + optional: true + + '@types/react-dom@19.2.5(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 '@types/react@19.2.18': dependencies: @@ -7428,47 +7710,47 @@ snapshots: '@types/triple-beam@1.3.5': {} - '@vitejs/plugin-react@6.0.5(babel-plugin-react-compiler@1.0.0)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@vitejs/plugin-react@6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) optionalDependencies: babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) playwright: 1.62.1 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10)': + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@vitest/utils': 4.1.10 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - ws: 8.21.1 + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + ws: 8.21.2 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -7477,9 +7759,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) '@vitest/expect@3.2.4': dependencies: @@ -7489,22 +7771,22 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -7513,16 +7795,27 @@ snapshots: '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 + optional: true + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 '@vitest/runner@4.1.10': dependencies: '@vitest/utils': 4.1.10 pathe: 2.0.3 + optional: true - '@vitest/snapshot@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 @@ -7530,18 +7823,18 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.10(vitest@4.1.10)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 fflate: 0.8.3 flatted: 3.4.4 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) '@vitest/utils@3.2.4': dependencies: @@ -7554,6 +7847,13 @@ snapshots: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + optional: true + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 '@volar/language-core@2.4.28': dependencies: @@ -7773,13 +8073,13 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.5.4(postcss@8.5.25): + autoprefixer@10.5.4(postcss@8.5.26): dependencies: browserslist: 4.28.7 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -8029,6 +8329,8 @@ snapshots: colorjs.io@0.5.2: {} + colorjs.io@0.7.1: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -8061,7 +8363,7 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -8493,34 +8795,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -8704,7 +9006,7 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.0: {} + eventsource-parser@4.1.0: {} expand-template@2.0.3: {} @@ -9030,7 +9332,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - highlight.js@11.11.1: {} + highlight.js@11.12.0: {} hookified@1.15.1: {} @@ -9073,9 +9375,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.25): + icss-utils@5.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 ieee754@1.2.1: {} @@ -9538,8 +9840,8 @@ snapshots: magicast@0.5.4: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: @@ -9548,7 +9850,7 @@ snapshots: map-stream@0.0.7: {} - marked@18.0.9: {} + marked@18.0.11: {} math-intrinsics@1.1.0: {} @@ -9639,7 +9941,7 @@ snapshots: mustache@4.2.0: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} napi-build-utils@2.0.0: {} @@ -9823,27 +10125,27 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.24.2: + oxc-resolver@11.21.2: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.24.2 - '@oxc-resolver/binding-android-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-x64': 11.24.2 - '@oxc-resolver/binding-freebsd-x64': 11.24.2 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 - '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-musl': 11.24.2 - '@oxc-resolver/binding-openharmony-arm64': 11.24.2 - '@oxc-resolver/binding-wasm32-wasi': 11.24.2 - '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 - '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + '@oxc-resolver/binding-android-arm-eabi': 11.21.2 + '@oxc-resolver/binding-android-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-x64': 11.21.2 + '@oxc-resolver/binding-freebsd-x64': 11.21.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-musl': 11.21.2 + '@oxc-resolver/binding-openharmony-arm64': 11.21.2 + '@oxc-resolver/binding-wasm32-wasi': 11.21.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.2 p-limit@3.1.0: dependencies: @@ -9917,6 +10219,8 @@ snapshots: pend@1.2.0: {} + picocolors@0.2.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -9958,52 +10262,52 @@ snapshots: postcss-clean@1.2.2: dependencies: clean-css: 4.2.4 - postcss: 8.5.25 + postcss: 7.0.39 postcss-media-query-parser@0.2.3: {} - postcss-modules-extract-imports@3.1.0(postcss@8.5.25): + postcss-modules-extract-imports@3.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-modules-local-by-default@4.2.0(postcss@8.5.25): + postcss-modules-local-by-default@4.2.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.25): + postcss-modules-scope@3.2.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.4 - postcss-modules-values@4.0.0(postcss@8.5.25): + postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: - icss-utils: 5.1.0(postcss@8.5.25) - postcss: 8.5.25 + icss-utils: 5.1.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-modules@9.0.1(postcss@8.5.25): + postcss-modules@9.0.1(postcss@8.5.26): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.25) + icss-utils: 5.1.0(postcss@8.5.26) lodash.camelcase: 4.3.0 - postcss: 8.5.25 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.25) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.25) - postcss-modules-scope: 3.2.1(postcss@8.5.25) - postcss-modules-values: 4.0.0(postcss@8.5.25) + postcss: 8.5.26 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.26) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.26) + postcss-modules-scope: 3.2.1(postcss@8.5.26) + postcss-modules-values: 4.0.0(postcss@8.5.26) string-hash: 1.1.3 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.25): + postcss-safe-parser@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-scss@4.0.9(postcss@8.5.25): + postcss-scss@4.0.9(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser@7.1.4: dependencies: @@ -10014,9 +10318,14 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.25: + postcss@7.0.39: dependencies: - nanoid: 3.3.16 + picocolors: 0.2.1 + source-map: 0.6.1 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -10189,9 +10498,11 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-error-boundary@6.1.2(react@19.2.8): + react-error-boundary@6.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 react-is@16.13.1: {} @@ -10335,6 +10646,28 @@ snapshots: '@rolldown/binding-wasm32-wasi': 1.2.1 '@rolldown/binding-win32-arm64-msvc': 1.2.1 '@rolldown/binding-win32-x64-msvc': 1.2.1 + optional: true + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 rollup@4.61.1: dependencies: @@ -10417,103 +10750,94 @@ snapshots: safer-buffer@2.1.2: {} - sass-embedded-all-unknown@1.100.0: + sass-embedded-all-unknown@1.103.1: dependencies: - sass: 1.100.0 + sass: 1.103.1 optional: true - sass-embedded-android-arm64@1.100.0: + sass-embedded-android-arm64@1.103.1: optional: true - sass-embedded-android-arm@1.100.0: + sass-embedded-android-arm@1.103.1: optional: true - sass-embedded-android-riscv64@1.100.0: + sass-embedded-android-riscv64@1.103.1: optional: true - sass-embedded-android-x64@1.100.0: + sass-embedded-android-x64@1.103.1: optional: true - sass-embedded-darwin-arm64@1.100.0: + sass-embedded-darwin-arm64@1.103.1: optional: true - sass-embedded-darwin-x64@1.100.0: + sass-embedded-darwin-x64@1.103.1: optional: true - sass-embedded-linux-arm64@1.100.0: + sass-embedded-linux-arm64@1.103.1: optional: true - sass-embedded-linux-arm@1.100.0: + sass-embedded-linux-arm@1.103.1: optional: true - sass-embedded-linux-musl-arm64@1.100.0: + sass-embedded-linux-musl-arm64@1.103.1: optional: true - sass-embedded-linux-musl-arm@1.100.0: + sass-embedded-linux-musl-arm@1.103.1: optional: true - sass-embedded-linux-musl-riscv64@1.100.0: + sass-embedded-linux-musl-riscv64@1.103.1: optional: true - sass-embedded-linux-musl-x64@1.100.0: + sass-embedded-linux-musl-x64@1.103.1: optional: true - sass-embedded-linux-riscv64@1.100.0: + sass-embedded-linux-riscv64@1.103.1: optional: true - sass-embedded-linux-x64@1.100.0: + sass-embedded-linux-x64@1.103.1: optional: true - sass-embedded-unknown-all@1.100.0: + sass-embedded-unknown-all@1.103.1: dependencies: - sass: 1.100.0 + sass: 1.103.1 optional: true - sass-embedded-win32-arm64@1.100.0: + sass-embedded-win32-arm64@1.103.1: optional: true - sass-embedded-win32-x64@1.100.0: + sass-embedded-win32-x64@1.103.1: optional: true - sass-embedded@1.100.0: + sass-embedded@1.103.1: dependencies: '@bufbuild/protobuf': 2.12.1 - colorjs.io: 0.5.2 + colorjs.io: 0.7.1 immutable: 5.1.9 rxjs: 7.8.2 supports-color: 8.1.1 sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.100.0 - sass-embedded-android-arm: 1.100.0 - sass-embedded-android-arm64: 1.100.0 - sass-embedded-android-riscv64: 1.100.0 - sass-embedded-android-x64: 1.100.0 - sass-embedded-darwin-arm64: 1.100.0 - sass-embedded-darwin-x64: 1.100.0 - sass-embedded-linux-arm: 1.100.0 - sass-embedded-linux-arm64: 1.100.0 - sass-embedded-linux-musl-arm: 1.100.0 - sass-embedded-linux-musl-arm64: 1.100.0 - sass-embedded-linux-musl-riscv64: 1.100.0 - sass-embedded-linux-musl-x64: 1.100.0 - sass-embedded-linux-riscv64: 1.100.0 - sass-embedded-linux-x64: 1.100.0 - sass-embedded-unknown-all: 1.100.0 - sass-embedded-win32-arm64: 1.100.0 - sass-embedded-win32-x64: 1.100.0 + sass-embedded-all-unknown: 1.103.1 + sass-embedded-android-arm: 1.103.1 + sass-embedded-android-arm64: 1.103.1 + sass-embedded-android-riscv64: 1.103.1 + sass-embedded-android-x64: 1.103.1 + sass-embedded-darwin-arm64: 1.103.1 + sass-embedded-darwin-x64: 1.103.1 + sass-embedded-linux-arm: 1.103.1 + sass-embedded-linux-arm64: 1.103.1 + sass-embedded-linux-musl-arm: 1.103.1 + sass-embedded-linux-musl-arm64: 1.103.1 + sass-embedded-linux-musl-riscv64: 1.103.1 + sass-embedded-linux-musl-x64: 1.103.1 + sass-embedded-linux-riscv64: 1.103.1 + sass-embedded-linux-x64: 1.103.1 + sass-embedded-unknown-all: 1.103.1 + sass-embedded-win32-arm64: 1.103.1 + sass-embedded-win32-x64: 1.103.1 - sass@1.100.0: - dependencies: - chokidar: 5.0.0 - immutable: 5.1.9 - source-map-js: 1.2.1 - optionalDependencies: - '@parcel/watcher': 2.6.0 - optional: true - - sass@1.102.0: + sass@1.103.1: dependencies: chokidar: 5.0.0 immutable: 5.1.9 @@ -10704,7 +11028,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.5.6(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): + storybook@10.5.10(@types/react@19.2.18)(prettier@3.9.6)(react@19.2.8): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) @@ -10714,11 +11038,11 @@ snapshots: '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 - esbuild: 0.28.1 + esbuild: 0.28.2 jsonc-parser: 3.3.1 open: 10.2.0 oxc-parser: 0.127.0 - oxc-resolver: 11.24.2 + oxc-resolver: 11.21.2 recast: 0.23.19 semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.8) @@ -10844,7 +11168,7 @@ snapshots: stubborn-fs@1.2.5: {} - style-dictionary@5.5.0(tslib@2.8.1): + style-dictionary@5.5.2(tslib@2.8.1): dependencies: '@bundled-es-modules/deepmerge': 4.3.2 '@bundled-es-modules/glob': 13.0.6 @@ -10862,26 +11186,26 @@ snapshots: transitivePeerDependencies: - tslib - stylelint-config-recommended-scss@17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-recommended-scss@17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.25) + postcss-scss: 4.0.9(postcss@8.5.26) stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) stylelint-config-recommended: 18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-scss: 7.2.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 stylelint-config-recommended@18.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: stylelint: 17.14.1(supports-color@5.5.0)(typescript@6.0.3) - stylelint-config-recommended-scss: 17.0.1(postcss@8.5.25)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) + stylelint-config-recommended-scss: 17.0.1(postcss@8.5.26)(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) stylelint-config-standard: 40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)) optionalDependencies: - postcss: 8.5.25 + postcss: 8.5.26 stylelint-config-standard@40.0.0(stylelint@17.14.1(supports-color@5.5.0)(typescript@6.0.3)): dependencies: @@ -10935,8 +11259,8 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.25 - postcss-safe-parser: 7.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-safe-parser: 7.0.1(postcss@8.5.26) postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 string-width: 8.2.2 @@ -11032,7 +11356,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tdigest@0.1.2: + tdigest@0.1.3: dependencies: bintrees: 1.0.2 @@ -11198,7 +11522,7 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.61.1) '@volar/typescript': 2.4.28(typescript@6.0.3) @@ -11210,11 +11534,11 @@ snapshots: typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) - esbuild: 0.28.1 + '@microsoft/api-extractor': 7.56.2(@types/node@26.4.0) + esbuild: 0.28.2 rolldown: 1.2.1 rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - supports-color @@ -11276,13 +11600,13 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.1.2))(esbuild@0.28.1)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.2(@types/node@26.4.0))(esbuild@0.28.2)(rolldown@1.2.1)(rollup@4.61.1)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) optionalDependencies: - '@microsoft/api-extractor': 7.56.2(@types/node@26.1.2) + '@microsoft/api-extractor': 7.56.2(@types/node@26.4.0) rollup: 4.61.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -11292,29 +11616,29 @@ snapshots: - typescript - webpack - vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0): + vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.2.1 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 - esbuild: 0.28.1 + '@types/node': 26.4.0 + esbuild: 0.28.2 fsevents: 2.3.3 - sass: 1.102.0 - sass-embedded: 1.100.0 + sass: 1.103.1 + sass-embedded: 1.103.1 - vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(canvas@3.2.3))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -11326,13 +11650,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 - '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.102.0))(vitest@4.1.10) - '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - '@vitest/ui': 4.1.10(vitest@4.1.10) + '@types/node': 26.4.0 + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(sass-embedded@1.103.1)(sass@1.103.1))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 30.0.1(canvas@3.2.3) transitivePeerDependencies: - msw @@ -11485,8 +11809,6 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.21.1: {} - ws@8.21.2: {} wsl-utils@0.1.0: diff --git a/frontend/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 49622d9710..d6d921ea8a 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -282,8 +282,7 @@ [code value] (if (some? value) (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code))) - (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))) - nil) + (throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))))) (defn not-valid [plugin-id code value] diff --git a/frontend/text-editor/package.json b/frontend/text-editor/package.json index b916b5b12e..af4dd7fde3 100644 --- a/frontend/text-editor/package.json +++ b/frontend/text-editor/package.json @@ -19,7 +19,7 @@ "@types/node": "^26.1.2", "@vitest/browser": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", - "@vitest/ui": "^4.1.10", + "@vitest/ui": "^4.1.11", "canvas": "^3.2.3", "esbuild": "^0.28.0", "jsdom": "^30.0.1", @@ -28,5 +28,5 @@ "vite": "^8.2.0", "vitest": "^4.1.10" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/library/package.json b/library/package.json index 06c0763609..f22f9aea27 100644 --- a/library/package.json +++ b/library/package.json @@ -3,7 +3,7 @@ "version": "1.2.0-RC1", "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "type": "module", "repository": { "type": "git", @@ -36,7 +36,7 @@ "devDependencies": { "@types/node": "^26.1.2", "@zip.js/zip.js": "2.8.34", - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "date-fns": "^4.4.0", "nodemon": "^3.1.14", "source-map-support": "^0.5.21" diff --git a/library/pnpm-lock.yaml b/library/pnpm-lock.yaml index 9d0024fe7e..6fa97dcff3 100644 --- a/library/pnpm-lock.yaml +++ b/library/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -18,8 +119,8 @@ importers: specifier: 2.8.34 version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -82,8 +183,8 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -304,7 +405,7 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 diff --git a/mcp/package.json b/mcp/package.json index f40e0e87ce..2b0046f3b0 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -23,9 +23,9 @@ "type": "git", "url": "https://github.com/penpot/penpot.git" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "devDependencies": { - "concurrently": "^10.0.4", + "concurrently": "^10.0.5", "prettier": "^3.9.6" } } diff --git a/mcp/packages/common/package.json b/mcp/packages/common/package.json index 3af6239ff4..4c82e9796a 100644 --- a/mcp/packages/common/package.json +++ b/mcp/packages/common/package.json @@ -4,7 +4,7 @@ "description": "Shared type definitions and interfaces for Penpot MCP", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "scripts": { "build": "tsc --build --clean && tsc --build", "watch": "tsc --watch", diff --git a/mcp/packages/plugin/vite.release.config.ts b/mcp/packages/plugin/vite.release.config.ts index 7156a2f85c..8449280032 100644 --- a/mcp/packages/plugin/vite.release.config.ts +++ b/mcp/packages/plugin/vite.release.config.ts @@ -1,5 +1,5 @@ import { defineConfig, mergeConfig } from "vite"; -import baseConfig from "./vite.config"; +import baseConfig from "./vite.config.ts"; export default mergeConfig( baseConfig, diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 05f48fc974..e675c2e7c8 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -24,7 +24,7 @@ ], "author": "", "license": "MIT", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "class-transformer": "^0.5.1", diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index 4d49e451b3..706069eee7 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -12,8 +113,8 @@ importers: .: devDependencies: concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -41,16 +142,16 @@ importers: version: 6.0.3 vite: specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5) + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5)) + version: 0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)) packages/server: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -65,7 +166,7 @@ importers: version: 6.0.0(supports-color@10.2.2) js-yaml: specifier: ^5.2.3 - version: 5.2.3 + version: 5.4.1 nrepl-client: specifier: ^0.3.0 version: 0.3.0 @@ -83,10 +184,10 @@ importers: version: 0.2.2 sharp: specifier: ^0.35.3 - version: 0.35.3(@types/node@26.1.2) + version: 0.35.4(@types/node@26.4.0) ws: specifier: ^8.21.1 - version: 8.21.1 + version: 8.21.3 zod: specifier: ^4.4.3 version: 4.4.3 @@ -102,7 +203,7 @@ importers: version: 4.0.9 '@types/node': specifier: ^26.1.2 - version: 26.1.2 + version: 26.4.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -111,13 +212,13 @@ importers: version: 10.1.0 esbuild: specifier: ^0.28.1 - version: 0.28.1 + version: 0.28.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@26.1.2)(typescript@6.0.3) + version: 10.9.2(@types/node@26.4.0)(typescript@6.0.3) tsx: specifier: ^4.23.5 - version: 4.23.5 + version: 4.23.12 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -134,164 +235,164 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@hono/node-server@2.0.12': - resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -300,160 +401,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -471,8 +572,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -481,8 +582,8 @@ packages: '@cfworker/json-schema': optional: true - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} '@penpot/plugin-styles@1.5.0': resolution: {integrity: sha512-rwXFBRPd0IZs3ens+eRT8oA5WqkQgF6xlW6fCfH7U1NHRjej9HH7vTGXmfjoIVml6Xx1sbyYHZVxkQC8Ub8+zA==} @@ -493,92 +594,98 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm64@1.2.2': - resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.2': - resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.2': - resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.2': - resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.2': - resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.2': - resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.2': - resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.2': - resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.2': - resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.2': - resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.2': - resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.2': - resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.2': - resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.2': - resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -592,8 +699,8 @@ packages: '@seahax/semaphore@0.5.1': resolution: {integrity: sha512-q6SXYYbE6X+LDcq2h2yCgE+pCWJumNP3XCZkztdG4S4tiig9akMZGp8TsfU/EIRcHWPdnQ3BA8/NAvdDYdF/NQ==} - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + '@tsconfig/node10@1.0.13': + resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} '@tsconfig/node12@1.0.11': resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} @@ -610,8 +717,8 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -622,8 +729,8 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -647,12 +754,12 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -725,21 +832,21 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} cookie-signature@1.2.2: @@ -823,8 +930,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -843,16 +950,16 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -861,8 +968,8 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-copy@4.0.4: + resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -870,8 +977,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -933,16 +1040,16 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono@4.12.34: - resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} inherits@2.0.4: @@ -952,8 +1059,8 @@ packages: resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} engines: {node: '>=20.0.0'} - ip-address@10.4.0: - resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -966,15 +1073,15 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@5.2.3: - resolution: {integrity: sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true json-schema-traverse@1.0.0: @@ -983,8 +1090,8 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - libphonenumber-js@1.13.7: - resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==} + libphonenumber-js@1.13.11: + resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} @@ -1067,8 +1174,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -1089,14 +1196,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} nrepl-client@0.3.0: resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} @@ -1134,8 +1241,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} pino-abstract-transport@3.0.0: @@ -1161,8 +1268,8 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prettier@3.9.6: @@ -1170,16 +1277,13 @@ packages: engines: {node: '>=14'} hasBin: true - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1190,8 +1294,8 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -1216,8 +1320,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.2.2: - resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1254,8 +1358,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1291,9 +1395,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1361,15 +1462,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.5: - resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -1399,17 +1496,16 @@ packages: vite-live-preview@0.4.0: resolution: {integrity: sha512-Qz8kr0kixXwnQl+zLPZX66OjajN4jnVnDwhNToJsO6TTboUtBo8pEmRuc0iBmkwW9lXR8mOeMu+QtxFkXBcHYg==} - hasBin: true peerDependencies: vite: '>=5.4.0' - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1458,20 +1554,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1519,192 +1603,192 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@2.0.12(hono@4.12.34)': + '@hono/node-server@2.1.1(hono@4.13.5)': dependencies: - hono: 4.12.34 + hono: 4.13.5 '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@ioredis/commands@2.0.0': {} @@ -1718,20 +1802,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: - '@hono/node-server': 2.0.12(hono@4.12.34) + '@hono/node-server': 2.1.1(hono@4.13.5) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 express: 5.2.1(supports-color@10.2.2) - express-rate-limit: 8.5.2(express@5.2.1(supports-color@10.2.2)) - hono: 4.12.34 - jose: 6.2.3 + express-rate-limit: 8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.13.5 + jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -1740,7 +1824,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@oxc-project/types@0.142.0': {} + '@oxc-project/types@0.147.0': {} '@penpot/plugin-styles@1.5.0': {} @@ -1748,46 +1832,49 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm64@1.2.2': + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true - '@rolldown/binding-darwin-arm64@1.2.2': + '@rolldown/binding-android-arm64@1.2.6': optional: true - '@rolldown/binding-darwin-x64@1.2.2': + '@rolldown/binding-darwin-arm64@1.2.6': optional: true - '@rolldown/binding-freebsd-x64@1.2.2': + '@rolldown/binding-darwin-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + '@rolldown/binding-freebsd-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.2': + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.2': + '@rolldown/binding-linux-arm64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.2': + '@rolldown/binding-linux-arm64-musl@1.2.6': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.2': + '@rolldown/binding-linux-ppc64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.2': + '@rolldown/binding-linux-s390x-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-musl@1.2.2': + '@rolldown/binding-linux-x64-gnu@1.2.6': optional: true - '@rolldown/binding-openharmony-arm64@1.2.2': + '@rolldown/binding-linux-x64-musl@1.2.6': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.2': + '@rolldown/binding-openharmony-arm64@1.2.6': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.2': + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1796,7 +1883,7 @@ snapshots: '@seahax/semaphore@0.5.1': {} - '@tsconfig/node10@1.0.12': {} + '@tsconfig/node10@1.0.13': {} '@tsconfig/node12@1.0.11': {} @@ -1807,15 +1894,15 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.1.2 + '@types/node': 26.4.0 '@types/connect@3.4.38': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 - '@types/express-serve-static-core@5.1.1': + '@types/express-serve-static-core@5.1.3': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1823,14 +1910,14 @@ snapshots: '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 + '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} - '@types/node@26.1.2': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 @@ -1840,29 +1927,29 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.1.2 + '@types/node': 26.4.0 '@types/validator@13.15.10': {} '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 - acorn-walk@8.3.4: + acorn-walk@8.3.5: dependencies: - acorn: 8.15.0 + acorn: 8.18.0 - acorn@8.15.0: {} + acorn@8.18.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -1871,7 +1958,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1890,10 +1977,10 @@ snapshots: body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 - content-type: 2.0.0 + content-type: 2.1.0 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 on-finished: 2.4.1 qs: 6.15.3 raw-body: 3.0.2 @@ -1920,7 +2007,7 @@ snapshots: class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.13.7 + libphonenumber-js: 1.13.11 validator: 13.15.35 cliui@9.0.1: @@ -1933,7 +2020,7 @@ snapshots: colorette@2.0.20: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -1942,11 +2029,11 @@ snapshots: tree-kill: 1.2.2 yargs: 18.0.0 - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} - content-type@2.0.0: {} + content-type@2.1.0: {} cookie-signature@1.2.2: {} @@ -2010,34 +2097,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -2047,22 +2134,25 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 - express-rate-limit@8.5.2(express@5.2.1(supports-color@10.2.2)): + express-rate-limit@8.6.2(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): dependencies: + debug: 4.4.3(supports-color@10.2.2) express: 5.2.1(supports-color@10.2.2) - ip-address: 10.4.0 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 body-parser: 2.3.0(supports-color@10.2.2) - content-disposition: 1.0.1 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -2081,27 +2171,27 @@ snapshots: parseurl: 1.3.3 proxy-addr: 2.0.7 qs: 6.15.3 - range-parser: 1.2.1 + range-parser: 1.3.0 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) serve-static: 2.2.1(supports-color@10.2.2) statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color - fast-copy@4.0.2: {} + fast-copy@4.0.4: {} fast-deep-equal@3.1.3: {} fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.6: {} - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 finalhandler@2.1.1(supports-color@10.2.2): dependencies: @@ -2155,7 +2245,7 @@ snapshots: help-me@5.0.0: {} - hono@4.12.34: {} + hono@4.13.5: {} http-errors@2.0.1: dependencies: @@ -2165,7 +2255,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -2182,7 +2272,7 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.4.0: {} + ip-address@10.5.0: {} ipaddr.js@1.9.1: {} @@ -2190,11 +2280,11 @@ snapshots: isexe@2.0.0: {} - jose@6.2.3: {} + jose@6.2.10: {} joycon@3.1.1: {} - js-yaml@5.2.3: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 @@ -2202,7 +2292,7 @@ snapshots: json-schema-typed@8.0.2: {} - libphonenumber-js@1.13.7: {} + libphonenumber-js@1.13.11: {} lightningcss-android-arm64@1.33.0: optional: true @@ -2257,7 +2347,7 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -2271,9 +2361,11 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 nrepl-client@0.3.0: dependencies: @@ -2302,7 +2394,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} pino-abstract-transport@3.0.0: dependencies: @@ -2317,16 +2409,16 @@ snapshots: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.2 + fast-copy: 4.0.4 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 minimist: 1.2.8 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 - pump: 3.0.3 + pump: 3.0.4 secure-json-parse: 4.1.0 - sonic-boom: 4.2.0 + sonic-boom: 4.2.1 strip-json-comments: 5.0.3 pino-std-serializers@7.1.0: {} @@ -2338,7 +2430,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.0.0 + process-warning: 5.1.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -2347,26 +2439,21 @@ snapshots: pkce-challenge@5.0.1: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 prettier@3.9.6: {} - process-warning@5.0.0: {} + process-warning@5.1.0: {} proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -2379,13 +2466,13 @@ snapshots: quick-format-unescaped@4.0.4: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 real-require@0.2.0: {} @@ -2398,25 +2485,26 @@ snapshots: require-from-string@2.0.2: {} - rolldown@1.2.2: + rolldown@1.2.6: dependencies: - '@oxc-project/types': 0.142.0 + '@oxc-project/types': 0.147.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.2 - '@rolldown/binding-darwin-arm64': 1.2.2 - '@rolldown/binding-darwin-x64': 1.2.2 - '@rolldown/binding-freebsd-x64': 1.2.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 - '@rolldown/binding-linux-arm64-gnu': 1.2.2 - '@rolldown/binding-linux-arm64-musl': 1.2.2 - '@rolldown/binding-linux-ppc64-gnu': 1.2.2 - '@rolldown/binding-linux-s390x-gnu': 1.2.2 - '@rolldown/binding-linux-x64-gnu': 1.2.2 - '@rolldown/binding-linux-x64-musl': 1.2.2 - '@rolldown/binding-openharmony-arm64': 1.2.2 - '@rolldown/binding-win32-arm64-msvc': 1.2.2 - '@rolldown/binding-win32-x64-msvc': 1.2.2 + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 router@2.2.0(supports-color@10.2.2): dependencies: @@ -2451,7 +2539,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -2467,38 +2555,38 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@26.1.2): + sharp@0.35.4(@types/node@26.4.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.1.2 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.4.0 shebang-command@2.0.0: dependencies: @@ -2536,10 +2624,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -2572,23 +2656,23 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 toidentifier@1.0.1: {} tree-kill@1.2.2: {} - ts-node@10.9.2(@types/node@26.1.2)(typescript@6.0.3): + ts-node@10.9.2(@types/node@26.4.0)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 + '@tsconfig/node10': 1.0.13 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 26.1.2 - acorn: 8.15.0 - acorn-walk: 8.3.4 + '@types/node': 26.4.0 + acorn: 8.18.0 + acorn-walk: 8.3.5 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.4 @@ -2599,22 +2683,16 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.5: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - type-is@2.1.0: dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 + content-type: 2.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typescript@6.0.3: {} @@ -2629,31 +2707,31 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5)): + vite-live-preview@0.4.0(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5) - ws: 8.21.0 + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(tsx@4.23.5): + vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.2.2 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 - esbuild: 0.28.1 + '@types/node': 26.4.0 + esbuild: 0.28.2 fsevents: 2.3.3 - tsx: 4.23.5 + tsx: 4.23.12 which@2.0.2: dependencies: @@ -2667,9 +2745,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} - - ws@8.21.1: {} + ws@8.21.3: {} y18n@5.0.8: {} diff --git a/mcp/pnpm-workspace.yaml b/mcp/pnpm-workspace.yaml index 2425fd761c..eb1c390c2f 100644 --- a/mcp/pnpm-workspace.yaml +++ b/mcp/pnpm-workspace.yaml @@ -1,8 +1,3 @@ -# auto-confirm node_modules purge when pnpm detects an incompatible modules -# directory (e.g. after a store location or pnpm major version change), -# preventing the interactive prompt from blocking bootstrap -confirmModulesPurge: false - allowBuilds: esbuild: true sharp: false @@ -19,9 +14,9 @@ minimumReleaseAgeExclude: - hono@4.12.34 packages: - - "./packages/common" - - "./packages/server" - - "./packages/plugin" + - "packages/common" + - "packages/server" + - "packages/plugin" overrides: '@hono/node-server@<2.0.5': ^2.0.5 diff --git a/media-processor/package.json b/media-processor/package.json index 7c08c70c0f..0bb5711cf7 100644 --- a/media-processor/package.json +++ b/media-processor/package.json @@ -14,7 +14,7 @@ "fmt:check": "prettier --check src/ test/", "clean": "rm -rf dist/" }, - "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "dependencies": { "express": "^5.2.1", "multer": "^2.2.0", @@ -22,19 +22,19 @@ "pino": "^10.3.1", "pino-loki": "^3.0.0", "pino-pretty": "^13.1.3", - "sharp": "^0.35.3", + "sharp": "^0.35.4", "zod": "^4.4.3" }, "devDependencies": { "@types/express": "^5.0.6", "@types/multer": "^2.0.0", - "@types/node": "^26.1.2", + "@types/node": "^26.3.0", "@types/supertest": "^7.2.1", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "prettier": "^3.6.2", "supertest": "^7.2.2", - "tsx": "^4.22.4", + "tsx": "^4.23.12", "typescript": "^7.0.2", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } } diff --git a/media-processor/pnpm-lock.yaml b/media-processor/pnpm-lock.yaml index be8c8eec4e..51eef68766 100644 --- a/media-processor/pnpm-lock.yaml +++ b/media-processor/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -27,8 +128,8 @@ importers: specifier: ^13.1.3 version: 13.1.3 sharp: - specifier: ^0.35.3 - version: 0.35.3(@types/node@26.1.2) + specifier: ^0.35.4 + version: 0.35.4(@types/node@26.3.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -40,14 +141,14 @@ importers: specifier: ^2.0.0 version: 2.2.0 '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.3.0 + version: 26.3.0 '@types/supertest': specifier: ^7.2.1 version: 7.2.1 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 prettier: specifier: ^3.6.2 version: 3.9.6 @@ -55,172 +156,172 @@ importers: specifier: ^7.2.2 version: 7.2.2 tsx: - specifier: ^4.22.4 - version: 4.23.1 + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^7.0.2 version: 7.0.2 vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) packages: - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -229,160 +330,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -574,8 +675,8 @@ packages: '@types/multer@2.2.0': resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -715,11 +816,11 @@ packages: cpu: [x64] os: [win32] - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -729,20 +830,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -892,8 +993,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -1219,8 +1320,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -1313,8 +1414,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -1388,20 +1489,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1442,193 +1543,193 @@ packages: snapshots: - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1721,7 +1822,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.1.2 + '@types/node': 26.3.0 '@types/chai@5.2.3': dependencies: @@ -1730,7 +1831,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.3.0 '@types/cookiejar@2.1.5': {} @@ -1740,7 +1841,7 @@ snapshots: '@types/express-serve-static-core@5.1.2': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.3.0 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -1759,7 +1860,7 @@ snapshots: dependencies: '@types/express': 5.0.6 - '@types/node@26.1.2': + '@types/node@26.3.0': dependencies: undici-types: 8.3.0 @@ -1769,18 +1870,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.3.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.1.2 + '@types/node': 26.3.0 '@types/superagent@8.1.11': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 26.1.2 + '@types/node': 26.3.0 form-data: 4.0.6 '@types/supertest@7.2.1': @@ -1848,44 +1949,44 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1))': + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -2015,34 +2116,34 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escape-html@1.0.3: {} @@ -2422,38 +2523,38 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@26.1.2): + sharp@0.35.4(@types/node@26.3.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 - '@types/node': 26.1.2 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.3.0 side-channel-list@1.0.1: dependencies: @@ -2549,9 +2650,9 @@ snapshots: tslib@2.8.1: optional: true - tsx@4.23.1: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -2599,28 +2700,28 @@ snapshots: vary@1.1.2: {} - vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1): + vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12): dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 postcss: 8.5.20 rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.3.0 fsevents: 2.3.3 - tsx: 4.23.1 + tsx: 4.23.12 - vitest@4.1.10(@types/node@26.1.2)(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)): + vitest@4.1.11(@types/node@26.3.0)(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@26.1.2)(tsx@4.23.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.6(@types/node@26.3.0)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -2632,10 +2733,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.6(@types/node@26.1.2)(tsx@4.23.1) + vite: 7.3.6(@types/node@26.3.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.3.0 transitivePeerDependencies: - msw diff --git a/plugins/apps/colors-to-tokens-plugin/package.json b/plugins/apps/colors-to-tokens-plugin/package.json index e0719fca63..8d6aff2904 100644 --- a/plugins/apps/colors-to-tokens-plugin/package.json +++ b/plugins/apps/colors-to-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 21b2a1c1e3..60697f5367 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -23,8 +23,8 @@ "playwright": "^1.62.1", "prettier": "^3.9.6", "typescript": "^5.9.3", - "vite": "^8.2.0", + "vite": "^8.2.2", "vite-live-preview": "^0.4.0" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/contrast-plugin/package.json b/plugins/apps/contrast-plugin/package.json index dce3ec209f..5e6772cc8e 100644 --- a/plugins/apps/contrast-plugin/package.json +++ b/plugins/apps/contrast-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/create-palette-plugin/package.json b/plugins/apps/create-palette-plugin/package.json index b558d558e5..9507e644df 100644 --- a/plugins/apps/create-palette-plugin/package.json +++ b/plugins/apps/create-palette-plugin/package.json @@ -12,5 +12,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/e2e/package.json b/plugins/apps/e2e/package.json index a5b3507429..008fd417ff 100644 --- a/plugins/apps/e2e/package.json +++ b/plugins/apps/e2e/package.json @@ -7,5 +7,5 @@ "test": "vitest", "lint": "eslint ." }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/example-styles/package.json b/plugins/apps/example-styles/package.json index 0f756858b8..59850da654 100644 --- a/plugins/apps/example-styles/package.json +++ b/plugins/apps/example-styles/package.json @@ -11,5 +11,5 @@ "serve": "vite preview", "lint": "eslint ." }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/icons-plugin/package.json b/plugins/apps/icons-plugin/package.json index 58e75dc346..5df19f2d44 100644 --- a/plugins/apps/icons-plugin/package.json +++ b/plugins/apps/icons-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/lorem-ipsum-plugin/package.json b/plugins/apps/lorem-ipsum-plugin/package.json index bc36b0f6e6..f747085348 100644 --- a/plugins/apps/lorem-ipsum-plugin/package.json +++ b/plugins/apps/lorem-ipsum-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/plugin-api-test-suite/package.json b/plugins/apps/plugin-api-test-suite/package.json index 91c907367d..e88b6354fe 100644 --- a/plugins/apps/plugin-api-test-suite/package.json +++ b/plugins/apps/plugin-api-test-suite/package.json @@ -19,5 +19,5 @@ "devDependencies": { "playwright": "^1.62.1" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/poc-state-plugin/package.json b/plugins/apps/poc-state-plugin/package.json index 3cdfff436f..c2db3b3009 100644 --- a/plugins/apps/poc-state-plugin/package.json +++ b/plugins/apps/poc-state-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/poc-tokens-plugin/package.json b/plugins/apps/poc-tokens-plugin/package.json index c30a9e92c9..e675d5c538 100644 --- a/plugins/apps/poc-tokens-plugin/package.json +++ b/plugins/apps/poc-tokens-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "exit 0" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/rename-layers-plugin/package.json b/plugins/apps/rename-layers-plugin/package.json index fc6c9ffecc..ca57c4ff7c 100644 --- a/plugins/apps/rename-layers-plugin/package.json +++ b/plugins/apps/rename-layers-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/apps/table-plugin/package.json b/plugins/apps/table-plugin/package.json index 2200694357..ccdb6df697 100644 --- a/plugins/apps/table-plugin/package.json +++ b/plugins/apps/table-plugin/package.json @@ -13,5 +13,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugin-types/package.json b/plugins/libs/plugin-types/package.json index 86207557f6..15935921fe 100644 --- a/plugins/libs/plugin-types/package.json +++ b/plugins/libs/plugin-types/package.json @@ -7,5 +7,5 @@ "build": "node ../../tools/scripts/build-types.mjs", "lint": "tsc -p . --noEmit" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugins-runtime/package.json b/plugins/libs/plugins-runtime/package.json index d98d7eeb6b..fa31aac91a 100644 --- a/plugins/libs/plugins-runtime/package.json +++ b/plugins/libs/plugins-runtime/package.json @@ -16,5 +16,5 @@ "lint": "eslint .", "test": "vitest" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/libs/plugins-styles/package.json b/plugins/libs/plugins-styles/package.json index 890b9fb454..31e7d851b3 100644 --- a/plugins/libs/plugins-styles/package.json +++ b/plugins/libs/plugins-styles/package.json @@ -6,5 +6,5 @@ "build": "node ../../tools/scripts/build-css.mjs", "lint": "echo 0" }, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee" + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67" } diff --git a/plugins/package.json b/plugins/package.json index 81c5b1ae31..b7ab60fbd0 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -3,7 +3,7 @@ "version": "0.6.0", "type": "module", "license": "MIT", - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "scripts": { "start": "pnpm run start:app:runtime", "start:app:runtime": "concurrently --kill-others --names build,server \"pnpm --filter @penpot/plugins-runtime run build:watch\" \"pnpm --filter @penpot/plugins-runtime run preview\"", @@ -32,29 +32,29 @@ }, "private": true, "devDependencies": { - "@angular-devkit/core": "22.1.2", - "@angular-devkit/schematics": "22.1.2", + "@angular-devkit/core": "22.1.6", + "@angular-devkit/schematics": "22.1.6", "@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/template-parser": "22.1.0", - "@angular/build": "22.1.2", - "@angular/cli": "22.1.2", - "@angular/compiler-cli": "22.1.0", - "@angular/language-service": "22.1.0", + "@angular/build": "22.1.6", + "@angular/cli": "22.1.6", + "@angular/compiler-cli": "22.1.3", + "@angular/language-service": "22.1.3", "@eslint/js": "10.0.1", - "@schematics/angular": "22.1.2", + "@schematics/angular": "22.1.6", "@types/feather-icons": "^4.29.4", "@types/node": "26.1.2", "@types/yargs": "^17.0.35", - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/utils": "^8.65.0", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "concurrently": "^10.0.4", + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/utils": "^8.68.0", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "concurrently": "^10.0.5", "dotenv": "^17.4.2", - "esbuild": "^0.28.1", - "eslint": "10.6.0", + "esbuild": "^0.28.2", + "eslint": "10.9.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", @@ -62,33 +62,33 @@ "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-unused-imports": "^4.4.1", "fs-extra": "^11.4.0", - "globals": "^17.9.0", - "happy-dom": "^20.11.1", + "globals": "^17.11.0", + "happy-dom": "^20.11.6", "jiti": "2.7.0", "jsdom": "~30.0.1", - "jsonc-eslint-parser": "^3.1.0", + "jsonc-eslint-parser": "^3.3.0", "prettier": "^3.9.6", - "tsx": "^4.23.5", + "tsx": "^4.23.12", "typedoc": "^0.28.20", "typescript": "6.0.3", - "typescript-eslint": "^8.65.0", - "vite": "8.2.0", + "typescript-eslint": "^8.68.0", + "vite": "8.2.2", "vite-plugin-checker": "^0.14.5", "vite-plugin-dts": "5.0.3", "vite-plugin-static-copy": "^4.1.1", - "vitest": "4.1.10", + "vitest": "4.1.11", "yargs": "^18.1.0" }, "dependencies": { - "@angular/common": "22.1.0", - "@angular/compiler": "22.1.0", - "@angular/core": "22.1.0", - "@angular/forms": "22.1.0", - "@angular/platform-browser": "22.1.0", - "@angular/router": "22.1.0", - "axios": "^1.19.0", + "@angular/common": "22.1.3", + "@angular/compiler": "22.1.3", + "@angular/core": "22.1.3", + "@angular/forms": "22.1.3", + "@angular/platform-browser": "22.1.3", + "@angular/router": "22.1.3", + "axios": "^1.20.0", "feather-icons": "^4.29.2", - "puppeteer": "^25.4.0", + "puppeteer": "^25.9.0", "rxjs": "~7.8.2", "ses": "^2.2.0", "tslib": "^2.8.1", diff --git a/plugins/pnpm-lock.yaml b/plugins/pnpm-lock.yaml index f1f6bdea00..d1d02b8eeb 100644 --- a/plugins/pnpm-lock.yaml +++ b/plugins/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -19,32 +120,32 @@ importers: .: dependencies: '@angular/common': - specifier: 22.1.0 - version: 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) '@angular/compiler': - specifier: 22.1.0 - version: 22.1.0 + specifier: 22.1.3 + version: 22.1.3 '@angular/core': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + specifier: 22.1.3 + version: 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) '@angular/forms': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) '@angular/platform-browser': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) '@angular/router': - specifier: 22.1.0 - version: 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) + specifier: 22.1.3 + version: 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2) axios: - specifier: ^1.19.0 - version: 1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) + specifier: ^1.20.0 + version: 1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) feather-icons: specifier: ^4.29.2 version: 4.29.2 puppeteer: - specifier: ^25.4.0 - version: 25.4.0 + specifier: ^25.9.0 + version: 25.9.0 rxjs: specifier: ~7.8.2 version: 7.8.2 @@ -62,38 +163,38 @@ importers: version: 0.16.2 devDependencies: '@angular-devkit/core': - specifier: 22.1.2 - version: 22.1.2(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@angular-devkit/schematics': - specifier: 22.1.2 - version: 22.1.2(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@angular-eslint/eslint-plugin': specifier: 22.1.0 - version: 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + version: 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/eslint-plugin-template': specifier: 22.1.0 - version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + version: 22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.68.0)(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular-eslint/template-parser': specifier: 22.1.0 - version: 22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + version: 22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) '@angular/build': - specifier: 22.1.2 - version: 22.1.2(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.5)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0) + specifier: 22.1.6 + version: 22.1.6(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0) '@angular/cli': - specifier: 22.1.2 - version: 22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2) + specifier: 22.1.6 + version: 22.1.6(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2) '@angular/compiler-cli': - specifier: 22.1.0 - version: 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) + specifier: 22.1.3 + version: 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) '@angular/language-service': - specifier: 22.1.0 - version: 22.1.0 + specifier: 22.1.3 + version: 22.1.3 '@eslint/js': specifier: 10.0.1 - version: 10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) '@schematics/angular': - specifier: 22.1.2 - version: 22.1.2(chokidar@5.0.0) + specifier: 22.1.6 + version: 22.1.6(chokidar@5.0.0) '@types/feather-icons': specifier: ^4.29.4 version: 4.29.4 @@ -104,59 +205,59 @@ importers: specifier: ^17.0.35 version: 17.0.35 '@typescript-eslint/eslint-plugin': - specifier: 8.65.0 - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + specifier: 8.68.0 + version: 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/parser': - specifier: 8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + specifier: 8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@typescript-eslint/utils': - specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) '@vitest/ui': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 dotenv: specifier: ^17.4.2 version: 17.4.2 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 eslint: - specifier: 10.6.0 - version: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + specifier: 10.9.1 + version: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-config-prettier: specifier: 10.1.8 - version: 10.1.8(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + version: 2.32.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-jsx-a11y: specifier: 6.10.2 - version: 6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 6.10.2(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react: specifier: 7.37.5 - version: 7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react-hooks: specifier: 7.1.1 - version: 7.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + version: 7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) fs-extra: specifier: ^11.4.0 version: 11.4.0 globals: - specifier: ^17.9.0 - version: 17.9.0 + specifier: ^17.11.0 + version: 17.11.0 happy-dom: - specifier: ^20.11.1 - version: 20.11.1 + specifier: ^20.11.6 + version: 20.11.6 jiti: specifier: 2.7.0 version: 2.7.0 @@ -164,14 +265,14 @@ importers: specifier: ~30.0.1 version: 30.0.1 jsonc-eslint-parser: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.3.0 + version: 3.3.0 prettier: specifier: ^3.9.6 version: 3.9.6 tsx: - specifier: ^4.23.5 - version: 4.23.5 + specifier: ^4.23.12 + version: 4.23.12 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@6.0.3) @@ -179,23 +280,23 @@ importers: specifier: 6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.65.0 - version: 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) vite: - specifier: 8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + specifier: 8.2.2 + version: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-checker: specifier: ^0.14.5 - version: 0.14.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + version: 0.14.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) vite-plugin-dts: specifier: 5.0.3 - version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) + version: 5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) vite-plugin-static-copy: specifier: ^4.1.1 - version: 4.1.1(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + version: 4.1.1(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) vitest: - specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + specifier: 4.1.11 + version: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) yargs: specifier: ^18.1.0 version: 18.1.0 @@ -221,11 +322,11 @@ importers: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) vite-live-preview: specifier: ^0.4.0 - version: 0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + version: 0.4.0(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) apps/contrast-plugin: {} @@ -275,13 +376,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2201.2': - resolution: {integrity: sha512-RRG3JA3hPH0ypbDIyquZt9DDTP5pOMPgqQ/iLSkok1MZdKiOgpk6FGfXCa1ei72SwlX7lnJdq94d6WWdqpbyKg==} + '@angular-devkit/architect@0.2201.6': + resolution: {integrity: sha512-oGQEdof2/1bZk58PN9dvpGi8pvtbgE5vkP1xRtCqN8dSVxwre1l0pKynOj/Uge1KxjCvs4c1QrQu0vsAnY0vpg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@22.1.2': - resolution: {integrity: sha512-tF1oEE7KPs8I08HJQmH5e4GkLUB3+MXXy8t6gMJULaLFxZYP9K1oXRFLappMpdm9OIbEXOChk23hrho0By9aYg==} + '@angular-devkit/core@22.1.6': + resolution: {integrity: sha512-KLBsZoc2RhOy0xSdeYcLoSOqV/fBP3feBmhY9o12ry2IuyW/17sCmWr6XbIfTIYQN6M98Y1ewmwVNFsvV5b0gA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -289,8 +390,8 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@22.1.2': - resolution: {integrity: sha512-Lw6NvW5rfMUl/2dsuWY8l6wlfWCuYBzCYSSqqliLPDco0doGzBliHwY9uxuzuUKZgOl5TvuVyvEo0t3o4Jj4GA==} + '@angular-devkit/schematics@22.1.6': + resolution: {integrity: sha512-IbDO9KbQyQm20uinsfT8d9ZtQw41/WVutI/65mAw39WZfN8Ky+MOgOUJCGLiSccMprvZ9YdodBTnN9bLH149Ag==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/bundled-angular-compiler@22.1.0': @@ -325,8 +426,8 @@ packages: eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/build@22.1.2': - resolution: {integrity: sha512-DE/3o17JTel4EBt2BA4DqJYeBBuz5Ef/kf1jL9YZTyJu4SrLr/HI79K14jFr0VRIxzcqG92FdIzfLDxbOesQsg==} + '@angular/build@22.1.6': + resolution: {integrity: sha512-J7JBd3hDAV4dlGNMavDC0KjtvbDd4KOddaom1inwkq92tYnD1ljmhDDigAsUBnRvQhJ82cvagD8zi0s9Ki4rTQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^22.0.0 @@ -336,7 +437,7 @@ packages: '@angular/platform-browser': ^22.0.0 '@angular/platform-server': ^22.0.0 '@angular/service-worker': ^22.0.0 - '@angular/ssr': ^22.1.2 + '@angular/ssr': ^22.1.6 istanbul-lib-instrument: ^6.0.0 karma: ^6.4.0 less: ^4.2.0 @@ -377,38 +478,38 @@ packages: vitest: optional: true - '@angular/cli@22.1.2': - resolution: {integrity: sha512-gzB+iuZzB507DAkZb9s5+Jw8QRzOBolUhHEuAKH74xF6oWlEP5JdexfTgti45SjXaKKqeYpODJFnUmSQQJRhxA==} + '@angular/cli@22.1.6': + resolution: {integrity: sha512-HT3OzYkSpCyXMTgD5G1tsS7vkrY8cYJ/JgSjRjrpwOAooSMtKF1hv7BgBcn79sKg4eaiPLrDpQLXP93vHxgSFg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.1.0': - resolution: {integrity: sha512-67L8AS00egxwEKnoMhNDxy+TY+eKOwvwa+os0Odq8nLm7+Qh7JnMVeub8hfncpenOFqlC/RUjO2W9H7Gd2veNA==} + '@angular/common@22.1.3': + resolution: {integrity: sha512-QtMkjhiRd0EnmKR50bw3WbCWYTi6CmA72nnSz1BLQPpaLSi2goloCrPPniHz8fP+w2ESrmmlOWxs1Da3COgnQg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/core': 22.1.0 + '@angular/core': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@22.1.0': - resolution: {integrity: sha512-jL89dbzkrV8AeLaxedBgT7ErMnbfi2dvwDJuCrUgm3eCNfbcOpGbNxzH+wDgvbRg2Lhj11/u3JPbW50xA6rvvg==} + '@angular/compiler-cli@22.1.3': + resolution: {integrity: sha512-37lLaDp0RHWZ/lmJqCmIEr0HOM2D5ulHy61gqTBm7KRj3Y6ZaxR8B/JqZmeIpPzKFILVsga+NQ4A8apBUkmezw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 typescript: '>=6.0 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@22.1.0': - resolution: {integrity: sha512-WCmuPnuXgqnqrkbrwqQRyldi1k3rlzNLVDl8ntINF7XWuJh0KfQLEkRK0FCCmBztWJkbGug4RBVnKTWlKhRzCQ==} + '@angular/compiler@22.1.3': + resolution: {integrity: sha512-L8Mw2r7bGG/obqgQC+RU3mdFJ3NtLgO5gWhEC1ylcHpLCMPIAXYsMKJIL8dnS78S1wXo/omXwmJ4FiIlCwWahg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.1.0': - resolution: {integrity: sha512-X5UaMuOCI4HAvSQIs3QtM+5e0Cni16DRaHUIL3BIBd4ZQNnSH3pZ25TsKQ8Jlu/3hAQ9rzV278kNQcecooGJ7g==} + '@angular/core@22.1.3': + resolution: {integrity: sha512-313+Xkf970AmStJE0E/zNJW/9xvDExQG+6TNltBBl+KJsW0q5dffK2w2PQfV4mtTquBqYoeHSRsms4WgjBKL8g==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -417,37 +518,37 @@ packages: zone.js: optional: true - '@angular/forms@22.1.0': - resolution: {integrity: sha512-nWlSM/pPp78Sx/fBM/tFEgZxdfZe50LkCE2/hkO22Fi1UM2maGc43LDsu/s6l0q9hFep4Wj+xa30KXDBS7Cn8A==} + '@angular/forms@22.1.3': + resolution: {integrity: sha512-b4ual9pgfNqcnEHord50w960DDFIytG3Qb3bu2aCgzmagvRlg9wrtwQNqY+oqY2FVq7c9McNUN7MZRcWl9HNdQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 - '@angular/platform-browser': 22.1.0 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 + '@angular/platform-browser': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 - '@angular/language-service@22.1.0': - resolution: {integrity: sha512-5J+j17o9rvJEiTVotsQfHprPCgKrHhYxz+SpiV25p5mG2qOJ9vX465o/ODbFpRapK8eyHqd/HkPRaGafu3nZkg==} + '@angular/language-service@22.1.3': + resolution: {integrity: sha512-Nc5cHyuYTTH9uENTSmJFiqR85xPGFkpOwxN6Ms2qlqz767FyOXna0iqw2DprNRB1+xG/WtP4RbCI6ofyXHbM0w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/platform-browser@22.1.0': - resolution: {integrity: sha512-gqUYDUiPfwbaLYdH8WLnOLl3feo3OcNpnMO08HBHaUdi4TLNkC28xwa9fC6ANyYD22QZ5A3abSg8fmR6upWMwg==} + '@angular/platform-browser@22.1.3': + resolution: {integrity: sha512-A8McE6AclwZa2ese4jMfZZu+qZfBFQ4Hl6CaMpzJ1C6Vv6+sXkLu9pouTosJEsUE+etVdepDsqau90lhzgw3Eg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/animations': 22.1.0 - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 + '@angular/animations': 22.1.3 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/router@22.1.0': - resolution: {integrity: sha512-42Bs0g+tV2gE70Lqnt+VD/+DWbvWwQcg8QgXkTIu3A504tYknrZG/wmvki2AJGyZhmyQ46B4pfXLG4WDP8MFSA==} + '@angular/router@22.1.3': + resolution: {integrity: sha512-23owvZKCpdL7Yh3EzBj4OLf3x0z+jT9b57Qk96wdwI8Lsyf9L78/RJPYCutJ5r+zq3pFM/BHVKyd+2hkfH7N6Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.0 - '@angular/core': 22.1.0 - '@angular/platform-browser': 22.1.0 + '@angular/common': 22.1.3 + '@angular/core': 22.1.3 + '@angular/platform-browser': 22.1.3 rxjs: ^6.5.3 || ^7.4.0 '@asamuzakjp/css-color@6.0.5': @@ -674,158 +775,158 @@ packages: '@endo/immutable-arraybuffer@1.1.2': resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -844,8 +945,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -1067,12 +1168,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@listr2/prompt-adapter-inquirer@4.2.4': - resolution: {integrity: sha512-/KRI2DMD7JGSYaREF0Ygl7AefJ/2ase4Gc5cBiKqT5l4tFjsSJfhFGcc5nSkgl0Sp9LkCQNzl/cqbVJYP2L3dw==} + '@listr2/prompt-adapter-inquirer@4.2.5': + resolution: {integrity: sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==} engines: {node: '>=22.13.0'} peerDependencies: '@inquirer/prompts': '>= 3 < 9' - listr2: 10.2.1 + listr2: 11.0.0 '@lmdb/lmdb-darwin-arm64@3.5.6': resolution: {integrity: sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==} @@ -1122,8 +1223,8 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1439,6 +1540,9 @@ packages: '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@parcel/watcher-android-arm64@2.6.0': resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} @@ -1530,8 +1634,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@puppeteer/browsers@3.0.6': - resolution: {integrity: sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==} + '@puppeteer/browsers@3.2.1': + resolution: {integrity: sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -1543,6 +1647,12 @@ packages: yauzl: optional: true + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1561,6 +1671,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1579,6 +1695,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1597,6 +1719,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1615,6 +1743,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1633,6 +1767,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1654,6 +1794,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1675,6 +1822,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1696,6 +1850,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1717,6 +1878,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1738,6 +1906,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1759,6 +1934,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1777,6 +1959,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1805,6 +1993,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1823,6 +2017,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2074,8 +2274,8 @@ packages: '@rushstack/ts-command-line@5.1.7': resolution: {integrity: sha512-Ugwl6flarZcL2nqH5IXFYk3UR3mBVDsVFlCQW/Oaqidvdb/5Ota6b/Z3JXWIdqV3rOR2/JrYoAHanWF5rgenXA==} - '@schematics/angular@22.1.2': - resolution: {integrity: sha512-52udja/QGSNH5geSnL4JWFOEfx8M7tqf7LNXz8byjki4VshVOkKHTawQcL4YbJfo3MfwwXm4AsoadMOk8EST2w==} + '@schematics/angular@22.1.6': + resolution: {integrity: sha512-RiD4OZJ4yuaM1aXb3pt1gjDSWwkP0jrgiCuoaBls1eabcrDmlsNuQv0ekxbcGgCRPdSJvmHRZIHHmFPzei8w3Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@seahax/deep-copy@0.1.0': @@ -2168,63 +2368,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.68.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-basic-ssl@2.3.0': @@ -2233,20 +2433,20 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2256,25 +2456,25 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.10': - resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2356,11 +2556,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -2500,8 +2695,8 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} - axios@1.19.0: - resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -2623,9 +2818,9 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} + cli-truncate@6.1.1: + resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} + engines: {node: '>=22'} cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} @@ -2651,8 +2846,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -2776,8 +2971,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devtools-protocol@0.0.1653615: - resolution: {integrity: sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==} + devtools-protocol@0.0.1666840: + resolution: {integrity: sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==} diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} @@ -2891,8 +3086,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -2994,8 +3189,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.6.0: - resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3038,9 +3233,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3221,8 +3413,8 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - globals@17.9.0: - resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3236,8 +3428,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@20.11.1: - resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + happy-dom@20.11.6: + resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==} engines: {node: '>=20.0.0'} has-bigints@1.1.0: @@ -3567,8 +3759,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-eslint-parser@3.1.0: - resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + jsonc-eslint-parser@3.3.0: + resolution: {integrity: sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} jsonc-parser@3.3.1: @@ -3684,8 +3876,8 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - listr2@10.2.2: - resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} + listr2@11.0.0: + resolution: {integrity: sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==} engines: {node: '>=22.13.0'} lmdb@3.5.6: @@ -3711,9 +3903,9 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} + log-update@8.0.0: + resolution: {integrity: sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==} + engines: {node: '>=22'} loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -3820,8 +4012,8 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - modern-tar@0.7.7: - resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + modern-tar@0.8.4: + resolution: {integrity: sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==} engines: {node: '>=18.0.0'} mrmime@2.0.1: @@ -3847,6 +4039,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4074,6 +4271,10 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4121,12 +4322,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@25.4.0: - resolution: {integrity: sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==} + puppeteer-core@25.9.0: + resolution: {integrity: sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg==} engines: {node: '>=22.12.0'} - puppeteer@25.4.0: - resolution: {integrity: sha512-xfQp8dFBcGaLc1hEMaVr7s+oW4ZkAurr8Y9H81ilKhu6QoLfSTkZjU7IavnyJ/VWpB9ni3KNJUQHUatslLWyGw==} + puppeteer@25.9.0: + resolution: {integrity: sha512-2JqQszD2pyDTpIvBH1ZCXdrHgENVNdJIeOM6asbwHRgWknFiaLd1gNB91w/B/0hQHNpafkpxa90lPpBaJM87Hw==} engines: {node: '>=22.12.0'} hasBin: true @@ -4193,9 +4394,6 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4211,6 +4409,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4390,11 +4593,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -4475,13 +4673,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -4706,8 +4900,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.5: - resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -4745,8 +4939,8 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4850,9 +5044,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + vite-live-preview@0.4.0: resolution: {integrity: sha512-Qz8kr0kixXwnQl+zLPZX66OjajN4jnVnDwhNToJsO6TTboUtBo8pEmRuc0iBmkwW9lXR8mOeMu+QtxFkXBcHYg==} - hasBin: true peerDependencies: vite: '>=5.4.0' @@ -4950,13 +5147,13 @@ packages: yaml: optional: true - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -4993,20 +5190,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5129,8 +5326,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5141,8 +5338,8 @@ packages: utf-8-validate: optional: true - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5222,14 +5419,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2201.2(chokidar@5.0.0)': + '@angular-devkit/architect@0.2201.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.2(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@22.1.2(chokidar@5.0.0)': + '@angular-devkit/core@22.1.6(chokidar@5.0.0)': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -5240,9 +5437,9 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@22.1.2(chokidar@5.0.0)': + '@angular-devkit/schematics@22.1.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.2(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 1.0.0 ora: 9.4.1 @@ -5252,58 +5449,58 @@ snapshots: '@angular-eslint/bundled-angular-compiler@22.1.0': {} - '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.65.0)(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin-template@22.1.0(@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3))(@typescript-eslint/types@8.68.0)(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/template-parser': 22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@angular-eslint/template-parser': 22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) aria-query: 5.3.2 axobject-query: 4.1.0 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin@22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@angular-eslint/utils': 22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - '@angular-eslint/template-parser@22.1.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@angular-eslint/template-parser@22.1.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-scope: 9.1.2 typescript: 6.0.3 - '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': + '@angular-eslint/utils@22.1.0(@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.1.0 - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 - '@angular/build@22.1.2(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.5)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0)': + '@angular/build@22.1.6(@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3))(@angular/compiler@22.1.3)(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(@types/node@26.1.2)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3(supports-color@10.2.2))(jiti@2.7.0)(less@4.6.4)(postcss@8.5.25)(rollup@4.60.4)(sass-embedded@1.97.3)(supports-color@10.2.2)(terser@5.46.2)(tslib@2.8.1)(tsx@4.23.12)(typescript@6.0.3)(vitest@4.1.11)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) - '@angular/compiler': 22.1.0 - '@angular/compiler-cli': 22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3) + '@angular-devkit/architect': 0.2201.6(chokidar@5.0.0) + '@angular/compiler': 22.1.3 + '@angular/compiler-cli': 22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3) '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 6.1.1(@types/node@26.1.2) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) beasties: 0.4.3 browserslist: 4.28.7 - esbuild: 0.28.1 + esbuild: 0.28.2 https-proxy-agent: 9.1.0(supports-color@10.2.2) jsonc-parser: 3.3.1 - listr2: 10.2.2 + listr2: 11.0.0 magic-string: 1.0.0 mrmime: 2.0.1 oxc-parser: 0.142.0 @@ -5317,17 +5514,17 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) less: 4.6.4 lmdb: 3.5.6 postcss: 8.5.25 rollup: 4.60.4 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -5342,21 +5539,21 @@ snapshots: - tsx - yaml - '@angular/cli@22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2)': + '@angular/cli@22.1.6(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@10.2.2)': dependencies: - '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) - '@angular-devkit/core': 22.1.2(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.2(chokidar@5.0.0) + '@angular-devkit/architect': 0.2201.6(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.6(chokidar@5.0.0) '@inquirer/prompts': 8.5.2(@types/node@26.1.2) - '@listr2/prompt-adapter-inquirer': 4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) - '@schematics/angular': 22.1.2(chokidar@5.0.0) + '@listr2/prompt-adapter-inquirer': 4.2.5(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@11.0.0) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) + '@schematics/angular': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 - listr2: 10.2.2 + listr2: 11.0.0 npm-package-arg: 14.0.0 parse5-html-rewriting-stream: 8.0.1 semver: 7.8.5 - yargs: 18.0.0 + yargs: 18.1.0 zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' @@ -5364,15 +5561,15 @@ snapshots: - chokidar - supports-color - '@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': + '@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3)': + '@angular/compiler-cli@22.1.3(@angular/compiler@22.1.3)(typescript@6.0.3)': dependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 '@babel/core': 8.0.1 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 @@ -5384,41 +5581,41 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.1.0': + '@angular/compiler@22.1.3': dependencies: tslib: 2.8.1 - '@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)': + '@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 22.1.0 + '@angular/compiler': 22.1.3 zone.js: 0.16.2 - '@angular/forms@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/forms@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 zod: 4.4.3 - '@angular/language-service@22.1.0': {} + '@angular/language-service@22.1.3': {} - '@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))': + '@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) tslib: 2.8.1 - '@angular/router@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': + '@angular/router@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) - '@angular/core': 22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2) - '@angular/platform-browser': 22.1.0(@angular/common@22.1.0(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.0(@angular/compiler@22.1.0)(rxjs@7.8.2)(zone.js@0.16.2)) + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2) + '@angular/platform-browser': 22.1.3(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)(zone.js@0.16.2)) rxjs: 7.8.2 tslib: 2.8.1 @@ -5713,87 +5910,87 @@ snapshots: '@endo/immutable-arraybuffer@1.1.2': {} - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -5802,11 +5999,11 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@10.2.2) - minimatch: 10.2.5 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -5814,9 +6011,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))': optionalDependencies: - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -6005,11 +6202,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2)': + '@listr2/prompt-adapter-inquirer@4.2.5(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@11.0.0)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@26.1.2) '@inquirer/type': 4.0.7(@types/node@26.1.2) - listr2: 10.2.2 + listr2: 11.0.0 transitivePeerDependencies: - '@types/node' @@ -6074,7 +6271,7 @@ snapshots: '@microsoft/tsdoc@0.16.0': optional: true - '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)': dependencies: '@hono/node-server': 2.0.12(hono@4.12.34) ajv: 8.20.0 @@ -6308,6 +6505,8 @@ snapshots: '@oxc-project/types@0.142.0': {} + '@oxc-project/types@0.147.0': {} + '@parcel/watcher-android-arm64@2.6.0': optional: true @@ -6371,11 +6570,14 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@puppeteer/browsers@3.0.6': + '@puppeteer/browsers@3.2.1': dependencies: - modern-tar: 0.7.7 + modern-tar: 0.8.4 yargs: 18.1.0 + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -6385,6 +6587,9 @@ snapshots: '@rolldown/binding-android-arm64@1.2.2': optional: true + '@rolldown/binding-android-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-arm64@1.1.5': optional: true @@ -6394,6 +6599,9 @@ snapshots: '@rolldown/binding-darwin-arm64@1.2.2': optional: true + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + '@rolldown/binding-darwin-x64@1.1.5': optional: true @@ -6403,6 +6611,9 @@ snapshots: '@rolldown/binding-darwin-x64@1.2.2': optional: true + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + '@rolldown/binding-freebsd-x64@1.1.5': optional: true @@ -6412,6 +6623,9 @@ snapshots: '@rolldown/binding-freebsd-x64@1.2.2': optional: true + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true @@ -6421,6 +6635,9 @@ snapshots: '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true @@ -6430,6 +6647,9 @@ snapshots: '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true @@ -6439,6 +6659,9 @@ snapshots: '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true @@ -6448,6 +6671,9 @@ snapshots: '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true @@ -6457,6 +6683,9 @@ snapshots: '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true @@ -6466,6 +6695,9 @@ snapshots: '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true @@ -6475,6 +6707,9 @@ snapshots: '@rolldown/binding-linux-x64-musl@1.2.2': optional: true + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true @@ -6484,6 +6719,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.2.2': optional: true + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 @@ -6507,6 +6745,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true @@ -6516,6 +6757,9 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.4.0(rollup@4.60.4)': @@ -6705,10 +6949,10 @@ snapshots: - '@types/node' optional: true - '@schematics/angular@22.1.2(chokidar@5.0.0)': + '@schematics/angular@22.1.6(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.2(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.2(chokidar@5.0.0) + '@angular-devkit/core': 22.1.6(chokidar@5.0.0) + '@angular-devkit/schematics': 22.1.6(chokidar@5.0.0) jsonc-parser: 3.3.1 typescript: 6.0.3 transitivePeerDependencies: @@ -6811,15 +7055,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6827,56 +7071,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 debug: 4.4.3(supports-color@10.2.2) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.68.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) debug: 4.4.3(supports-color@10.2.2) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.68.0': {} - '@typescript-eslint/typescript-estree@8.65.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.68.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 @@ -6886,30 +7130,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -6918,57 +7162,57 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.10(vitest@4.1.10)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 fflate: 0.8.3 flatted: 3.4.3 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -7093,16 +7337,13 @@ snapshots: acorn: 8.18.0 optional: true - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 - - acorn@8.16.0: {} + acorn: 8.18.0 acorn@8.17.0: {} - acorn@8.18.0: - optional: true + acorn@8.18.0: {} agent-base@6.0.2(supports-color@10.2.2): dependencies: @@ -7263,7 +7504,7 @@ snapshots: axe-core@4.11.4: {} - axios@1.19.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): + axios@1.20.0(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2): dependencies: follow-redirects: 1.16.0(debug@4.4.3(supports-color@10.2.2)) form-data: 4.0.6 @@ -7393,9 +7634,9 @@ snapshots: chrome-trace-event@1.0.4: optional: true - chromium-bidi@17.0.2(devtools-protocol@0.0.1653615): + chromium-bidi@17.0.2(devtools-protocol@0.0.1666840): dependencies: - devtools-protocol: 0.0.1653615 + devtools-protocol: 0.0.1666840 mitt: 3.0.1 zod: 3.25.76 @@ -7407,9 +7648,9 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@5.2.0: + cli-truncate@6.1.1: dependencies: - slice-ansi: 8.0.0 + slice-ansi: 9.0.0 string-width: 8.2.2 cli-width@4.1.0: {} @@ -7434,7 +7675,7 @@ snapshots: concat-map@0.0.1: {} - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -7555,7 +7796,7 @@ snapshots: detect-libc@2.1.2: {} - devtools-protocol@0.0.1653615: {} + devtools-protocol@0.0.1666840: {} diff@8.0.4: optional: true @@ -7728,34 +7969,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -7765,9 +8006,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: @@ -7777,17 +8018,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7796,9 +8037,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7810,13 +8051,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-jsx-a11y@6.10.2(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -7826,7 +8067,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7835,18 +8076,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-react@7.37.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -7854,7 +8095,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -7868,11 +8109,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint-scope@5.1.1: dependencies: @@ -7891,12 +8132,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2): + eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@10.2.2) - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 @@ -7920,7 +8161,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7930,8 +8171,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -7957,8 +8198,6 @@ snapshots: etag@1.8.1: {} - eventemitter3@5.0.4: {} - events@3.3.0: optional: true @@ -8168,7 +8407,7 @@ snapshots: glob-to-regexp@0.4.1: optional: true - globals@17.9.0: {} + globals@17.11.0: {} globalthis@1.0.4: dependencies: @@ -8179,7 +8418,7 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@20.11.1: + happy-dom@20.11.6: dependencies: '@types/node': 26.1.2 '@types/whatwg-mimetype': 3.0.2 @@ -8187,7 +8426,7 @@ snapshots: buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8532,11 +8771,11 @@ snapshots: json5@2.2.3: {} - jsonc-eslint-parser@3.1.0: + jsonc-eslint-parser@3.3.0: dependencies: - acorn: 8.16.0 + acorn: 8.18.0 eslint-visitor-keys: 5.0.1 - semver: 7.8.1 + verkit: 0.3.2 jsonc-parser@3.3.1: {} @@ -8639,12 +8878,10 @@ snapshots: dependencies: uc.micro: 2.1.0 - listr2@10.2.2: + listr2@11.0.0: dependencies: - cli-truncate: 5.2.0 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 + cli-truncate: 6.1.1 + log-update: 8.0.0 wrap-ansi: 10.0.0 lmdb@3.5.6: @@ -8686,13 +8923,14 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.2.0 - log-update@6.1.0: + log-update@8.0.0: dependencies: ansi-escapes: 7.3.0 cli-cursor: 5.0.0 - slice-ansi: 7.1.2 + slice-ansi: 9.0.0 + string-width: 8.2.2 strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 + wrap-ansi: 10.0.0 loose-envify@1.4.0: dependencies: @@ -8722,7 +8960,7 @@ snapshots: magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@2.1.0: @@ -8797,7 +9035,7 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - modern-tar@0.7.7: {} + modern-tar@0.8.4: {} mrmime@2.0.1: {} @@ -8824,6 +9062,8 @@ snapshots: nanoid@3.3.16: {} + nanoid@3.3.18: {} + natural-compare@1.4.0: {} needle@3.5.0: @@ -9079,6 +9319,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -9113,27 +9359,27 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@25.4.0: + puppeteer-core@25.9.0: dependencies: - '@puppeteer/browsers': 3.0.6 - chromium-bidi: 17.0.2(devtools-protocol@0.0.1653615) - devtools-protocol: 0.0.1653615 + '@puppeteer/browsers': 3.2.1 + chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) + devtools-protocol: 0.0.1666840 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.2 - ws: 8.21.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - proxy-agent - utf-8-validate - yauzl - puppeteer@25.4.0: + puppeteer@25.9.0: dependencies: - '@puppeteer/browsers': 3.0.6 - chromium-bidi: 17.0.2(devtools-protocol@0.0.1653615) - devtools-protocol: 0.0.1653615 + '@puppeteer/browsers': 3.2.1 + chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) + devtools-protocol: 0.0.1666840 lilconfig: 3.1.3 - puppeteer-core: 25.4.0 + puppeteer-core: 25.9.0 typed-query-selector: 2.12.2 transitivePeerDependencies: - bufferutil @@ -9216,8 +9462,6 @@ snapshots: retry@0.12.0: {} - rfdc@1.4.1: {} - rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -9279,6 +9523,28 @@ snapshots: '@rolldown/binding-openharmony-arm64': 1.2.2 '@rolldown/binding-win32-arm64-msvc': 1.2.2 '@rolldown/binding-win32-x64-msvc': 1.2.2 + optional: true + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 rollup@4.60.4: dependencies: @@ -9477,8 +9743,6 @@ snapshots: lru-cache: 6.0.0 optional: true - semver@7.8.1: {} - semver@7.8.5: {} send@1.2.1(supports-color@10.2.2): @@ -9598,12 +9862,7 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -9734,15 +9993,15 @@ snapshots: tapable@2.3.3: optional: true - terser-webpack-plugin@5.6.1(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): + terser-webpack-plugin@5.6.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.2 - webpack: 5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25) + webpack: 5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 lightningcss: 1.33.0 postcss: 8.5.25 optional: true @@ -9805,9 +10064,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.5: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -9865,13 +10124,13 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 - typescript-eslint@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -9904,7 +10163,7 @@ snapshots: unpipe@1.0.0: {} - unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): + unplugin-dts@1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.60.4) '@volar/typescript': 2.4.28(typescript@6.0.3) @@ -9918,11 +10177,11 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) '@rspack/core': 1.6.8(@swc/helpers@0.5.18) - esbuild: 0.28.1 + esbuild: 0.28.2 rolldown: 1.2.2 rollup: 4.60.4 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) - webpack: 5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) + webpack: 5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - supports-color @@ -9950,20 +10209,22 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.4.0(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): + verkit@0.3.2: {} + + vite-live-preview@0.4.0(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@seahax/deep-copy': 0.1.0 '@seahax/semaphore': 0.5.1 '@types/ws': 8.18.1 escape-goat: 4.0.0 strip-ansi: 7.2.0 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - vite-plugin-checker@0.14.5(eslint@10.6.0(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): + vite-plugin-checker@0.14.5(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -9972,19 +10233,19 @@ snapshots: picomatch: 4.0.4 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) optionalDependencies: - eslint: 10.6.0(jiti@2.7.0)(supports-color@10.2.2) + eslint: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) optionator: 0.9.4 typescript: 6.0.3 - vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)): + vite-plugin-dts@5.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: - unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.1)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) + unplugin-dts: 1.0.3(@microsoft/api-extractor@7.56.0(@types/node@26.1.2))(@rspack/core@1.6.8(@swc/helpers@0.5.18))(esbuild@0.28.2)(rolldown@1.2.2)(rollup@4.60.4)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) optionalDependencies: '@microsoft/api-extractor': 7.56.0(@types/node@26.1.2) rollup: 4.60.4 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - '@vue/language-core' @@ -9994,15 +10255,15 @@ snapshots: - typescript - webpack - vite-plugin-static-copy@4.1.1(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): + vite-plugin-static-copy@4.1.1(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: chokidar: 3.6.0 p-map: 7.0.4 picocolors: 1.1.1 tinyglobby: 0.2.17 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) - vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0): + vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -10011,44 +10272,44 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 less: 4.6.4 sass: 1.101.0 sass-embedded: 1.97.3 terser: 5.46.2 - tsx: 4.23.5 + tsx: 4.23.12 yaml: 2.9.0 - vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0): + vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.2.2 + postcss: 8.5.26 + rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 less: 4.6.4 sass: 1.101.0 sass-embedded: 1.97.3 terser: 5.46.2 - tsx: 4.23.5 + tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@30.0.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.1.2)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.11.6)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -10060,13 +10321,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.5)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.97.3)(sass@1.101.0)(terser@5.46.2)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.2 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - '@vitest/ui': 4.1.10(vitest@4.1.10) - happy-dom: 20.11.1 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) + happy-dom: 20.11.6 jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -10093,7 +10354,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25): + webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -10116,7 +10377,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.25)) + terser-webpack-plugin: 5.6.1(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.106.2(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.25)) watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -10220,10 +10481,10 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} - ws@8.21.1: {} + ws@8.21.3: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/render-wasm/package.json b/render-wasm/package.json index 75e77ee457..cdbe651e3d 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -4,14 +4,14 @@ "license": "MPL-2.0", "author": "Kaleidos INC Sucursal en España SL", "private": true, - "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee", + "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" }, "type": "module", "devDependencies": { - "@types/node": "^26.1.2", - "esbuild": "^0.28.1" + "@types/node": "^26.3.0", + "esbuild": "^0.28.2" } } diff --git a/render-wasm/pnpm-lock.yaml b/render-wasm/pnpm-lock.yaml index 0d580aceed..c06712b52e 100644 --- a/render-wasm/pnpm-lock.yaml +++ b/render-wasm/pnpm-lock.yaml @@ -1,3 +1,104 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- lockfileVersion: '9.0' settings: @@ -9,175 +110,175 @@ importers: .: devDependencies: '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.3.0 + version: 26.3.0 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 packages: - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -186,115 +287,115 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@types/node@26.1.2': + '@types/node@26.3.0': dependencies: undici-types: 8.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 undici-types@8.3.0: {} From 9051b87f0e8309401b1629065b5679fb83775019 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 12:02:19 +0200 Subject: [PATCH 250/298] :paperclip: Update the update-changelog skill --- .opencode/skills/update-changelog/SKILL.md | 32 ++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.opencode/skills/update-changelog/SKILL.md b/.opencode/skills/update-changelog/SKILL.md index d456ce5bf9..d482959f94 100644 --- a/.opencode/skills/update-changelog/SKILL.md +++ b/.opencode/skills/update-changelog/SKILL.md @@ -477,9 +477,15 @@ There are exactly two types: release, but the PR is being released elsewhere — the fix may not actually ship here. 2. **PR is in the milestone, but the issue it closes is in a different - milestone (or has no milestone).** The PR is being released here, but - the issue it fixes is being released in a different version (or never - tracked in a milestone) — the changelog pairing is misleading. + milestone.** The PR is being released here, but the issue it fixes is + being released in a different version — the changelog pairing is + misleading. + + **Exception — issue with no milestone is NOT an anomaly.** Milestones + are only required for issues tracked in the "Main" project. A milestone + PR that closes an issue with no milestone references an issue from + another (probably private) project; that is expected and the issue is + not part of this changelog. Do not report it. **Anything else is not an anomaly.** Other discrepancies (exclusion labels on in-changelog issues, missing valid issues, unmerged PR @@ -635,6 +641,10 @@ for pr_num in sorted(changelog_prs): if get_pr_milestone(pr_num) != MILESTONE: continue for issue_num in pr.get('closing_issues', []): issue_ms = get_issue_milestone(issue_num) + # No milestone = issue from another (probably private) project — + # milestones are only required for the "Main" project. Not an + # anomaly, and the issue never belongs in this changelog. + if issue_ms is None: continue if issue_ms != MILESTONE: anomalies_b.append({ 'pr': pr_num, @@ -657,7 +667,7 @@ with open(OUTPUT, 'w') as f: f.write('## Summary\n\n') f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n') - f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n') + f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n') f.write(f'- **Total anomalies:** {n_a + n_b}\n\n') # --- Anomalies section --- @@ -686,7 +696,7 @@ with open(OUTPUT, 'w') as f: f.write('\n') if n_b: - f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n') + f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n') by_pr = {} for b in anomalies_b: by_pr.setdefault(b['pr'], []).append(b) @@ -721,8 +731,11 @@ milestone mismatches between issues and their referenced PRs: 1. **Issue in milestone, referenced PR in different milestone or no milestone** — the changelog claims a fix here, but the PR is released elsewhere. -2. **PR in milestone, closing issue in different milestone or no milestone** — +2. **PR in milestone, closing issue in a different milestone** — the PR is released here, but the issue it fixes belongs to another version. + (An issue with *no* milestone belongs to another, probably private, + project — milestones are only required on the "Main" project — so it is + neither an anomaly nor a changelog candidate.) **Rule violations are not in the report** — they are workflow errors the LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks). @@ -799,8 +812,11 @@ self-contained and clickable in any Markdown viewer. - **Anomaly = milestone mismatch only.** The report contains only milestone mismatches: (1) the issue is in this milestone but the referenced PR is in a different milestone (or unassigned), and (2) the PR is in this - milestone but the issue it closes is in a different milestone (or - unassigned). These are anomalies because the changelog pairing is + milestone but the issue it closes is in a different milestone. An + *unassigned* (milestone-less) issue closed by a milestone PR is **not** + an anomaly: milestones are required only for the "Main" project, so such + issues come from another (probably private) project and are not changelog + candidates. These anomalies are reported because the changelog pairing is *misleading* — the human needs to decide whether the milestone or the changelog is wrong. All other discrepancies (exclusion labels, missing valid issues, unmerged PR references, duplicates, stale milestone From 41ec134ea2831b1786aab00d6650d1b47ee07854 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 1 Sep 2026 12:02:57 +0200 Subject: [PATCH 251/298] :books: Update changelog --- CHANGES.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a414495e33..0adb7e213b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,18 +11,121 @@ - Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359)) - Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541)) - Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535)) -- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523)) - Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381)) +- Fix invalid formulas being accepted in numeric inputs (by @AKnassa) [#9581](https://github.com/penpot/penpot/issues/9581) (PR: [#10659](https://github.com/penpot/penpot/pull/10659)) +- Fix radial gradient handles blowing up in size when rotated on ellipses (by @AKnassa) [#10069](https://github.com/penpot/penpot/issues/10069) (PR: [#10666](https://github.com/penpot/penpot/pull/10666)) +- Fix plugin API validation errors being too generic to diagnose the failure (by @AKnassa) [#10072](https://github.com/penpot/penpot/issues/10072) (PR: [#10667](https://github.com/penpot/penpot/pull/10667)) +- Fix crash with referential integrity error when deleting a component inside a grid (by @Alotor) [#10101](https://github.com/penpot/penpot/issues/10101) (PR: [#10956](https://github.com/penpot/penpot/pull/10956)) +- Fix component copies not preserving rotation when the main component has changes [#10109](https://github.com/penpot/penpot/issues/10109) (PR: [#10574](https://github.com/penpot/penpot/pull/10574)) +- Fix text width and height staying stale after setting growType in the plugin API [#10207](https://github.com/penpot/penpot/issues/10207) (PR: [#9898](https://github.com/penpot/penpot/pull/9898)) +- Fix padding not painted until expanding the 4-sides padding option [#10278](https://github.com/penpot/penpot/issues/10278) (PR: [#10602](https://github.com/penpot/penpot/pull/10602)) +- Fix files with custom fonts breaking with a referential integrity error when moved between teams (by @filipsajdak) [#10496](https://github.com/penpot/penpot/issues/10496) (PR: [#10837](https://github.com/penpot/penpot/pull/10837)) +- Fix clicking overlapping comment bubbles zooming to 20000% without showing the comments [#10526](https://github.com/penpot/penpot/issues/10526) (PR: [#10543](https://github.com/penpot/penpot/pull/10543)) +- Fix user menu subsections in the dashboard not closing when hovering away from the parent option (by @AKnassa) [#10549](https://github.com/penpot/penpot/issues/10549) (PR: [#10639](https://github.com/penpot/penpot/pull/10639)) +- Fix self-hosted env-generated config.js being cached for 7 days so PENPOT_FLAGS changes did not reach already-cached browsers (by @filipsajdak) [#10556](https://github.com/penpot/penpot/issues/10556) (PR: [#11146](https://github.com/penpot/penpot/pull/11146)) +- Fix color of selected text in light theme [#10570](https://github.com/penpot/penpot/issues/10570) (PR: [#10614](https://github.com/penpot/penpot/pull/10614)) +- Fix margin input order being inconsistent with padding inputs and between collapsed and expanded states [#10578](https://github.com/penpot/penpot/issues/10578) (PR: [#10797](https://github.com/penpot/penpot/pull/10797)) +- Fix uncaught DOMException when writing image/svg+xml content to the clipboard (by @AKnassa) [#10596](https://github.com/penpot/penpot/issues/10596) (PR: [#10663](https://github.com/penpot/penpot/pull/10663)) +- Fix tick icons not aligned in the font selector [#10597](https://github.com/penpot/penpot/issues/10597) (PR: [#10774](https://github.com/penpot/penpot/pull/10774)) +- Fix incorrect padding values when multiple shapes are selected [#10598](https://github.com/penpot/penpot/issues/10598) (PR: [#10602](https://github.com/penpot/penpot/pull/10602)) +- Fix integrity errors related to variants not being repaired [#10606](https://github.com/penpot/penpot/issues/10606) (PR: [#10768](https://github.com/penpot/penpot/pull/10768)) +- Fix changing password showing 'Password should be at least 8 characters' error on the old password field (by @AKnassa) [#10626](https://github.com/penpot/penpot/issues/10626) (PR: [#10661](https://github.com/penpot/penpot/pull/10661)) +- Fix stroke caps disappearing when dragging [#10633](https://github.com/penpot/penpot/issues/10633) (PR: [#10634](https://github.com/penpot/penpot/pull/10634)) +- Fix layout padding being saved as string after invalid input in multi-selection, causing persistence errors (by @niwinz) [#10638](https://github.com/penpot/penpot/issues/10638) (PR: [#10758](https://github.com/penpot/penpot/pull/10758)) +- Fix inconsistent theme handling between Penpot and plugins [#10676](https://github.com/penpot/penpot/issues/10676) (PR: [#10677](https://github.com/penpot/penpot/pull/10677)) +- Fix image stroke (strokeImage) support missing in the plugin API Stroke interface [#10682](https://github.com/penpot/penpot/issues/10682) (PR: [#10683](https://github.com/penpot/penpot/pull/10683)) +- Fix SVG images not working as fill in the WebGL renderer [#10705](https://github.com/penpot/penpot/issues/10705) (PR: [#10707](https://github.com/penpot/penpot/pull/10707)) +- Fix background blur not working on text shapes [#10706](https://github.com/penpot/penpot/issues/10706) (PR: [#10712](https://github.com/penpot/penpot/pull/10712)) +- Fix background blur not applying on strokes [#10713](https://github.com/penpot/penpot/issues/10713) (PR: [#10716](https://github.com/penpot/penpot/pull/10716)) +- Fix text shape with empty content breaking workspace updates [#10725](https://github.com/penpot/penpot/issues/10725) (PR: [#10731](https://github.com/penpot/penpot/pull/10731)) +- Fix missing SVG option in the file filters when adding an image fill (by @LuBoys) [#10756](https://github.com/penpot/penpot/issues/10756) (PR: [#10771](https://github.com/penpot/penpot/pull/10771)) +- Update onboarding image [#10779](https://github.com/penpot/penpot/issues/10779) (PR: [#10783](https://github.com/penpot/penpot/pull/10783)) +- Fix main toolbar overlapping the grid edition bar [#10788](https://github.com/penpot/penpot/issues/10788) (PR: [#10789](https://github.com/penpot/penpot/pull/10789)) +- Fix WASM renderer panic when the WebGL context is restored mid-reload [#10810](https://github.com/penpot/penpot/issues/10810) (PR: [#10824](https://github.com/penpot/penpot/pull/10824)) +- Fix nginx frontend forwarding the client Host header to backend/exporter, breaking Istio strict mTLS routing (by @yamila-moreno) [#10835](https://github.com/penpot/penpot/issues/10835) (PR: [#11233](https://github.com/penpot/penpot/pull/11233)) +- Fix tutorial templates with components causing errors [#10839](https://github.com/penpot/penpot/issues/10839) +- Fix plugin 'Try out' flow crashing when projects have not loaded yet [#10858](https://github.com/penpot/penpot/issues/10858) (PR: [#10859](https://github.com/penpot/penpot/pull/10859)) +- Fix collapsed Fill color section on the design panel for new texts [#10860](https://github.com/penpot/penpot/issues/10860) (PR: [#10972](https://github.com/penpot/penpot/pull/10972)) +- Fix grid item date tooltip in the project view showing 'Will be deleted' instead of creation date (by @0xTHAC0) [#10873](https://github.com/penpot/penpot/issues/10873) (PR: [#11161](https://github.com/penpot/penpot/pull/11161)) +- Merge stop and start measurement shortcut to match current behavior [#10884](https://github.com/penpot/penpot/issues/10884) (PR: [#10906](https://github.com/penpot/penpot/pull/10906)) +- Fix shape size badge displayed twice when a user with Viewer permissions selects a shape [#10893](https://github.com/penpot/penpot/issues/10893) (PR: [#10985](https://github.com/penpot/penpot/pull/10985)) +- Fix main menu being covered by the toolbar [#10902](https://github.com/penpot/penpot/issues/10902) (PR: [#10926](https://github.com/penpot/penpot/pull/10926)) +- Fix font family typography asset persisting across files in newly created text layers [#10925](https://github.com/penpot/penpot/issues/10925) (PR: [#11134](https://github.com/penpot/penpot/pull/11134)) +- Fix error raised when editing justified text [#10944](https://github.com/penpot/penpot/issues/10944) (PR: [#10945](https://github.com/penpot/penpot/pull/10945)) +- Fix MCP WebSocket proxy failing after penpot-mcp container restarts due to stale nginx DNS resolution (by @780Farva) [#10946](https://github.com/penpot/penpot/issues/10946) (PR: [#10947](https://github.com/penpot/penpot/pull/10947)) +- Fix verification email address being unreadable due to low-contrast text on the register success page [#10950](https://github.com/penpot/penpot/issues/10950) (PR: [#10965](https://github.com/penpot/penpot/pull/10965)) +- Fix image swatches displaying a wrong format in the color picker list view [#10951](https://github.com/penpot/penpot/issues/10951) (PR: [#10975](https://github.com/penpot/penpot/pull/10975)) +- Fix text editor crashing when dropping dragged text after selecting all content [#10954](https://github.com/penpot/penpot/issues/10954) (PR: [#10959](https://github.com/penpot/penpot/pull/10959)) +- Fix MCP tokens being usable as API access tokens [#10960](https://github.com/penpot/penpot/issues/10960) (PR: [#10962](https://github.com/penpot/penpot/pull/10962)) +- Add size limit and rate limiting to the send-user-feedback endpoint [#10979](https://github.com/penpot/penpot/issues/10979) (PR: [#10990](https://github.com/penpot/penpot/pull/10990)) +- Fix main menu not keeping alignment when the left sidebar is expanded [#10981](https://github.com/penpot/penpot/issues/10981) (PR: [#10986](https://github.com/penpot/penpot/pull/10986)) +- Fix update-profile-props RPC method accepting undocumented keys [#10991](https://github.com/penpot/penpot/issues/10991) (PR: [#10992](https://github.com/penpot/penpot/pull/10992)) +- Fix import-binfile RPC method schema accepting a file-id parameter [#10993](https://github.com/penpot/penpot/issues/10993) (PR: [#10994](https://github.com/penpot/penpot/pull/10994)) +- Fix assemble-chunks session lookup ignoring the profile-id scope [#11011](https://github.com/penpot/penpot/issues/11011) (PR: [#11012](https://github.com/penpot/penpot/pull/11012)) +- Validate font-id team ownership in create-font-variant [#11013](https://github.com/penpot/penpot/issues/11013) (PR: [#11014](https://github.com/penpot/penpot/pull/11014)) +- Validate team ownership on file library link endpoints [#11015](https://github.com/penpot/penpot/issues/11015) (PR: [#11016](https://github.com/penpot/penpot/pull/11016)) +- Limit object size allocation in the V1 binfile parser [#11017](https://github.com/penpot/penpot/issues/11017) (PR: [#11018](https://github.com/penpot/penpot/pull/11018)) +- Limit recursion depth in the Fressian reader [#11019](https://github.com/penpot/penpot/issues/11019) (PR: [#11020](https://github.com/penpot/penpot/pull/11020)) +- Limit concurrent imports in the import-binfile RPC method [#11023](https://github.com/penpot/penpot/issues/11023) (PR: [#11024](https://github.com/penpot/penpot/pull/11024)) +- Validate content-type on management upload endpoints [#11025](https://github.com/penpot/penpot/issues/11025) (PR: [#11026](https://github.com/penpot/penpot/pull/11026)) +- Fix webhook endpoints allowing unauthorized access via creator-id fallback [#11028](https://github.com/penpot/penpot/issues/11028) (PR: [#11029](https://github.com/penpot/penpot/pull/11029)) +- Escape markdown in user-controlled fields of Mattermost error notifications [#11033](https://github.com/penpot/penpot/issues/11033) (PR: [#11034](https://github.com/penpot/penpot/pull/11034)) +- Enforce file read permission check on asset endpoints [#11035](https://github.com/penpot/penpot/issues/11035) (PR: [#11036](https://github.com/penpot/penpot/pull/11036)) +- Add accumulated storage byte quota for media uploads [#11037](https://github.com/penpot/penpot/issues/11037) (PR: [#11038](https://github.com/penpot/penpot/pull/11038)) +- Add bounding box dimension limit to exports [#11041](https://github.com/penpot/penpot/issues/11041) (PR: [#11042](https://github.com/penpot/penpot/pull/11042)) +- Sanitize embedded scripts in SVG uploads [#11043](https://github.com/penpot/penpot/issues/11043) (PR: [#11044](https://github.com/penpot/penpot/pull/11044)) +- Fix duplicate file ID returning inconsistent error responses [#11045](https://github.com/penpot/penpot/issues/11045) (PR: [#11050](https://github.com/penpot/penpot/pull/11050)) +- Enforce permission checks in WebSocket subscription handlers [#11052](https://github.com/penpot/penpot/issues/11052) (PR: [#11054](https://github.com/penpot/penpot/pull/11054)) +- Fix 'something went wrong' popup when using incremental numerical input interaction [#11053](https://github.com/penpot/penpot/issues/11053) (PR: [#10794](https://github.com/penpot/penpot/pull/10794)) +- Enforce password complexity validation on the backend [#11055](https://github.com/penpot/penpot/issues/11055) (PR: [#11059](https://github.com/penpot/penpot/pull/11059)) +- Normalize string inputs before processing [#11060](https://github.com/penpot/penpot/issues/11060) (PR: [#11061](https://github.com/penpot/penpot/pull/11061)) +- Add cooldown to avoid sending duplicate invitation emails [#11062](https://github.com/penpot/penpot/issues/11062) (PR: [#11063](https://github.com/penpot/penpot/pull/11063)) +- Enable SSRF protection for organization SSO validation [#11064](https://github.com/penpot/penpot/issues/11064) (PR: [#11065](https://github.com/penpot/penpot/pull/11065)) +- Fix clone-file-media-object allowing to clone media objects from files without read access [#11087](https://github.com/penpot/penpot/issues/11087) (PR: [#11090](https://github.com/penpot/penpot/pull/11090)) +- Fix 404 error page logo not visible in dark mode [#11091](https://github.com/penpot/penpot/issues/11091) (PR: [#11167](https://github.com/penpot/penpot/pull/11167)) +- Fix incorrect permission handling when creating an invitation [#11098](https://github.com/penpot/penpot/issues/11098) (PR: [#11099](https://github.com/penpot/penpot/pull/11099)) +- Reject zero or negative total-chunks values in upload sessions [#11103](https://github.com/penpot/penpot/issues/11103) (PR: [#11104](https://github.com/penpot/penpot/pull/11104)) +- Fix import-binfile accepting unsupported version values without validation [#11105](https://github.com/penpot/penpot/issues/11105) (PR: [#11107](https://github.com/penpot/penpot/pull/11107)) +- Fix sessions remaining active on other devices after account deletion [#11114](https://github.com/penpot/penpot/issues/11114) (PR: [#11115](https://github.com/penpot/penpot/pull/11115)) +- Use random UUIDs for share link IDs instead of a predictable scheme [#11116](https://github.com/penpot/penpot/issues/11116) (PR: [#11117](https://github.com/penpot/penpot/pull/11117)) +- Fix plugin manifest fetch hanging indefinitely without timeout [#11119](https://github.com/penpot/penpot/issues/11119) (PR: [#11120](https://github.com/penpot/penpot/pull/11120)) +- Use constant-time comparison for shared key authentication [#11121](https://github.com/penpot/penpot/issues/11121) (PR: [#11122](https://github.com/penpot/penpot/pull/11122)) +- Fix ESC key not closing the comment input box after posting a comment in the workspace [#11128](https://github.com/penpot/penpot/issues/11128) (PR: [#11131](https://github.com/penpot/penpot/pull/11131)) +- Fix token edit modal crashing when resolving tokens with group nodes [#11143](https://github.com/penpot/penpot/issues/11143) (PR: [#11144](https://github.com/penpot/penpot/pull/11144)) +- Fix text editor crashing when pasting into an empty text shape [#11149](https://github.com/penpot/penpot/issues/11149) (PR: [#11150](https://github.com/penpot/penpot/pull/11150)) +- Fix comment avatars appearing on top of rulers when scrolling the canvas (by @filipsajdak) [#11163](https://github.com/penpot/penpot/issues/11163) (PR: [#11168](https://github.com/penpot/penpot/pull/11168)) +- Fix infinite loop of get-teams and get-team-members calls when granting team access from an email link [#11215](https://github.com/penpot/penpot/issues/11215) (PR: [#11223](https://github.com/penpot/penpot/pull/11223)) +- Fix RPC requests bypassing rate limiting with fractional bucket refill intervals [#11253](https://github.com/penpot/penpot/issues/11253) (PR: [#11254](https://github.com/penpot/penpot/pull/11254)) +- Fix tempfile bucket serving objects to any authenticated user instead of only the uploader [#11269](https://github.com/penpot/penpot/issues/11269) (PR: [#11270](https://github.com/penpot/penpot/pull/11270)) +- Fix increasing a value by clicking and dragging in a numeric input [#11274](https://github.com/penpot/penpot/issues/11274) (PR: [#11334](https://github.com/penpot/penpot/pull/11334)) +- Fix notification pill rendering unescaped HTML in the detail section when importing tokens [#11276](https://github.com/penpot/penpot/issues/11276) (PR: [#11275](https://github.com/penpot/penpot/pull/11275)) +- Fix share-link holders reading pages outside the authorized scope via the get-page RPC command [#11281](https://github.com/penpot/penpot/issues/11281) (PR: [#11284](https://github.com/penpot/penpot/pull/11284)) +- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290)) +- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317)) +- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359)) ### :sparkles: New features & Enhancements - Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354)) - Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677)) -- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433)) - Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146)) - Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461)) - Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459)) - +- Highlight the first matching font in the font list when searching (by @ai-mountain) [#3204](https://github.com/penpot/penpot/issues/3204) (PR: [#9512](https://github.com/penpot/penpot/pull/9512), [#10450](https://github.com/penpot/penpot/pull/10450)) +- Preserve token references when copying and pasting properties instead of resolving them to values (by @AKnassa) [#9582](https://github.com/penpot/penpot/issues/9582) (PR: [#10665](https://github.com/penpot/penpot/pull/10665)) +- Add waitForLayoutUpdate method to the plugin API [#10136](https://github.com/penpot/penpot/issues/10136) (PR: [#9898](https://github.com/penpot/penpot/pull/9898)) +- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275)) +- Simplify MCP server configuration for common MCP clients [#10355](https://github.com/penpot/penpot/issues/10355) (PR: [#10604](https://github.com/penpot/penpot/pull/10604)) +- Remove misleading MCP client JSON snippet from the key-generated modal (by @Shlok1729) [#10399](https://github.com/penpot/penpot/issues/10399) (PR: [#10415](https://github.com/penpot/penpot/pull/10415)) +- Preview font families in the font selector [#10403](https://github.com/penpot/penpot/issues/10403) (PR: [#10411](https://github.com/penpot/penpot/pull/10411)) +- Remember expanded/collapsed state of token sets in the color tokens picker (session scope) [#10551](https://github.com/penpot/penpot/issues/10551) (PR: [#10864](https://github.com/penpot/penpot/pull/10864)) +- Show token sets in reverse order by default in the color tokens picker (by @rhinocap) [#10552](https://github.com/penpot/penpot/issues/10552) (PR: [#10658](https://github.com/penpot/penpot/pull/10658)) +- Add multi-selection and bulk delete support to pages in the workspace sitemap [#10580](https://github.com/penpot/penpot/issues/10580) (PR: [#10581](https://github.com/penpot/penpot/pull/10581)) +- Add a grid/list view toggle for files in the dashboard [#10691](https://github.com/penpot/penpot/issues/10691) (PR: [#10692](https://github.com/penpot/penpot/pull/10692)) +- Migrate Docker images to Docker Hardened Images (DHI) [#10720](https://github.com/penpot/penpot/issues/10720) (PR: [#10732](https://github.com/penpot/penpot/pull/10732), [#10733](https://github.com/penpot/penpot/pull/10733), [#10734](https://github.com/penpot/penpot/pull/10734)) +- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675)) +- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964)) +- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138)) ## 2.17.2 ### :bug: Bugs fixed @@ -3033,7 +3136,7 @@ is a number of cores) - Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883) - Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895) - Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374) -- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047) +- Increment font size by 10 with shift+arrows [#1047](https://github.com/penpot/penpot/issues/1047) - New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799) - Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921) - Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823) From b458dc764e8a37bcac4ce320d318f34ac011def3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Tue, 1 Sep 2026 12:47:05 +0200 Subject: [PATCH 252/298] :wrench: Set wasm export by flag instead of team feature (#11449) --- common/src/app/common/features.cljc | 1 - .../src/app/main/data/exports/assets.cljs | 21 ++++--------------- .../app/main/data/workspace/clipboard.cljs | 2 +- frontend/src/app/plugins/shape.cljs | 4 ++-- render-wasm/docs/rendering_architecture.md | 7 +++---- 5 files changed, 10 insertions(+), 25 deletions(-) diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index fdb0fb347e..e1b12729e0 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -134,7 +134,6 @@ :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-text-editor-wasm "text-editor-wasm/v1" :feature-render-wasm "render-wasm/v1" - :feature-wasm-export "wasm-export/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" nil)) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 24b290c15d..4055da67ca 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -256,23 +256,10 @@ (def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (defn- wasm-export-enabled? - "WASM export is available when the `wasm-export/v1` feature is active AND - render-wasm is active for the current file. When render-wasm is inactive its - shape tree isn't loaded, so a client-side WASM render would crash. - - This governs the client-side render only; it says nothing about the exporter." [state] - (and (features/active-feature? state "wasm-export/v1") + (and (contains? cf/flags :wasm-export) (features/active-feature? state "render-wasm/v1"))) -(defn- wasm-export-available? - "Whether the *exporter* renders with render-wasm. Its `enable-wasm-export` - flag has to be on too, otherwise the browser backend does the work and the - job API would promise capabilities the server does not have." - [state] - (and (wasm-export-enabled? state) - (contains? cf/flags :wasm-export))) - (defn- use-wasm-export? "Whether to take the client-side WASM export path for `export`." [state export] @@ -308,7 +295,7 @@ :profile-id profile-id :cmd :export-shapes :wait true - :is-wasm (wasm-export-available? state)})] + :is-wasm (wasm-export-enabled? state)})] (rx/concat (dwp/force-persist-and-wait 400) @@ -337,7 +324,7 @@ :cmd cmd :profile-id profile-id :force-multiple true - :is-wasm (wasm-export-available? state)} + :is-wasm (wasm-export-enabled? state)} (some? name) (assoc :name name)) @@ -365,7 +352,7 @@ ;; locally. With wasm export active the job API is used instead: it ;; answers with the exporter's own object count and gives a handle ;; to cancel. - (->> (if (wasm-export-available? state) + (->> (if (wasm-export-enabled? state) (->> (rp/cmd! :create-export-job params) (rx/map (fn [{job-id :id :keys [total] :as job}] (vreset! resource-id (:resource-id job)) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 0b234dadc1..58d4404241 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -1203,7 +1203,7 @@ ;; Call exporter to get image URI, then fetch blob and resolve the deferred. (->> (if (and (features/active-feature? state "render-wasm/v1") - (features/active-feature? state "wasm-export/v1")) + (contains? cf/flags :wasm-export)) (rx/of {:uri (wasm.exports/export-image-uri export)}) (rp/cmd! :export {:exports [export] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index d096458b31..44059d3244 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -33,6 +33,7 @@ [app.common.types.shape.shadow :as ctss] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.exports.assets :as de] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] @@ -48,7 +49,6 @@ [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.variants :as dwv] - [app.main.features :as features] [app.main.repo :as rp] [app.main.store :as st] [app.plugins.exports :as exports] @@ -1528,7 +1528,7 @@ (u/not-valid plugin-id :export value) :else - (if (and (features/active-feature? @st/state "wasm-export/v1") + (if (and (contains? cf/flags :wasm-export) (contains? #{:jpeg :webp :png} (:type value :png))) ;; New export with wasm (let [uri (wasm.exports/export-image-uri diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md index 12812af022..9a6a090609 100644 --- a/render-wasm/docs/rendering_architecture.md +++ b/render-wasm/docs/rendering_architecture.md @@ -80,10 +80,9 @@ flowchart TB The client-side WASM export — rendering in the browser through the vector path (`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and -only when render-wasm is active and the `wasm-export/v1` feature is enabled -(instance-wide through the `enable-feature-wasm-export` flag, or per team -through the team `features` column). The exporter service keeps its own -`:wasm-export` flag to decide whether it can serve the headless WASM path. +only when render-wasm is active and the `enable-wasm-export` flag is set. The +exporter service reads the same flag to decide whether it can serve the +headless WASM path. **Multiple/batch export** (`request-multiple-export`) always runs **server-side** via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the From c7b3a0849f503d46224fa1591f01d62c045801be Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Tue, 1 Sep 2026 13:27:09 +0200 Subject: [PATCH 253/298] :bug: Fix colopicker image checkbox (#11456) --- .../app/main/ui/workspace/colorpicker.cljs | 16 +--- .../app/main/ui/workspace/colorpicker.scss | 96 +------------------ 2 files changed, 6 insertions(+), 106 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 37fec9e299..b5613862af 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -30,10 +30,10 @@ [app.main.ui.components.select :refer [select]] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.workspace.colorpicker.color-inputs :refer [color-inputs*]] [app.main.ui.workspace.colorpicker.color-tokens :refer [token-section*]] [app.main.ui.workspace.colorpicker.gradients :refer [gradients*]] @@ -476,16 +476,10 @@ (when (some? (:image current-color)) [:div {:class (stl/css :checkbox-option)} - [:label {:for "keep-aspect-ratio" - :class (stl/css-case :global/checked keep-aspect-ratio?)} - [:span {:class (stl/css-case :global/checked keep-aspect-ratio?)} - (when keep-aspect-ratio? - deprecated-icon/status-tick)] - (tr "media.keep-aspect-ratio") - [:input {:type "checkbox" - :id "keep-aspect-ratio" - :checked keep-aspect-ratio? - :on-change handle-change-keep-aspect-ratio}]]]) + [:> checkbox* {:id "keep-aspect-ratio" + :checked keep-aspect-ratio? + :on-change handle-change-keep-aspect-ratio + :label (tr "media.keep-aspect-ratio")}]]) [:> button* {:class (stl/css :choose-image) :variant "secondary" diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index 1e6541042e..09e5c5c41a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -112,105 +112,11 @@ inline-size: 100%; } -// TODO: Use a DS checkbox component .checkbox-option { display: flex; align-items: center; - margin-block: var(--sp-l) 0; + margin-block: var(--sp-l) var(--sp-m); margin-inline: 0; - - // The native checkbox markup (label, span, input, svg) has no dedicated - // classes; styling these elements directly is unavoidable. - label { - @include t.use-typography("body-small"); - - display: flex; - align-items: center; - gap: px2rem(6); - cursor: pointer; - color: var(--color-foreground-secondary); - - span { - --checkbox-icon-background-color: var(--color-background-quaternary); - --checkbox-icon-border-color: var(--color-foreground-secondary); - --checkbox-icon-foreground-color: var(--color-background-primary); - - display: flex; - justify-content: center; - align-items: center; - inline-size: $sz-16; - block-size: $sz-16; - min-inline-size: $sz-16; - min-block-size: $sz-16; - border-radius: $br-4; - background-color: var(--checkbox-icon-background-color); - border: $b-1 solid var(--checkbox-icon-border-color); - - svg { - display: none; - inline-size: $sz-16; - block-size: $sz-16; - stroke: var(--checkbox-icon-foreground-color); - } - - &:hover { - --checkbox-icon-border-color: var(--color-accent-primary-muted); - } - - &:focus { - --checkbox-icon-border-color: var(--color-accent-primary); - } - - &:global(.checked) { - --checkbox-icon-background-color: var(--color-accent-primary); - --checkbox-icon-border-color: var(--color-background-quaternary); - - svg { - display: flex; - justify-content: center; - align-items: center; - inline-size: $sz-12; - block-size: $sz-12; - stroke-width: 1.33px; - } - } - - &:global(.intermediate) { - --checkbox-icon-background-color: var(--color-foreground-secondary); - --checkbox-icon-foreground-color: var(--color-background-secondary); - - svg { - display: flex; - justify-content: center; - align-items: center; - inline-size: $sz-12; - block-size: $sz-12; - stroke-width: 1.33px; - } - } - - &:global(.unchecked) { - --checkbox-icon-border-color: var(--color-background-quaternary); - } - } - - input { - margin: 0; - } - - &:hover { - span { - --checkbox-icon-border-color: var(--color-accent-primary-muted); - } - } - - &:focus, - &:focus-within { - span { - --checkbox-icon-border-color: var(--color-accent-primary); - } - } - } } .token-color-title { From 99378dc02d2b7d19352fbdd111f20d797aaf9450 Mon Sep 17 00:00:00 2001 From: Luis de Dios <luis.dedios@kaleidos.net> Date: Tue, 1 Sep 2026 15:48:09 +0200 Subject: [PATCH 254/298] :bug: Fix font preview in assets breaks the font row (#11428) * :bug: Fix font preview in assets breaks the font row * :bug: Fix font height problem also in the font dropdown * :bug: Fix a small bug within the changes --------- Co-authored-by: Eva Marco <evamarcod@gmail.com> --- frontend/src/app/main/ui/dashboard/grid.scss | 5 + .../sidebar/options/menus/typography.cljs | 150 +++++++++++++++--- .../sidebar/options/menus/typography.scss | 4 + frontend/src/app/util/dom.cljs | 30 ++++ 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index a64e898019..832da19306 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168); } .library-typography-sample { + display: flex; + justify-content: center; + align-items: center; block-size: px2rem(20); + line-height: 1; margin-inline-end: var(--sp-xs); inline-size: px2rem(20); + overflow: hidden; } // ─── MISC ────────────────────────────────────── diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 53a5a26b01..3d41372e2c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -90,6 +90,97 @@ (constantly nil))))) @loaded?)) +;; --- OPTICAL CENTERING OF SAMPLE TEXT -------------------------------------- + +;; Fonts with exaggerated vertical metrics (huge ascender/descender, small +;; caps) render their line box lower within a fixed-height row, so a plain +;; `align-items: center` leaves the visible glyphs sitting low. We measure the +;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the +;; text by the computed offset so the visible glyphs are optically centered. +;; The offset is expressed in `em`, which makes it size-independent: the same +;; measurement corrects both the 16px `Ag` sample and the smaller font-name +;; labels in the font selector. + +(defonce ^:private optical-offset-cache (atom {})) + +(defn- optical-offset-key [family weight style text] + (dm/str family "|" weight "|" style "|" text)) + +(defn- optical-offset-em + "Vertical shift (in `em` units, i.e. relative to the font size) that centers + the ink of `text` within a single line box. + + For a centered line the shift reduces to the difference between the font-wide + and ink bounding boxes: + dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2. + Measuring at 16px and dividing the pixel shift by it yields the `em` value." + [family weight style text] + (when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]} + (dom/measure-text-metrics family weight style text 16)] + (let [dy (/ (+ (- ink-ascent font-ascent) + (- font-descent ink-descent)) + 2) + em (/ dy 16)] + ;; Round to avoid float noise leaking into the transform string. + (/ (js/Math.round (* em 10000)) 10000)))) + +(defn- load-optical-offset + [font-id family weight style text] + (let [key (optical-offset-key family weight style text)] + (if-let [cached (get @optical-offset-cache key)] + (p/resolved cached) + (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + (let [em (or (optical-offset-em family weight style text) 0)] + (swap! optical-offset-cache assoc key em) + em))))))) + +(defn- use-optical-offset + "Lazily resolve the optical-centering offset (in `em`) for sample text in a + given font, measuring once per font/sample and caching it. Falls back to 0 + when the font isn't available or the metrics can't be measured." + [font-id family weight style text] + (let [offset* (mf/use-state 0)] + (mf/use-effect + (mf/deps font-id family weight style text) + (fn [] + (let [cancelled? (volatile! false) + key (optical-offset-key family weight style text)] + (if (contains? @optical-offset-cache key) + (reset! offset* (get @optical-offset-cache key)) + (let [task (tm/schedule-on-idle + (fn [] + (-> (load-optical-offset font-id family weight style text) + (p/then + (fn [em] + (when-not @cancelled? + (reset! offset* em)))))))] + (fn [] + (vreset! cancelled? true) + (tm/dispose! task))))) + nil)) + (deref offset*))) + +(defn- sample-container-style + "Inline style that applies the typography font to the (clipped, fixed-height) + sample container." + [typography] + {:font-family (:font-family typography) + :font-weight (:font-weight typography) + :font-style (:font-style typography)}) + +(defn- sample-text-style + "Inline style that optically centers the sample glyphs. Must be applied to + the text node itself, not to the clipped container: a transform on an + `overflow: hidden` element moves its own clip region along with it, so it + would shift the whole box relative to the row instead of the glyphs inside it." + [em] + (when-not (zero? em) + {:transform (dm/str "translateY(" em "em)")})) + +;; --- FONT SELECTOR -------------------------------------------------------- + (mf/defc font-item-preview* "Row content with previews: a vector preview from the shared sprite for catalog fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't @@ -110,7 +201,18 @@ ;; we show the plain name rather than runtime-loading the whole catalog. in-sprite? (and attached? (contains? (:ids sprite) font-id)) fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) - loaded? (use-font-lazy-load font-id fallback?)] + loaded? (use-font-lazy-load font-id fallback?) + + ;; Optical centering for the fallback name (custom fonts the sprite + ;; doesn't cover): extreme vertical metrics would push the name low in + ;; the row, so shift it by the measured offset once the font is known. + ;; The label renders at `body-medium` (400/normal), which is the weight + ;; and style we measure against. + label-offset (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -118,8 +220,11 @@ :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] [:span {:class (stl/css :font-item-label) - :style (when loaded? - #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} + :style (cond-> {} + loaded? + (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) + (not (zero? label-offset)) + (assoc :transform (dm/str "translateY(" label-offset "em)")))} (:name font)]))) (mf/defc font-item* @@ -590,6 +695,11 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -624,10 +734,9 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -661,11 +770,9 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) :title (:name typography)} @@ -712,6 +819,11 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -769,10 +881,9 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -789,10 +900,9 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) :title (if name-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 967e9d7c0f..99fd0a398f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -119,6 +119,8 @@ $font-preview-box-height: 28px; inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + line-height: 1; + overflow: hidden; color: var(--color-foreground-primary); } @@ -171,8 +173,10 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; font-size: px2rem(16); + line-height: 1; block-size: $sz-32; padding: 0; + overflow: hidden; color: var(--color-foreground-primary); } diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 5995a51264..8049af8a62 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -949,6 +949,36 @@ {:ascent (.-fontBoundingBoxAscent measure) :descent (.-fontBoundingBoxDescent measure)})) +(defn measure-text-metrics + "Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text` + at `font-size` px for the given font. + + Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil + when the browser doesn't expose the bounding-box metrics. The font-wide + values track what CSS uses for the line box, while the ink ones track the + visible glyphs, which is what an optical centering shift needs." + ([family weight style] + (measure-text-metrics family weight style "Ag" 16)) + ([family weight style text font-size] + (let [element (.createElement globals/document "canvas") + context (.getContext element "2d") + _ (set! (.-font context) + (dm/str (or weight "400") " " (or style "normal") " " + font-size "px \"" family "\"")) + measure ^js (.measureText context (str text)) + font-ascent (.-fontBoundingBoxAscent measure) + font-descent (.-fontBoundingBoxDescent measure) + ink-ascent (.-actualBoundingBoxAscent measure) + ink-descent (.-actualBoundingBoxDescent measure)] + (when (and (number? font-ascent) + (number? font-descent) + (number? ink-ascent) + (number? ink-descent)) + {:font-ascent font-ascent + :font-descent font-descent + :ink-ascent ink-ascent + :ink-descent ink-descent})))) + (defn clone-node ([^js node] (clone-node node true)) From d1a54134bb473d30b51cb5d1cb71628bbdbf6abb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Tue, 1 Sep 2026 17:06:50 +0200 Subject: [PATCH 255/298] :sparkles: Add text and @font-face to WASM SVG export (#11442) * :sparkles: Add text and @font-face to WASM SVG export Emit selectable <text> elements and inject @font-face rules that reference the font URLs registered at load time. Text fill bypasses save_layer wrappers that SkSVGDevice drops. Text strokes are deferred to a follow-up PR. * :bug: Strip trailing commas from Skia SVG text positions Skia's SVG backend appends a trailing comma to per-glyph x/y lists. Firefox rejects the malformed attribute and drops glyph positioning, making exported text vanish or mis-render. --- frontend/src/app/render_wasm/api/fonts.cljs | 31 +++- render-wasm/src/globals.rs | 27 +++ render-wasm/src/render/fonts.rs | 167 +++++++++++++++++- render-wasm/src/render/svg/document.rs | 52 +++++- render-wasm/src/render/svg/fixtures.rs | 63 ++++++- render-wasm/src/render/svg/mod.rs | 85 +++++++-- ...ts__exports_solid_text_with_font_face.snap | 10 ++ render-wasm/src/render/svg/tests.rs | 27 +++ render-wasm/src/render/svg/text.rs | 19 ++ render-wasm/src/render/text.rs | 9 + render-wasm/src/utils.rs | 6 +- render-wasm/src/wasm/fonts.rs | 24 +++ 12 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap create mode 100644 render-wasm/src/render/svg/text.rs diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index c44cc81fa5..0b438c5e20 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -121,8 +121,28 @@ (aget shape-id-buffer 3))))) ;; IMPORTANT: Only TTF fonts can be stored. +(defn- store-font-url + [font-data font-url] + (when (and (wasm/live?) (some? font-url) (not (str/blank? font-url))) + (let [font-id-buffer (:family-id-buffer font-data) + encoder (js/TextEncoder.) + encoded (.encode encoder font-url) + size (.-byteLength encoded) + ptr (h/call wasm/internal-module "_alloc_bytes" size) + heap (gobj/get ^js wasm/internal-module "HEAPU8") + mem (js/Uint8Array. (.-buffer heap) ptr size)] + (.set mem encoded) + (h/call wasm/internal-module "_store_font_url" + (aget font-id-buffer 0) + (aget font-id-buffer 1) + (aget font-id-buffer 2) + (aget font-id-buffer 3) + (:weight font-data) + (:style font-data)) + true))) + (defn- store-font-buffer - [font-data font-array-buffer emoji? fallback?] + [font-data font-array-buffer font-url emoji? fallback?] (when (wasm/live?) (let [font-id-buffer (:family-id-buffer font-data) size (.-byteLength font-array-buffer) @@ -140,6 +160,7 @@ (:style font-data) emoji? fallback?) + (store-font-url font-data font-url) (clear-font-storage-failure! font-data) ;; Reported after the store call: subscribers react by measuring text. (rx/push! font-stored-stream (font-data-key font-data)) @@ -158,7 +179,8 @@ (fn [request] {:font-data font-data :emoji? emoji? - :fallback? (or fallback? (:fallback? request))})))) + :fallback? (or fallback? (:fallback? request)) + :font-url font-url})))) (defn- take-font-fetches! [font-url] @@ -176,9 +198,9 @@ (report-font-storage-failed! font-data)))) (defn- store-font-fetch! - [body {:keys [font-data emoji? fallback?]}] + [body {:keys [font-data emoji? fallback? font-url]}] (try - (let [stored? (store-font-buffer font-data body emoji? fallback?)] + (let [stored? (store-font-buffer font-data body font-url emoji? fallback?)] (when-not stored? (report-font-storage-failed! font-data)) stored?) @@ -275,6 +297,7 @@ ;; Deferred so consumers, which subscribe after dispatching the sync ;; that lands here, are listening when an already-stored font reports. (do + (store-font-url font-data uri) (clear-font-storage-failure! font-data) (tm/schedule #(rx/push! font-stored-stream (font-data-key font-data)))) (fetch-font font-data uri emoji? fallback?))) diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index b1de8c9271..b7956075a6 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -56,6 +56,11 @@ pub(crate) fn has_render_state() -> bool { unsafe { !RENDER_STATE.is_null() } } +#[inline(always)] +pub(crate) fn has_render_resources() -> bool { + unsafe { !RENDER_RESOURCES.is_null() } +} + #[inline(always)] pub(crate) fn get_resources() -> &'static mut RenderResources { unsafe { @@ -115,6 +120,28 @@ macro_rules! with_current_shape { }; } +/// Scoped override of the global render resources pointer for unit tests. +#[cfg(test)] +pub(crate) struct TestRenderResourcesGuard { + prev: *mut RenderResources, +} + +#[cfg(test)] +impl TestRenderResourcesGuard { + pub(crate) fn install(resources: &mut RenderResources) -> Self { + let prev = unsafe { RENDER_RESOURCES }; + unsafe { RENDER_RESOURCES = resources as *mut _ }; + Self { prev } + } +} + +#[cfg(test)] +impl Drop for TestRenderResourcesGuard { + fn drop(&mut self) { + unsafe { RENDER_RESOURCES = self.prev }; + } +} + /// Initializes GPUState. fn gpu_init() { unsafe { diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index 17bdb84290..5df5403bc4 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -1,5 +1,5 @@ use skia_safe::{self as skia, textlayout, Font, FontMgr}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::error::{Error, Result}; use crate::shapes::{FontFamily, FontStyle}; @@ -26,6 +26,9 @@ pub struct FontStore { debug_font: Font, ui_font: Font, fallback_fonts: HashSet<String>, + /// Source URL registered when the font was fetched (SVG export references + /// this in `@font-face` rules). + source_urls: HashMap<String, String>, } impl FontStore { @@ -55,6 +58,7 @@ impl FontStore { debug_font, ui_font, fallback_fonts: HashSet::new(), + source_urls: HashMap::new(), }) } @@ -131,6 +135,133 @@ impl FontStore { pub fn get_emoji_font(&self, _size: f32) -> Option<Font> { None } + + pub fn set_source_url(&mut self, alias: &str, url: String) { + if !url.is_empty() { + self.source_urls.insert(alias.to_string(), url); + } + } + + pub fn source_url(&self, alias: &str) -> Option<&str> { + self.source_urls.get(alias).map(String::as_str) + } + + /// Builds `@font-face` CSS rules for the given registered aliases. + /// + /// Each rule references the source URL registered for the alias at load + /// time. Aliases without a registered URL are omitted. + pub fn font_face_css_for_aliases(&self, aliases: &HashSet<String>) -> String { + let mut seen: HashSet<String> = HashSet::new(); + let mut css = String::new(); + + for alias in aliases { + let Some(typeface) = self + .font_provider + .match_family_style(alias, skia::FontStyle::default()) + else { + continue; + }; + + let family = typeface.family_name(); + let style = typeface.font_style(); + + // Skia's SVG backend derives `<text>` font descriptors from the + // typeface's own `SkFontStyle` using a quirky bucketed table (see + // `skia_svg_font_weight`). We must mirror it exactly here so each + // `@font-face` pairs with the `<text>` elements that reference it; + // otherwise, when several weights of the same family coexist, the + // browser cannot match the weight and silently falls back to 400. + let weight = skia_svg_font_weight(*style.weight()); + let slant = match style.slant() { + skia::font_style::Slant::Italic => "italic", + skia::font_style::Slant::Oblique => "oblique", + _ => "normal", + }; + let stretch = skia_svg_font_stretch(*style.width()); + + let dedup_key = format!("{family}|{weight}|{slant}|{stretch:?}"); + if !seen.insert(dedup_key) { + continue; + } + + let stretch_decl = stretch + .map(|s| format!("font-stretch:{s};")) + .unwrap_or_default(); + + let Some(url) = self.source_url(alias) else { + continue; + }; + let src = font_face_src_from_url(url); + + css.push_str(&format!( + "@font-face{{font-family:\"{family}\";font-style:{slant};font-weight:{weight};{stretch_decl}src:{src};}}", + )); + } + + css + } +} + +fn font_face_src_from_url(url: &str) -> String { + let format = font_format_from_url(url); + format!("url(\"{}\") format(\"{format}\")", css_escape_url(url)) +} + +fn font_format_from_url(url: &str) -> &'static str { + let path = url + .split('#') + .next() + .unwrap_or(url) + .split('?') + .next() + .unwrap_or(url); + if path.ends_with(".woff2") { + "woff2" + } else if path.ends_with(".woff") { + "woff" + } else if path.ends_with(".otf") { + "opentype" + } else { + "truetype" + } +} + +fn css_escape_url(url: &str) -> String { + url.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Reproduces the `font-weight` string that `SkSVGDevice::addTextAttributes` +/// writes on `<text>` elements for a given typeface weight. +fn skia_svg_font_weight(weight: i32) -> &'static str { + // Skia's table is ["100","200","300","normal","400","500","600","bold", + // "800","900"]; we substitute "400" for the omitted-normal bucket so the + // descriptor still resolves to weight 400. + const WEIGHTS: [&str; 10] = [ + "100", "200", "300", "400", "400", "500", "600", "bold", "800", "900", + ]; + let index = ((weight.clamp(100, 900) - 50) / 100) as usize; + WEIGHTS[index] +} + +/// Reproduces the `font-stretch` value `SkSVGDevice` writes for a typeface +/// width, returning `None` for the normal width (which Skia omits). +fn skia_svg_font_stretch(width: i32) -> Option<&'static str> { + const STRETCHES: [&str; 9] = [ + "ultra-condensed", + "extra-condensed", + "condensed", + "semi-condensed", + "normal", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded", + ]; + let index = width - 1; + if index == 4 { + return None; + } + STRETCHES.get(usize::try_from(index).ok()?).copied() } fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontProvider { @@ -144,3 +275,37 @@ fn load_default_provider(font_mgr: &FontMgr) -> skia::textlayout::TypefaceFontPr font_provider } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{FontFamily, FontStyle}; + use crate::uuid::Uuid; + + #[test] + fn font_face_css_uses_registered_url() { + let mut store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let alias = family.alias(); + store.set_source_url(&alias, "https://example.com/fonts/source.ttf".to_string()); + + let mut aliases = HashSet::new(); + aliases.insert(alias); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.contains("url(\"https://example.com/fonts/source.ttf\")")); + assert!(css.contains("format(\"truetype\")")); + assert!(!css.contains("base64,")); + } + + #[test] + fn font_face_css_skips_without_registered_url() { + let store = FontStore::try_new().expect("font store"); + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + let mut aliases = HashSet::new(); + aliases.insert(family.alias()); + let css = store.font_face_css_for_aliases(&aliases); + + assert!(css.is_empty()); + } +} diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 5724b61677..083c08f920 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -81,7 +81,8 @@ impl SvgLayerCanvas { } let prefix = format!("f{}_", self.frag_no); self.frag_no += 1; - self.out.push_str(&remap_ids(inner, &prefix)); + self.out + .push_str(&sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))); } pub(super) fn open_group(&mut self, attrs: &str) { @@ -119,7 +120,7 @@ impl SvgLayerCanvas { let inner = extract_inner_svg(&doc); let prefix = format!("f{}_", self.frag_no); self.frag_no += 1; - let geometry = remap_ids(inner, &prefix); + let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)); self.defs.push_str(&format!( "<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>" )); @@ -236,3 +237,50 @@ fn remap_ids(body: &str, prefix: &str) -> String { } out } + +/// Skia's SVG backend appends a trailing comma to list-valued `<text>` attrs +/// (`x`, `y`, `dx`, `dy`). Firefox rejects the malformed list and drops the +/// glyph positioning (text vanishes or mis-renders). +fn sanitize_skia_svg_fragment(body: &str) -> String { + const LIST_ATTRS: [&str; 4] = ["x=\"", "y=\"", "dx=\"", "dy=\""]; + let mut out = body.to_string(); + + for attr in LIST_ATTRS { + let mut search_from = 0; + while let Some(rel) = out[search_from..].find(attr) { + let value_start = search_from + rel + attr.len(); + let Some(end_rel) = out[value_start..].find('"') else { + break; + }; + let value_end = value_start + end_rel; + let trimmed_len = out[value_start..value_end] + .trim_end() + .trim_end_matches(',') + .len(); + if trimmed_len != value_end - value_start { + let trimmed = out[value_start..value_start + trimmed_len].to_string(); + out.replace_range(value_start..value_end, &trimmed); + search_from = value_start + trimmed_len + 1; + } else { + search_from = value_end + 1; + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::sanitize_skia_svg_fragment; + + #[test] + fn strips_trailing_comma_from_text_position_lists() { + let input = r#"<text x="1119, 1374.8594, 1584.332, " y="402, ">asd</text>"#; + let out = sanitize_skia_svg_fragment(input); + assert!(out.contains(r#"x="1119, 1374.8594, 1584.332""#)); + assert!(out.contains(r#"y="402""#)); + assert!(!out.contains("1584.332, \"")); + assert!(!out.contains("402, \"")); + } +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index c4cda3cff7..1359cbd29a 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -2,14 +2,26 @@ use skia_safe as skia; -use crate::render::RenderResources; -use crate::shapes::{Fill, Frame, Group, Rect, SolidColor, Type}; +use crate::globals::TestRenderResourcesGuard; +use crate::render::{FontStore, RenderResources}; +use crate::shapes::{ + Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign, + TextContent, TextDirection, TextSpan, Type, +}; use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; use super::render_tree_to_svg; +/// Font URL referenced in exported SVG `@font-face` rules. +pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf"; + +fn register_test_font_urls(fonts: &mut FontStore) { + let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal); + fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string()); +} + /// Deterministic UUID from a small integer, keeping snapshots stable. pub(super) fn uid(n: u32) -> Uuid { uuid_from_u32_quartet(0, 0, 0, n) @@ -64,8 +76,55 @@ pub(super) fn add_group( } } +/// Adds a single-line text shape using the embedded default font. +pub(super) fn add_solid_text( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + text: &str, + font_size: f32, + fill: skia::Color, +) { + let bounds = skia::Rect::from_ltrb(l, t, r, b); + let mut content = TextContent::new(bounds, GrowType::Fixed); + let line_height = 1.2; + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(fill))], + ); + content.add_paragraph(Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + vec![span], + )); + + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + // Set the selrect before the text type: `set_selrect` on a text shape + // eagerly relayouts (needing the font collection), which isn't available + // until the export installs it. The render recomputes text layout from the + // selrect anyway. + shape.set_selrect(l, t, r, b); + shape.set_shape_type(Type::Text(content)); +} + pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { let mut resources = RenderResources::try_new_headless().expect("headless resources"); + register_test_font_urls(&mut resources.fonts); + let _guard = TestRenderResourcesGuard::install(&mut resources); let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export"); String::from_utf8(bytes).expect("utf8 svg") } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 57207fbe22..b7b6e9c29a 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -1,13 +1,56 @@ use skia_safe::{self as skia}; +use std::collections::HashSet; + use crate::error::Result; -use crate::shapes::Type; +use crate::math::Bounds; +use crate::shapes::{Shape, Type}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; use super::vector::{render_leaf_content, VectorRenderer}; use super::RenderResources; +/// Collects the registered font aliases used by every text span in the subtree +/// rooted at `id`, so the exporter can emit `@font-face` rules for them. +fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet<String>) { + let Some(shape) = tree.get(id) else { + return; + }; + + if let Type::Text(_) = &shape.shape_type { + for paragraph in shape.get_text_content().paragraphs() { + for span in paragraph.children() { + out.insert(format!("{}", span.font_family)); + } + } + } + + for child_id in shape.children_ids_iter_forward(true) { + collect_font_aliases(tree, child_id, out); + } +} + +/// Bounds for the root `<svg>` width, height, and viewBox. +/// +/// Text uses [`Shape::layer_bounds`]: glyph metrics, at least the `selrect`, and +/// room for strokes/shadows/blur. Other shapes use `extrect` for overflow. +fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect { + if matches!(shape.shape_type, Type::Text(_)) { + let mut bounds = Bounds::from_rect(&shape.layer_bounds()); + if !shape.transform.is_identity() { + let mut matrix = shape.transform; + let center = shape.center(); + matrix.post_translate(center); + matrix.pre_translate(-center); + bounds.transform_mut(&matrix); + } + bounds.to_rect() + } else { + shape.extrect(tree, scale) + } +} + /// Renders a shape tree to an SVG document and returns the raw SVG bytes. /// /// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted @@ -16,8 +59,8 @@ use super::RenderResources; /// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native /// `<clipPath>`. /// -/// Special-case re-emission for shadows, layer blur, masks, and deferred -/// strokes is intentionally out of scope for this first cut. +/// Special-case re-emission for shadows, layer blur, masks, text strokes, and +/// deferred strokes is intentionally out of scope for this cut. pub fn render_to_svg( shared: &mut RenderResources, id: &Uuid, @@ -42,7 +85,7 @@ pub(crate) fn render_tree_to_svg( // children (and leaf effects like shadows when those land). Clipped frames // still resolve to roughly their selrect because `extrect` skips joining // children when `clip_content` is on. - let bounds = shape.extrect(tree, scale); + let bounds = svg_page_bounds(shape, tree, scale); let page_w = bounds.width() * scale; let page_h = bounds.height() * scale; @@ -50,15 +93,24 @@ pub(crate) fn render_tree_to_svg( let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; - let mut out = String::with_capacity(body.len() + defs.len() + 256); + let mut aliases = HashSet::new(); + collect_font_aliases(tree, id, &mut aliases); + let font_css = shared.fonts.font_face_css_for_aliases(&aliases); + + let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n"); out.push_str(&format!( "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \ width=\"{page_w}\" height=\"{page_h}\" viewBox=\"0 0 {page_w} {page_h}\">" )); - if !defs.is_empty() { + if !font_css.is_empty() || !defs.is_empty() { out.push_str("<defs>"); + if !font_css.is_empty() { + out.push_str(&format!( + "<style type=\"text/css\"><![CDATA[{font_css}]]></style>" + )); + } out.push_str(&defs); out.push_str("</defs>"); } @@ -72,12 +124,13 @@ pub(crate) fn render_tree_to_svg( mod document; mod frames; mod groups; +mod text; use document::SvgLayerCanvas; use frames::render_frame; use groups::render_group; +use text::render_text_fill; -use crate::shapes::Shape; use document::effect_attrs; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. @@ -134,13 +187,17 @@ fn render_leaf( } { - let matrix = element.centered_transform(); - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); - render_leaf_content(&mut renderer, element)?; - canvas.restore(); + if matches!(element.shape_type, Type::Text(_)) { + render_text_fill(builder, element)?; + } else { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale); + render_leaf_content(&mut renderer, element)?; + canvas.restore(); + } } if effects.is_some() { diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap new file mode 100644 index 0000000000..d1f4dd9b15 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_solid_text_with_font_face.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs> + <text fill="#E17FDA" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181"> + HOLA + </text> +</svg> diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 388ef2f09b..4076f27719 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -174,3 +174,30 @@ fn exports_an_unclipped_frame_with_overflowing_child() { ); insta::assert_snapshot!(svg); } + +#[test] +fn exports_solid_text_with_font_face() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_text( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, + skia::Color::from_rgb(0xE1, 0x7F, 0xDA), + ); + + let svg = render(&pool, id); + assert!(svg.contains("<text"), "text glyphs must be present: {svg}"); + assert!( + svg.contains("@font-face") && svg.contains(TEST_FONT_URL), + "missing @font-face for registered font URL: {svg}" + ); + // Fixed-size text exports at the selrect, not tight glyph bounds. + assert!( + svg.contains("width=\"560\" height=\"240\""), + "fixed text should export at selrect size: {svg}" + ); + insta::assert_snapshot!(svg); +} diff --git a/render-wasm/src/render/svg/text.rs b/render-wasm/src/render/svg/text.rs new file mode 100644 index 0000000000..a7af393461 --- /dev/null +++ b/render-wasm/src/render/svg/text.rs @@ -0,0 +1,19 @@ +use crate::error::Result; +use crate::shapes::Shape; + +use super::document::SvgLayerCanvas; +use crate::render::text; + +/// Emits a text shape's fill as native `<text>` elements. +/// +/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice` +/// silently drops. Text strokes are handled separately in a later PR. +pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> { + let matrix = element.centered_transform(); + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + text::paint_text_fill(canvas, element); + canvas.restore(); + Ok(()) +} diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index e027aebd21..291768f8d9 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -395,6 +395,15 @@ fn render_text_on_canvas( canvas.restore(); } +/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that +/// `SkSVGDevice` would drop. +pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) { + let text_content = shape.get_text_content(); + let text_content = text_content.new_bounds(shape.selrect()); + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false); +} + /// Lays out and paints paragraph builders without any layer management. fn paint_text( canvas: &Canvas, diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 2301846c10..e153e03b97 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -34,7 +34,11 @@ pub fn get_fallback_fonts() -> &'static HashSet<String> { } pub fn get_font_collection() -> &'static FontCollection { - with_state!(state, { state.font_collection() }) + if crate::globals::has_render_resources() { + get_resources().fonts.font_collection() + } else { + with_state!(state, { state.font_collection() }) + } } #[derive(Debug, Clone, Copy)] diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs index 112d523035..f3ad1d4189 100644 --- a/render-wasm/src/wasm/fonts.rs +++ b/render-wasm/src/wasm/fonts.rs @@ -1,5 +1,6 @@ use macros::{wasm_error, ToJs}; +use crate::error::Error; use crate::get_resources; use crate::mem; use crate::render::FontStore; @@ -55,6 +56,29 @@ pub extern "C" fn store_font( Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn store_font_url( + a: u32, + b: u32, + c: u32, + d: u32, + weight: u32, + style: u8, +) -> Result<()> { + let id = uuid_from_u32_quartet(a, b, c, d); + let url_bytes = mem::bytes(); + let url = String::from_utf8(url_bytes) + .map_err(|_| Error::CriticalError("Invalid UTF-8 in font source URL".to_string()))?; + mem::free_bytes()?; + + let font_style = RawFontStyle::from(style); + let family = FontFamily::new(id, weight, font_style.into()); + get_resources().fonts.set_source_url(&family.alias(), url); + + Ok(()) +} + /// Resets the font store to its default state, dropping every font uploaded via /// `store_font`. A headless host that reuses a single WASM instance across /// requests must call this per render so fonts don't accumulate unbounded. From b46ed37141665778a85f6aa18abbd5953c27efe9 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Wed, 2 Sep 2026 10:28:16 +0200 Subject: [PATCH 256/298] :bug: Fix size-limiting-stream read arity on v3 binfile import (#11468) The FilterInputStream proxy only implemented read() and read(byte[], int, int). Buffered reads call read(byte[]) (and read(byte[], int) via Clojure interop), causing ArityException while hashing storage objects and breaking v3 imports. Implement all read overloads and extract shared byte-count logic. --- backend/src/app/binfile/v3.clj | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 436be3b943..d8bab4d25c 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -436,25 +436,26 @@ Raises :validation :max-file-size-reached when the limit is exceeded." ^InputStream [^InputStream input ^long max-size] - (let [counter (atom 0)] + (let [counter (atom 0) + on-read (fn [n] + (when (pos? n) + (when (> (swap! counter + (long n)) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + n)] (proxy [FilterInputStream] [input] (read ([] (let [b (.read input)] - (when (pos? b) - (when (> (swap! counter inc) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) + (when (pos? b) (on-read 1)) b)) - ([buf off len] - (let [n (.read input buf off len)] - (when (pos? n) - (when (> (swap! counter + (long n)) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) - n)))))) + ([^bytes buf] + (on-read (.read input buf 0 (alength buf)))) + ([^bytes buf off] + (on-read (.read input buf (int off) (- (alength buf) (int off))))) + ([^bytes buf off len] + (on-read (.read input buf (int off) (int len)))))))) (defn- zip-entry-reader [^ZipFile input ^ZipEntry entry] From b9ddfc1596b7033909f9772404853d5d8c564475 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Wed, 2 Sep 2026 10:32:52 +0200 Subject: [PATCH 257/298] :zap: Batch WASM shape upload to speed up page switches (#11443) Upload structural shape attrs (base, children, blur, shadows, flex, layout-item) via multi-shape `_set_shapes_batch` FFI in chunks of 512, then apply host attrs with use-shape selection. --- .../app/common/render_wasm/api/shapes.cljs | 189 ------- .../app/common/render_wasm/api/upload.cljs | 453 +++++++++++++++++ .../common/render_wasm/serialize_shape.cljs | 36 +- frontend/src/app/render_wasm/api.cljs | 307 ++++++++---- render-wasm/src/main.rs | 27 +- render-wasm/src/shapes.rs | 28 ++ render-wasm/src/state.rs | 30 ++ render-wasm/src/utils.rs | 9 + render-wasm/src/wasm/fills.rs | 2 +- render-wasm/src/wasm/layouts.rs | 3 + render-wasm/src/wasm/shapes/base_props.rs | 26 +- render-wasm/src/wasm/shapes/mod.rs | 1 + render-wasm/src/wasm/shapes/upload_batch.rs | 473 ++++++++++++++++++ render-wasm/src/wasm/strokes.rs | 24 +- render-wasm/src/wasm/text.rs | 1 + 15 files changed, 1224 insertions(+), 385 deletions(-) delete mode 100644 common/src/app/common/render_wasm/api/shapes.cljs create mode 100644 common/src/app/common/render_wasm/api/upload.cljs create mode 100644 render-wasm/src/wasm/shapes/upload_batch.rs diff --git a/common/src/app/common/render_wasm/api/shapes.cljs b/common/src/app/common/render_wasm/api/shapes.cljs deleted file mode 100644 index e507c1245e..0000000000 --- a/common/src/app/common/render_wasm/api/shapes.cljs +++ /dev/null @@ -1,189 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS SUBSIDIARY SL - -(ns app.common.render-wasm.api.shapes - "Batched shape property serialization for improved WASM performance. - - This module provides a single WASM call to set all base shape properties, - replacing multiple individual calls (use_shape, set_parent, set_shape_type, - etc.) with one batched operation." - (:require - [app.common.data :as d] - [app.common.data.macros :as dm] - [app.common.render-wasm.helpers :as h] - [app.common.render-wasm.mem :as mem] - [app.common.render-wasm.serializers :as sr] - [app.common.render-wasm.wasm :as wasm] - [app.common.uuid :as uuid])) - -;; Binary layout constants matching Rust implementation: -;; -;; | Offset | Size | Field | Type | -;; |--------|------|--------------|-----------------------------------| -;; | 0 | 16 | id | UUID (4 × u32 LE) | -;; | 16 | 16 | parent_id | UUID (4 × u32 LE) | -;; | 32 | 1 | shape_type | u8 | -;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) | -;; | 34 | 1 | blend_mode | u8 | -;; | 35 | 1 | constraint_h | u8 (0xFF = None) | -;; | 36 | 1 | constraint_v | u8 (0xFF = None) | -;; | 37 | 3 | padding | - | -;; | 40 | 4 | opacity | f32 LE | -;; | 44 | 4 | rotation | f32 LE | -;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) | -;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) | -;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) | -;; |--------|------|--------------|-----------------------------------| -;; | Total | 104 | | | - -(def ^:const BASE-PROPS-SIZE 104) -(def ^:const FLAG-CLIP-CONTENT 0x01) -(def ^:const FLAG-HIDDEN 0x02) -(def ^:const CONSTRAINT-NONE 0xFF) - -(defn- write-uuid-to-heap - "Write a UUID to the heap at the given byte offset using DataView." - [dview offset id] - (let [buffer (uuid/get-u32 id)] - (.setUint32 dview offset (aget buffer 0) true) - (.setUint32 dview (+ offset 4) (aget buffer 1) true) - (.setUint32 dview (+ offset 8) (aget buffer 2) true) - (.setUint32 dview (+ offset 12) (aget buffer 3) true))) - -(defn- serialize-transform - "Extract transform matrix values, defaulting to identity matrix." - [transform] - (if (some? transform) - [(dm/get-prop transform :a) - (dm/get-prop transform :b) - (dm/get-prop transform :c) - (dm/get-prop transform :d) - (dm/get-prop transform :e) - (dm/get-prop transform :f)] - [1.0 0.0 0.0 1.0 0.0 0.0])) ; identity matrix - -(defn- serialize-selrect - "Extract selrect values." - [selrect] - (if (some? selrect) - [(dm/get-prop selrect :x1) - (dm/get-prop selrect :y1) - (dm/get-prop selrect :x2) - (dm/get-prop selrect :y2)] - [0.0 0.0 0.0 0.0])) - -(defn set-shape-base-props - "Set all base shape properties in a single WASM call. - - This replaces the following individual calls: - - use-shape - - set-parent-id - - set-shape-type - - set-shape-clip-content - - set-shape-rotation - - set-shape-transform - - set-shape-blend-mode - - set-shape-opacity - - set-shape-hidden - - set-shape-selrect - - set-shape-corners - - set-shape-constraints (clear + h + v) - - Returns nil." - [shape] - (when (wasm/live?) - (let [id (dm/get-prop shape :id) - parent-id (get shape :parent-id) - shape-type (dm/get-prop shape :type) - - clip-content (if (= shape-type :frame) - (not (get shape :show-content)) - false) - hidden (get shape :hidden false) - - flags (cond-> 0 - clip-content (bit-or FLAG-CLIP-CONTENT) - hidden (bit-or FLAG-HIDDEN)) - - blend-mode (sr/translate-blend-mode (get shape :blend-mode)) - constraint-h (let [c (get shape :constraints-h)] - (sr/translate-constraint-h c)) - constraint-v (let [c (get shape :constraints-v)] - (sr/translate-constraint-v c)) - - opacity (d/nilv (get shape :opacity) 1.0) - rotation (d/nilv (get shape :rotation) 0.0) - - ;; Transform matrix - [ta tb tc td te tf] (serialize-transform (get shape :transform)) - - ;; Selrect - selrect (get shape :selrect) - [sx1 sy1 sx2 sy2] (serialize-selrect selrect) - - ;; Corners - r1 (d/nilv (get shape :r1) 0.0) - r2 (d/nilv (get shape :r2) 0.0) - r3 (d/nilv (get shape :r3) 0.0) - r4 (d/nilv (get shape :r4) 0.0) - - ;; Allocate buffer and get DataView - offset (mem/alloc BASE-PROPS-SIZE) - heap (mem/get-heap-u8) - dview (js/DataView. (.-buffer heap))] - - ;; Write id (offset 0, 16 bytes) - (write-uuid-to-heap dview offset id) - - ;; Write parent_id (offset 16, 16 bytes) - (write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero)) - - ;; Write shape_type (offset 32, 1 byte) - (.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type)) - - ;; Write flags (offset 33, 1 byte) - (.setUint8 dview (+ offset 33) flags) - - ;; Write blend_mode (offset 34, 1 byte) - (.setUint8 dview (+ offset 34) blend-mode) - - ;; Write constraint_h (offset 35, 1 byte) - (.setUint8 dview (+ offset 35) constraint-h) - - ;; Write constraint_v (offset 36, 1 byte) - (.setUint8 dview (+ offset 36) constraint-v) - - ;; Padding at offset 37-39 (already zero from alloc) - - ;; Write opacity (offset 40, f32) - (.setFloat32 dview (+ offset 40) opacity true) - - ;; Write rotation (offset 44, f32) - (.setFloat32 dview (+ offset 44) rotation true) - - ;; Write transform matrix (offset 48, 6 × f32) - (.setFloat32 dview (+ offset 48) ta true) - (.setFloat32 dview (+ offset 52) tb true) - (.setFloat32 dview (+ offset 56) tc true) - (.setFloat32 dview (+ offset 60) td true) - (.setFloat32 dview (+ offset 64) te true) - (.setFloat32 dview (+ offset 68) tf true) - - ;; Write selrect (offset 72, 4 × f32) - (.setFloat32 dview (+ offset 72) sx1 true) - (.setFloat32 dview (+ offset 76) sy1 true) - (.setFloat32 dview (+ offset 80) sx2 true) - (.setFloat32 dview (+ offset 84) sy2 true) - - ;; Write corners (offset 88, 4 × f32) - (.setFloat32 dview (+ offset 88) r1 true) - (.setFloat32 dview (+ offset 92) r2 true) - (.setFloat32 dview (+ offset 96) r3 true) - (.setFloat32 dview (+ offset 100) r4 true) - - (h/call wasm/internal-module "_set_shape_base_props") - - nil))) diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs new file mode 100644 index 0000000000..eb2e42f57c --- /dev/null +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -0,0 +1,453 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.api.upload + "Enlarged per-shape + multi-shape structural upload for WASM cold load. + + Writes a binary batch consumed by `_set_shapes_batch`. Remaining + host-specific attrs (image bytes, text, path, grid tracks) are applied + afterwards via the existing per-shape setters." + (:require + [app.common.buffer :as buf] + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] + [app.common.types.fills :as types.fills] + [app.common.types.fills.impl :as types.fills.impl] + [app.common.types.shape.layout :as ctl] + [app.common.uuid :as uuid])) + +(def ^:const BASE-PROPS-SIZE 104) +(def ^:const FLAG-CLIP-CONTENT 0x01) +(def ^:const FLAG-HIDDEN 0x02) + +(def ^:const SECTION-CHILDREN 0x01) +(def ^:const SECTION-BLUR-LAYER 0x02) +(def ^:const SECTION-BLUR-BG 0x04) +(def ^:const SECTION-SHADOWS 0x08) +(def ^:const SECTION-MASKED 0x10) +(def ^:const SECTION-BOOL-TYPE 0x20) +(def ^:const SECTION-GROW-TYPE 0x40) +(def ^:const SECTION-LAYOUT-ITEM 0x80) +(def ^:const SECTION-FLEX 0x100) +(def ^:const SECTION-FILLS 0x200) +(def ^:const SECTION-STROKES 0x400) + +;; Stroke header before RawFillData (must match upload_batch.rs). +(def ^:const STROKE-HEADER-U8-SIZE 36) +(def ^:const STROKE-ALIGN-CENTER 0) +(def ^:const STROKE-ALIGN-INNER 1) +(def ^:const STROKE-ALIGN-OUTER 2) + +(defn- write-uuid! + [dview offset id] + (buf/write-uuid dview offset id) + (+ offset 16)) + +(defn- write-base-props! + "Write the 104-byte RawBasePropsData at `offset`. Returns next offset." + [dview offset shape] + (let [id (dm/get-prop shape :id) + parent-id (get shape :parent-id) + shape-type (dm/get-prop shape :type) + clip-content (if (= shape-type :frame) + (not (get shape :show-content)) + false) + hidden (get shape :hidden false) + flags (cond-> 0 + clip-content (bit-or FLAG-CLIP-CONTENT) + hidden (bit-or FLAG-HIDDEN)) + blend-mode (sr/translate-blend-mode (get shape :blend-mode)) + constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none)) + constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none)) + opacity (d/nilv (get shape :opacity) 1.0) + rotation (d/nilv (get shape :rotation) 0.0) + transform (get shape :transform) + [ta tb tc td te tf] + (if (some? transform) + [(dm/get-prop transform :a) + (dm/get-prop transform :b) + (dm/get-prop transform :c) + (dm/get-prop transform :d) + (dm/get-prop transform :e) + (dm/get-prop transform :f)] + [1.0 0.0 0.0 1.0 0.0 0.0]) + selrect (get shape :selrect) + [sx1 sy1 sx2 sy2] + (if (some? selrect) + [(dm/get-prop selrect :x1) + (dm/get-prop selrect :y1) + (dm/get-prop selrect :x2) + (dm/get-prop selrect :y2)] + [0.0 0.0 0.0 0.0]) + r1 (d/nilv (get shape :r1) 0.0) + r2 (d/nilv (get shape :r2) 0.0) + r3 (d/nilv (get shape :r3) 0.0) + r4 (d/nilv (get shape :r4) 0.0)] + + (write-uuid! dview offset id) + (write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero)) + (buf/write-u8 dview (+ offset 32) (sr/translate-shape-type shape-type)) + (buf/write-u8 dview (+ offset 33) flags) + (buf/write-u8 dview (+ offset 34) blend-mode) + (buf/write-u8 dview (+ offset 35) constraint-h) + (buf/write-u8 dview (+ offset 36) constraint-v) + (buf/write-f32 dview (+ offset 40) opacity) + (buf/write-f32 dview (+ offset 44) rotation) + (buf/write-f32 dview (+ offset 48) ta) + (buf/write-f32 dview (+ offset 52) tb) + (buf/write-f32 dview (+ offset 56) tc) + (buf/write-f32 dview (+ offset 60) td) + (buf/write-f32 dview (+ offset 64) te) + (buf/write-f32 dview (+ offset 68) tf) + (buf/write-f32 dview (+ offset 72) sx1) + (buf/write-f32 dview (+ offset 76) sy1) + (buf/write-f32 dview (+ offset 80) sx2) + (buf/write-f32 dview (+ offset 84) sy2) + (buf/write-f32 dview (+ offset 88) r1) + (buf/write-f32 dview (+ offset 92) r2) + (buf/write-f32 dview (+ offset 96) r3) + (buf/write-f32 dview (+ offset 100) r4) + (+ offset BASE-PROPS-SIZE))) + +(defn- write-blur! + [dview offset blur] + (buf/write-u8 dview offset (if (get blur :hidden) 1 0)) + (buf/write-f32 dview (+ offset 4) (get blur :value 0)) + (+ offset 8)) + +(defn- write-shadow! + [dview offset shadow] + (let [color (get shadow :color) + rgba (sr-clr/hex->u32argb (get color :color) + (get color :opacity))] + (buf/write-u32 dview offset rgba) + (buf/write-f32 dview (+ offset 4) (get shadow :blur 0)) + (buf/write-f32 dview (+ offset 8) (get shadow :spread 0)) + (buf/write-f32 dview (+ offset 12) (get shadow :offset-x 0)) + (buf/write-f32 dview (+ offset 16) (get shadow :offset-y 0)) + (buf/write-u8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style))) + (buf/write-u8 dview (+ offset 21) (if (get shadow :hidden) 1 0)) + (+ offset 24))) + +(defn- write-flex! + [dview offset shape] + (let [dir (-> (get shape :layout-flex-dir :row) + (sr/translate-layout-flex-dir)) + gap (get shape :layout-gap) + row-gap (get gap :row-gap 0) + column-gap (get gap :column-gap 0) + align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items) + align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content) + justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items) + justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content) + wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type) + padding (get shape :layout-padding) + padding-top (get padding :p1 0) + padding-right (get padding :p2 0) + padding-bottom (get padding :p3 0) + padding-left (get padding :p4 0)] + (buf/write-u8 dview offset dir) + (buf/write-u8 dview (+ offset 1) align-items) + (buf/write-u8 dview (+ offset 2) align-content) + (buf/write-u8 dview (+ offset 3) justify-items) + (buf/write-u8 dview (+ offset 4) justify-content) + (buf/write-u8 dview (+ offset 5) wrap-type) + (buf/write-f32 dview (+ offset 8) row-gap) + (buf/write-f32 dview (+ offset 12) column-gap) + (buf/write-f32 dview (+ offset 16) padding-top) + (buf/write-f32 dview (+ offset 20) padding-right) + (buf/write-f32 dview (+ offset 24) padding-bottom) + (buf/write-f32 dview (+ offset 28) padding-left) + (+ offset 32))) + +(defn- write-layout-item! + [dview offset shape] + (let [margins (get shape :layout-item-margin) + margin-top (get margins :m1 0) + margin-right (get margins :m2 0) + margin-bottom (get margins :m3 0) + margin-left (get margins :m4 0) + h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing) + v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing) + align-self (-> (get shape :layout-item-align-self) sr/translate-align-self) + max-h (get shape :layout-item-max-h) + min-h (get shape :layout-item-min-h) + max-w (get shape :layout-item-max-w) + min-w (get shape :layout-item-min-w) + is-absolute (boolean (get shape :layout-item-absolute)) + z-index (get shape :layout-item-z-index) + flags (cond-> 0 + (some? max-h) (bit-or 0x01) + (some? min-h) (bit-or 0x02) + (some? max-w) (bit-or 0x04) + (some? min-w) (bit-or 0x08) + is-absolute (bit-or 0x10))] + (buf/write-f32 dview offset margin-top) + (buf/write-f32 dview (+ offset 4) margin-right) + (buf/write-f32 dview (+ offset 8) margin-bottom) + (buf/write-f32 dview (+ offset 12) margin-left) + (buf/write-u8 dview (+ offset 16) (d/nilv h-sizing 0)) + (buf/write-u8 dview (+ offset 17) (d/nilv v-sizing 0)) + (buf/write-u8 dview (+ offset 18) flags) + (buf/write-u8 dview (+ offset 19) (d/nilv align-self 0)) + (buf/write-f32 dview (+ offset 20) (d/nilv max-h 0)) + (buf/write-f32 dview (+ offset 24) (d/nilv min-h 0)) + (buf/write-f32 dview (+ offset 28) (d/nilv max-w 0)) + (buf/write-f32 dview (+ offset 32) (d/nilv min-w 0)) + (buf/write-i32 dview (+ offset 36) (d/nilv z-index 0)) + (+ offset 40))) + +(defn- write-fills-section! + "Write fills in the same layout as `_set_shape_fills`: + [u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset." + [dview offset fills] + (let [fills (types.fills/coerce (or fills [])) + byte-size (types.fills/get-byte-size fills) + ;; write-to expects a Uint32Array heap + u32 element offset + heap-u32 (js/Uint32Array. (.-buffer dview)) + u32-off (quot offset 4)] + (types.fills/write-to fills heap-u32 u32-off) + (+ offset byte-size))) + +(defn- write-stroke-fill! + [dview offset stroke] + (let [opacity (or (:stroke-opacity stroke) 1.0) + color (:stroke-color stroke) + gradient (:stroke-color-gradient stroke) + image (:stroke-image stroke)] + (cond + (some? gradient) + (types.fills.impl/write-gradient-fill offset dview opacity gradient) + + (some? image) + (types.fills.impl/write-image-fill offset dview opacity image) + + (some? color) + (types.fills.impl/write-solid-fill offset dview opacity color) + + :else + (types.fills.impl/write-solid-fill offset dview 0.0 "#000000")))) + +(defn- write-stroke! + [dview offset stroke] + (let [width (or (:stroke-width stroke) 1.0) + style (-> stroke :stroke-style sr/translate-stroke-style) + align (case (:stroke-alignment stroke) + :inner STROKE-ALIGN-INNER + :outer STROKE-ALIGN-OUTER + STROKE-ALIGN-CENTER) + cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) + cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + dash (or (:stroke-dash stroke) -1) + gap (or (:stroke-gap stroke) -1) + per-side? (boolean (:stroke-per-side stroke)) + top (or (:stroke-width-top stroke) width) + right (or (:stroke-width-right stroke) width) + bottom (or (:stroke-width-bottom stroke) width) + left (or (:stroke-width-left stroke) width) + has-sides? (and per-side? (not= top right bottom left))] + (buf/write-f32 dview offset width) + (buf/write-u8 dview (+ offset 4) style) + (buf/write-u8 dview (+ offset 5) align) + (buf/write-u8 dview (+ offset 6) (d/nilv cap-start 0)) + (buf/write-u8 dview (+ offset 7) (d/nilv cap-end 0)) + (buf/write-f32 dview (+ offset 8) dash) + (buf/write-f32 dview (+ offset 12) gap) + (buf/write-u8 dview (+ offset 16) (if has-sides? 1 0)) + (buf/write-f32 dview (+ offset 20) top) + (buf/write-f32 dview (+ offset 24) right) + (buf/write-f32 dview (+ offset 28) bottom) + (buf/write-f32 dview (+ offset 32) left) + (write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke) + (+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + +(defn- visible-strokes + [shape] + (let [type (dm/get-prop shape :type)] + (if (= type :group) + [] + (into [] (remove :hidden) (or (get shape :strokes) []))))) + +(defn- write-strokes-section! + [dview offset strokes] + (buf/write-u32 dview offset (count strokes)) + (reduce (fn [o s] (write-stroke! dview o s)) + (+ offset 4) + strokes)) + +(defn write-shape-payload! + "Serialize one shape's structural payload into `dview` starting at `offset` + (payload only — no length prefix). Returns the offset after the payload. + + Options: + - `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load). + - `:include-fills-strokes?` — when true, emit FILLS + STROKES sections." + [dview offset shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false + include-fills-strokes? false}}] + (let [shape-type (dm/get-prop shape :type) + children (into [] (filter uuid?) (get shape :shapes)) + blur (get shape :blur) + bg-blur (get shape :background-blur) + shadows (or (get shape :shadow) []) + masked? (and (= shape-type :group) (boolean (get shape :masked-group))) + bool-type (when (= shape-type :bool) (get shape :bool-type)) + grow-type (when (= shape-type :text) (get shape :grow-type)) + flex? (and include-layout? (ctl/flex-layout? shape)) + layout-item? include-layout? + strokes (when include-fills-strokes? (visible-strokes shape)) + + mask (cond-> 0 + true (bit-or SECTION-CHILDREN) + (some? blur) (bit-or SECTION-BLUR-LAYER) + (some? bg-blur) (bit-or SECTION-BLUR-BG) + (seq shadows) (bit-or SECTION-SHADOWS) + (= shape-type :group) (bit-or SECTION-MASKED) + (some? bool-type) (bit-or SECTION-BOOL-TYPE) + (some? grow-type) (bit-or SECTION-GROW-TYPE) + flex? (bit-or SECTION-FLEX) + layout-item? (bit-or SECTION-LAYOUT-ITEM) + include-fills-strokes? (bit-or SECTION-FILLS) + include-fills-strokes? (bit-or SECTION-STROKES)) + + offset (write-base-props! dview offset shape) + _ (buf/write-u32 dview offset mask) + offset (+ offset 4) + + offset (let [o offset] + (buf/write-u32 dview o (count children)) + (reduce (fn [o id] (write-uuid! dview o id)) + (+ o 4) + children)) + + offset (cond-> offset + (some? blur) + (as-> o (write-blur! dview o blur))) + + offset (cond-> offset + (some? bg-blur) + (as-> o (write-blur! dview o bg-blur))) + + offset (cond-> offset + (seq shadows) + (as-> o + (do + (buf/write-u32 dview o (count shadows)) + (reduce (fn [o s] (write-shadow! dview o s)) + (+ o 4) + shadows)))) + + offset (cond-> offset + (= shape-type :group) + (as-> o + (do (buf/write-u8 dview o (if masked? 1 0)) + (+ o 4)))) + + offset (cond-> offset + (some? bool-type) + (as-> o + (do (buf/write-u8 dview o (sr/translate-bool-type bool-type)) + (+ o 4)))) + + offset (cond-> offset + (some? grow-type) + (as-> o + (do (buf/write-u8 dview o (sr/translate-grow-type grow-type)) + (+ o 4)))) + + ;; FLEX before LAYOUT-ITEM (Rust clears layout on flex) + offset (cond-> offset + flex? + (as-> o (write-flex! dview o shape))) + + offset (cond-> offset + layout-item? + (as-> o (write-layout-item! dview o shape))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-fills-section! dview o (get shape :fills)))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-strokes-section! dview o strokes)))] + offset)) + +(defn- payload-byte-size + [shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false include-fills-strokes? false}}] + (let [children (into [] (filter uuid?) (get shape :shapes)) + shadows (or (get shape :shadow) []) + shape-type (dm/get-prop shape :type) + blur (get shape :blur) + bg-blur (get shape :background-blur) + flex? (and include-layout? (ctl/flex-layout? shape)) + fills-size (if include-fills-strokes? + (types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) []))) + 0) + strokes (when include-fills-strokes? (visible-strokes shape)) + strokes-size (if include-fills-strokes? + (+ 4 (* (count strokes) + (+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + 0)] + (+ BASE-PROPS-SIZE + 4 ;; mask + (+ 4 (* 16 (count children))) + (if (some? blur) 8 0) + (if (some? bg-blur) 8 0) + (if (seq shadows) (+ 4 (* 24 (count shadows))) 0) + (if (= shape-type :group) 4 0) + (if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0) + (if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0) + (if flex? 32 0) + (if include-layout? 40 0) + fills-size + strokes-size))) + +(defn- encode-shape-record + "Returns a Uint8Array: [u32 payload_len][payload]." + [shape opts] + (let [capacity (+ 4 (payload-byte-size shape opts)) + buffer (js/ArrayBuffer. capacity) + dview (js/DataView. buffer) + end (write-shape-payload! dview 4 shape opts) + payload-len (- end 4)] + (assert (= end capacity) + (str "upload record size mismatch: wrote " end " expected " capacity)) + (buf/write-u32 dview 0 payload-len) + (js/Uint8Array. buffer 0 end))) + +(defn flush-shapes-batch! + "Upload `shapes` as one `_set_shapes_batch` call. + `opts` passed to each record writer (`:include-layout?`, + `:include-fills-strokes?`)." + [shapes opts] + (when (and (wasm/live?) (seq shapes)) + (let [records (mapv #(encode-shape-record % opts) shapes) + total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records) + offset (mem/alloc total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap))] + (buf/write-u32 dview offset (count records)) + (reduce (fn [o ^js u8] + (.set heap u8 o) + (+ o (.-byteLength u8))) + (+ offset 4) + records) + (h/call wasm/internal-module "_set_shapes_batch") + nil))) + +(defn set-shape-upload! + "Single-shape structural upload (enlarged blob, one FFI)." + ([shape] + (set-shape-upload! shape {:include-layout? false})) + ([shape opts] + (flush-shapes-batch! [shape] opts))) diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 3f4111bb60..868c0321f5 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -12,43 +12,29 @@ - the workspace `app.render-wasm.api/set-object` (browser), and - the headless exporter `app.wasm.serialize/set-shape!` (Node). - It applies only the properties that need no host-specific resources or driver: - base props, children, blur, background blur, shadows, svg attrs, group mask, - bool type, path/bool geometry and text grow type. The parts that DO differ by - host are handled by each caller AFTER this runs: - - fills / strokes (image bytes are fetched + uploaded differently), + Structural attrs (base, children, blur, shadows, masked, bool, grow) go + through the enlarged `_set_shapes_batch` upload. Path geometry stays on the + chunked path FFI. Host-specific parts remain in each caller AFTER this runs: + - fills / strokes image bytes (records may already be in cold-load batch), - text content (fonts), - - svg-raw markup (browser renders it via React), - - layout (grid/flex — workspace only). + - svg-raw markup (browser React), + - layout (grid/flex — workspace cold-load batches flex+item via upload; + incremental edits still use `set-shape-layout` / `set-layout-data`). The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps dispatching per changed key through the same underlying `props` setters." (:require [app.common.render-wasm.api.props :as props] - [app.common.render-wasm.api.shapes :as shapes])) + [app.common.render-wasm.api.upload :as upload])) (defn serialize-shape! - "Applies every host-independent WASM property of `shape`. `set-shape-base-props` - runs first because it selects the current shape (`use_shape`) the rest mutate." + "Applies every host-independent WASM property of `shape`." [shape] (let [type (get shape :type)] - (shapes/set-shape-base-props shape) - (props/set-shape-children (get shape :shapes)) - (props/set-shape-blur (get shape :blur)) - (props/set-shape-background-blur (get shape :background-blur)) - (props/set-shape-shadows (get shape :shadow)) + (upload/set-shape-upload! shape {:include-layout? false}) (when (some? (get shape :svg-attrs)) (props/set-shape-svg-attrs (get shape :svg-attrs))) - (when (= type :group) - (props/set-masked (boolean (get shape :masked-group)))) - - (when (= type :bool) - (props/set-shape-bool-type (get shape :bool-type))) - (when (and (contains? #{:path :bool} type) (some? (get shape :content))) - (props/set-shape-path-content (get shape :content))) - - (when (= type :text) - (props/set-shape-grow-type (get shape :grow-type))))) + (props/set-shape-path-content (get shape :content))))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index aaf8112c7f..e50d3c15e9 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -17,6 +17,7 @@ [app.common.logging :as log] [app.common.math :as mth] [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.upload :as upload] [app.common.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem] [app.common.render-wasm.mem.heap32 :as mem.h32] @@ -280,8 +281,6 @@ (defonce ^:private view-interaction-active? (atom false)) -;; Time budget (ms) per chunk of shape processing before yielding to browser -(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) ;; Threshold below which we use synchronous processing (no chunking overhead) (def ^:const ASYNC_THRESHOLD 100) @@ -736,6 +735,17 @@ (aget buffer 3))] (= result 1)))) +(defn- write-text-content! + "Push every paragraph of `content` to the current WASM text shape." + [content] + (let [paragraph-set (first (get content :children)) + paragraphs (get paragraph-set :children)] + (doseq [paragraph paragraphs + :let [spans (get paragraph :children)] + :when (seq spans)] + (let [text (apply str (map :text spans))] + (t/write-shape-text spans paragraph text))))) + (defn set-shape-text-content "This function sets shape text content and returns a stream that loads the needed fonts asynchronously" [shape-id content] @@ -750,10 +760,11 @@ (set-shape-vertical-align (get content :vertical-align)) - (let [fonts (f/get-content-fonts content) - fallback-fonts (fonts-from-text-content content true) - all-fonts (concat fonts fallback-fonts) - result (f/store-fonts all-fonts)] + (let [fonts (f/get-content-fonts content) + fallback-fonts (fonts-from-text-content content false) + all-fonts (concat fonts fallback-fonts) + result (f/store-fonts all-fonts)] + (write-text-content! content) (f/load-fallback-fonts-for-editor! fallback-fonts) (h/call wasm/internal-module "_update_shape_text_layout") result))) @@ -1030,37 +1041,56 @@ (map #(process-fill-image shape-id % thumbnail?)))))) (defn set-shape-fills - [shape-id fills thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (when-let [fills (props/write-shape-fills! fills)] - (keep (fn [id] - (let [buffer (uuid/get-u32 id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id id thumbnail?)))) - (types.fills/get-image-ids fills)))) + "Writes fill records (unless `write?` is false) and returns pending image + fetches. When fills were already uploaded in `_set_shapes_batch`, pass + `write?` false so only image fetches remain." + ([shape-id fills thumbnail?] + (set-shape-fills shape-id fills thumbnail? true)) + ([shape-id fills thumbnail? write?] + (let [fills (if write? + (props/write-shape-fills! fills) + (when (seq fills) + (types.fills/coerce fills)))] + (when fills + (keep (fn [id] + (let [buffer (uuid/get-u32 id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id id thumbnail?)))) + (types.fills/get-image-ids fills)))))) + +(defn- stroke-image-ids + [strokes] + (into [] + (comp (remove :hidden) + (keep #(get-in % [:stroke-image :id]))) + (or strokes []))) (defn set-shape-strokes - [shape-id strokes thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (keep (fn [image-id] - (let [buffer (uuid/get-u32 image-id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id image-id thumbnail?)))) - (props/write-shape-strokes! strokes))) + "Writes stroke records (unless `write?` is false) and returns pending image + fetches for stroke image fills." + ([shape-id strokes thumbnail?] + (set-shape-strokes shape-id strokes thumbnail? true)) + ([shape-id strokes thumbnail? write?] + (let [image-ids (if write? + (props/write-shape-strokes! strokes) + (stroke-image-ids strokes))] + (keep (fn [image-id] + (let [buffer (uuid/get-u32 image-id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id image-id thumbnail?)))) + image-ids)))) (defn set-shape-svg-attrs [attrs] @@ -1545,47 +1575,70 @@ [content] (or content (tc/v2-default-text-content))) +(defn- set-object-host-attrs + "Host-specific attrs after structural upload (text/svg-raw/grid; optionally + fills/strokes). When `skip-layout?` is true, flex+layout-item were already in + the batch; only grid tracks/cells are applied here. When + `skip-fills-strokes?` is true, fill/stroke records were already in the batch; + only image fetches remain. + + Always `use-shape` first: after a multi-shape batch the WASM current shape is + the last record in the chunk, not this shape." + [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type) + fills (get shape :fills) + strokes (if (= type :group) [] (get shape :strokes)) + content (let [content (get shape :content)] + (if (= type :text) + (ensure-text-content content) + content)) + write-fills-strokes? (not skip-fills-strokes?) + needs-current? (or write-fills-strokes? + (= type :text) + (and (some? content) (= type :svg-raw)) + (if skip-layout? + (ctl/grid-layout? shape) + true))] + + (when needs-current? + (use-shape id)) + + (when (and (some? content) (= type :svg-raw)) + (set-shape-svg-raw-content (get-static-markup shape))) + + (if skip-layout? + (when (ctl/grid-layout? shape) + (set-grid-layout shape)) + (do (set-shape-layout shape) + (set-layout-data shape))) + + (let [is-text? (= type :text) + text-content-pending (when is-text? (set-shape-text-content id content)) + pending-thumbnails (into [] (concat + text-content-pending + (when is-text? (set-shape-text-images id content true)) + (set-shape-fills id fills true write-fills-strokes?) + (set-shape-strokes id strokes true write-fills-strokes?))) + pending-full (into [] (concat + (when is-text? (set-shape-text-images id content false)) + (set-shape-fills id fills false write-fills-strokes?) + (set-shape-strokes id strokes false write-fills-strokes?)))] + {:thumbnails pending-thumbnails + :full pending-full + :font-pending-ids (if (some :callback text-content-pending) [id] [])}))) + (defn set-object [shape] (if-not (and shape (wasm/live?)) {:thumbnails [] :full [] :font-pending-ids []} (do (perf/begin-measure "set-object") - (let [shape (svg-filters/apply-svg-derived shape) - id (dm/get-prop shape :id) - type (dm/get-prop shape :type) - - fills (get shape :fills) - strokes (if (= type :group) - [] (get shape :strokes)) - content (let [content (get shape :content)] - (if (= type :text) - (ensure-text-content content) - content))] - + (let [shape (svg-filters/apply-svg-derived shape)] (serialize-shape/serialize-shape! shape) - - ;; Browser-only: svg-raw markup (needs React) + workspace layout. - (when (and (some? content) (= type :svg-raw)) - (set-shape-svg-raw-content (get-static-markup shape))) - (set-shape-layout shape) - (set-layout-data shape) - (let [is-text? (= type :text) - text-content-pending (when is-text? (set-shape-text-content id content)) - pending-thumbnails (into [] (concat - text-content-pending - (when is-text? (set-shape-text-images id content true)) - (set-shape-fills id fills true) - (set-shape-strokes id strokes true))) - pending-full (into [] (concat - (when is-text? (set-shape-text-images id content false)) - (set-shape-fills id fills false) - (set-shape-strokes id strokes false)))] + (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") - {:thumbnails pending-thumbnails - :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])}))))) - + result))))) (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates to the worker index." @@ -1697,30 +1750,54 @@ :font-pending-ids (persistent! font-acc)}))] (process-pending shapes thumbnails full font-pending-ids noop-fn))) +(def ^:private ^:const BATCH_MAX_SHAPES 512) + (defn- process-shapes-chunk - "Process shapes starting at `start-index` until the time budget is exhausted. + "Process up to `BATCH_MAX_SHAPES` shapes starting at `start-index`. + + Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; + host-specific attrs (fills/strokes/text/grid/path) stay per-shape. + Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" [shapes start-index thumbnails-acc full-acc font-pending-acc] - (let [total (count shapes) - deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] - (loop [index start-index + (let [total (count shapes) + end-index (min total (+ start-index BATCH_MAX_SHAPES)) + chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) + start-index end-index)) + prepared (mapv svg-filters/apply-svg-derived chunk)] + + ;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes). + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + + ;; Path + svg-attrs still need the legacy per-shape path (variable/large). + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + + (loop [xs prepared t-acc (transient thumbnails-acc) f-acc (transient full-acc) fp-acc (transient font-pending-acc)] - (if (and (< index total) - ;; Check performance.now every 8 shapes to reduce overhead - (or (pos? (bit-and (- index start-index) 7)) - (<= (js/performance.now) deadline))) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) + (if-let [shape (first xs)] + (let [{:keys [thumbnails full font-pending-ids]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (next xs) (reduce conj! t-acc thumbnails) (reduce conj! f-acc full) (reduce conj! fp-acc font-pending-ids))) {:thumbnails (persistent! t-acc) :full (persistent! f-acc) :font-pending-ids (persistent! fp-acc) - :next-index index})))) + :next-index end-index})))) (defn- set-objects-async "Asynchronously process shapes in time-budgeted chunks, yielding to the @@ -1847,32 +1924,50 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] - (if (< index total-shapes) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) - (reduce conj! thumbnails-acc thumbnails) - (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) - {:thumbnails (persistent! thumbnails-acc) - :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (perf/end-measure "set-objects") - (when on-shapes-ready (on-shapes-ready)) - (when (wasm/live?) - ;; Rebuild the tile index so _render knows which shapes - ;; map to which tiles after a page switch. - (h/call wasm/internal-module "_set_view_end") - (reset! view-interaction-active? false) - (process-pending shapes thumbnails full font-pending-ids - (fn [] - (if render-callback - (render-callback) - (request-render "set-objects-sync-complete")) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [prepared (mapv svg-filters/apply-svg-derived shapes)] + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + (let [total-shapes (count prepared) + {:keys [thumbnails full font-pending-ids]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-acc (transient [])] + (if (< index total-shapes) + (let [shape (nth prepared index) + {:keys [thumbnails full font-pending-ids]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (inc index) + (reduce conj! thumbnails-acc thumbnails) + (reduce conj! full-acc full) + (reduce conj! font-acc font-pending-ids))) + {:thumbnails (persistent! thumbnails-acc) + :full (persistent! full-acc) + :font-pending-ids (persistent! font-acc)}))] + (perf/end-measure "set-objects") + (when on-shapes-ready (on-shapes-ready)) + (when (wasm/live?) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) + (process-pending shapes thumbnails full font-pending-ids + (fn [] + (if render-callback + (render-callback) + (request-render "set-objects-sync-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))) (defn- shapes-in-tree-order "Returns shapes sorted in tree order (parents before children). diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index f58ddd9492..433a68c94d 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -552,33 +552,8 @@ pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> } fn set_children_set(entries: Vec<Uuid>) -> Result<()> { - let mut deleted = Vec::new(); - let mut parent_id = None; - - with_current_shape_mut!(state, |shape: &mut Shape| { - parent_id = Some(shape.id); - (_, deleted) = shape.compute_children_differences(&entries); - shape.children = entries.clone(); - - for id in entries { - state.touch_shape(id); - if let Some(children_shape) = state.shapes.get_mut(&id) { - children_shape.set_deleted(false); - } - } - }); - with_state!(state, { - let Some(parent_id) = parent_id else { - return Err(Error::RecoverableError( - "set_children_set: Parent ID not found".to_string(), - )); - }; - - for id in deleted { - state.delete_shape_children(parent_id, id); - state.touch_shape(id); - } + state.set_current_shape_children(entries)?; }); Ok(()) } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e93fd35e24..9b9989a271 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -200,6 +200,10 @@ pub struct Shape { pub svg_transform: Option<Matrix>, pub ignore_constraints: bool, deleted: bool, + /// Fills from a cold-load batch, held until text content is uploaded and laid out. + deferred_batch_fills: Option<Vec<Fill>>, + /// Strokes from a cold-load batch, applied together with deferred fills. + deferred_batch_strokes: Option<Vec<Stroke>>, } // Returns all ancestor shapes of this shape, traversing up the parent hierarchy @@ -302,6 +306,8 @@ impl Shape { svg_transform: None, ignore_constraints: false, deleted: false, + deferred_batch_fills: None, + deferred_batch_strokes: None, } } @@ -665,6 +671,7 @@ impl Shape { } pub fn set_fills(&mut self, fills: Vec<Fill>) { + self.deferred_batch_fills = None; self.fills = fills; } @@ -707,10 +714,31 @@ impl Shape { } pub fn clear_strokes(&mut self) { + self.deferred_batch_strokes = None; self.invalidate_extrect(); self.strokes.clear(); } + pub fn set_deferred_batch_fills(&mut self, fills: Vec<Fill>) { + self.deferred_batch_fills = Some(fills); + } + + pub fn set_deferred_batch_strokes(&mut self, strokes: Vec<Stroke>) { + self.deferred_batch_strokes = Some(strokes); + } + + /// Apply fill/stroke records that were parsed from a batch upload but held + /// back until text content exists and has been laid out. + pub fn apply_deferred_batch_paint(&mut self) { + if let Some(fills) = self.deferred_batch_fills.take() { + self.fills = fills; + } + if let Some(strokes) = self.deferred_batch_strokes.take() { + self.strokes = strokes; + self.invalidate_extrect(); + } + } + pub fn set_path_segments(&mut self, segments: Vec<Segment>) { match &mut self.shape_type { Type::Bool(Bool { bool_type, .. }) => { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 09220283cf..5b2684ecb4 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -344,6 +344,36 @@ impl State { self.shapes.set_modifiers(modifiers); } + /// Replace the current shape's children list (same semantics as `_set_children`). + pub fn set_current_shape_children(&mut self, entries: Vec<Uuid>) -> Result<()> { + let (parent_id, deleted) = { + let Some(shape) = self.current_shape_mut() else { + return Err(Error::RecoverableError( + "set_current_shape_children: no current shape".to_string(), + )); + }; + + let id = shape.id; + let (_, deleted) = shape.compute_children_differences(&entries); + shape.children = entries.clone(); + (id, deleted) + }; + + for id in &entries { + self.touch_shape(*id); + if let Some(children_shape) = self.shapes.get_mut(id) { + children_shape.set_deleted(false); + } + } + + for id in deleted { + self.delete_shape_children(parent_id, id); + self.touch_shape(id); + } + + Ok(()) + } + pub fn touch_current(&mut self) { if let Some(current_id) = self.current_id { self.touch_shape(current_id); diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index e153e03b97..12c9c51f90 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -41,6 +41,15 @@ pub fn get_font_collection() -> &'static FontCollection { } } +/// A negative f32 means "unset" — the renderer falls back to its default. +pub fn decode_optional_f32(value: f32) -> Option<f32> { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } +} + #[derive(Debug, Clone, Copy)] #[repr(u8)] pub enum Browser { diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c0a8d6850d..c4084ea983 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -8,7 +8,7 @@ mod gradient; mod image; mod solid; -const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>(); +pub(crate) const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>(); #[repr(C, u8, align(4))] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index d179188e51..364ef49cbd 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -7,6 +7,9 @@ pub mod constraints; mod flex; mod grid; +pub use align::{RawAlignContent, RawAlignItems, RawAlignSelf, RawJustifyContent, RawJustifyItems}; +pub use flex::{RawFlexDirection, RawWrapType}; + #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] #[allow(dead_code)] diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index ab204a1f44..9ab65f78fe 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -1,4 +1,3 @@ -use crate::mem; use crate::shapes::{BlendMode, ConstraintH, ConstraintV}; use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; @@ -6,16 +5,14 @@ use crate::wasm::blend::RawBlendMode; use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; use crate::with_state; -#[allow(unused_imports)] -use crate::error::{Error, Result}; -use macros::wasm_error; +use crate::error::Result; use super::RawShapeType; const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; const FLAG_HIDDEN: u8 = 0b0000_0010; -const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>(); +pub(crate) const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>(); /// Binary layout for batched shape base properties. /// @@ -24,7 +21,7 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>(); #[repr(C)] #[repr(align(4))] #[derive(Debug, Clone, Copy)] -pub struct RawBasePropsData { +pub(crate) struct RawBasePropsData { // UUID id (16 bytes) id_a: u32, id_b: u32, @@ -100,21 +97,8 @@ impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData { } } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_shape_base_props() -> Result<()> { - let bytes = mem::bytes(); - - if bytes.len() < RAW_BASE_PROPS_SIZE { - return Ok(()); - } - - // FIXME: this should just be a try_from - let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE] - .try_into() - .map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?; - let raw = RawBasePropsData::from(data); - +/// Apply base props from a parsed record (selects the shape and sets core attrs). +pub(crate) fn apply_base_props(raw: &RawBasePropsData) -> Result<()> { let id = raw.id(); let parent_id = raw.parent_id(); let shape_type = RawShapeType::from(raw.shape_type); diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 901feb57f8..11175d3780 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,4 +1,5 @@ mod base_props; +mod upload_batch; use macros::ToJs; diff --git a/render-wasm/src/wasm/shapes/upload_batch.rs b/render-wasm/src/wasm/shapes/upload_batch.rs new file mode 100644 index 0000000000..99051842a4 --- /dev/null +++ b/render-wasm/src/wasm/shapes/upload_batch.rs @@ -0,0 +1,473 @@ +//! Multi-shape / enlarged cold-upload batch protocol. +//! +//! Buffer layout: +//! ```text +//! [u32 shape_count] +//! repeat shape_count times: +//! [u32 payload_len] // bytes after this u32 +//! [104 base props] // RawBasePropsData +//! [u32 section_mask] +//! optional sections (presence via mask; order is FIXED, not bit-numeric): +//! CHILDREN: [u32 n][n × 16 uuid] +//! BLUR_LAYER: [u8 hidden][u8;3 pad][f32 value] +//! BLUR_BG: same +//! SHADOWS: [u32 n][n × 24] +//! MASKED: [u8 value][u8;3 pad] +//! BOOL_TYPE: [u8 value][u8;3 pad] +//! GROW_TYPE: [u8 value][u8;3 pad] +//! FLEX: 32 bytes (clears container layout, then sets flex) +//! LAYOUT_ITEM: 40 bytes (must follow FLEX so clear_layout does not wipe it) +//! FILLS: [u8 n][u8;3][n × RawFillData] (same as set_shape_fills) +//! STROKES: [u32 n][n × (36-byte header + RawFillData)] +//! ``` +//! +//! Text, path geometry, and grid tracks/cells stay on the legacy +//! per-shape FFI path after the batch flush. + +use skia_safe as skia; + +use crate::mem; +use crate::shapes::{Blur, BlurType, Shadow, ShadowStyle, Stroke, Type}; +use crate::utils::{decode_optional_f32, uuid_from_u32_quartet}; +use crate::uuid::Uuid; +use crate::wasm::fills::{read_fills_from_bytes, RawFillData, RAW_FILL_DATA_SIZE}; +use crate::wasm::layouts::{ + RawAlignContent, RawAlignItems, RawAlignSelf, RawFlexDirection, RawJustifyContent, + RawJustifyItems, RawSizing, RawWrapType, +}; +use crate::wasm::paths::bools::RawBoolType; +use crate::wasm::shadows::RawShadowStyle; +use crate::wasm::shapes::base_props::{apply_base_props, RawBasePropsData, RAW_BASE_PROPS_SIZE}; +use crate::wasm::strokes::{RawStrokeCap, RawStrokeStyle}; +use crate::wasm::text::RawGrowType; +use crate::with_current_shape_mut; +use crate::with_state; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use macros::wasm_error; + +const SECTION_CHILDREN: u32 = 1 << 0; +const SECTION_BLUR_LAYER: u32 = 1 << 1; +const SECTION_BLUR_BG: u32 = 1 << 2; +const SECTION_SHADOWS: u32 = 1 << 3; +const SECTION_MASKED: u32 = 1 << 4; +const SECTION_BOOL_TYPE: u32 = 1 << 5; +const SECTION_GROW_TYPE: u32 = 1 << 6; +const SECTION_LAYOUT_ITEM: u32 = 1 << 7; +const SECTION_FLEX: u32 = 1 << 8; +const SECTION_FILLS: u32 = 1 << 9; +const SECTION_STROKES: u32 = 1 << 10; + +const STROKE_ALIGN_INNER: u8 = 1; +const STROKE_ALIGN_OUTER: u8 = 2; + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::CriticalError( + "upload_batch: truncated buffer".to_string(), + )); + } + let slice = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(slice) + } + + fn u32(&mut self) -> Result<u32> { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn u8(&mut self) -> Result<u8> { + Ok(self.take(1)?[0]) + } + + fn f32(&mut self) -> Result<f32> { + let b = self.take(4)?; + Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn i32(&mut self) -> Result<i32> { + let b = self.take(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn uuid(&mut self) -> Result<Uuid> { + let a = self.u32()?; + let b = self.u32()?; + let c = self.u32()?; + let d = self.u32()?; + Ok(uuid_from_u32_quartet(a, b, c, d)) + } +} + +fn read_base_props(cur: &mut Cursor<'_>) -> Result<RawBasePropsData> { + let bytes = cur.take(RAW_BASE_PROPS_SIZE)?; + let arr: [u8; RAW_BASE_PROPS_SIZE] = bytes + .try_into() + .map_err(|_| Error::CriticalError("upload_batch: bad base props".to_string()))?; + Ok(RawBasePropsData::from(arr)) +} + +fn apply_blur(layer: bool, hidden: bool, value: f32) { + with_current_shape_mut!(state, |shape: &mut Shape| { + let blur_type = if layer { + BlurType::LayerBlur + } else { + BlurType::BackgroundBlur + }; + let blur = Some(Blur::new(blur_type, hidden, value)); + if layer { + shape.set_blur(blur); + } else { + shape.set_background_blur(blur); + } + }); +} + +fn clear_blur(layer: bool) { + with_current_shape_mut!(state, |shape: &mut Shape| { + if layer { + shape.set_blur(None); + } else { + shape.set_background_blur(None); + } + }); +} + +fn apply_shadows(cur: &mut Cursor<'_>) -> Result<()> { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_shadows(); + }); + let n = cur.u32()? as usize; + for _ in 0..n { + let rgba = cur.u32()?; + let blur = cur.f32()?; + let spread = cur.f32()?; + let x = cur.f32()?; + let y = cur.f32()?; + let style = cur.u8()?; + let hidden = cur.u8()? != 0; + let _pad = cur.take(2)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + let color = skia::Color::new(rgba); + let style: ShadowStyle = RawShadowStyle::from(style).into(); + shape.add_shadow(Shadow::new(color, blur, spread, (x, y), style, hidden)); + }); + } + Ok(()) +} + +fn apply_layout_item(cur: &mut Cursor<'_>) -> Result<()> { + let margin_top = cur.f32()?; + let margin_right = cur.f32()?; + let margin_bottom = cur.f32()?; + let margin_left = cur.f32()?; + let h_sizing = cur.u8()?; + let v_sizing = cur.u8()?; + let flags = cur.u8()?; + let align_self = cur.u8()?; + let max_h = cur.f32()?; + let min_h = cur.f32()?; + let max_w = cur.f32()?; + let min_w = cur.f32()?; + let z_index = cur.i32()?; + + let has_max_h = (flags & 0x01) != 0; + let has_min_h = (flags & 0x02) != 0; + let has_max_w = (flags & 0x04) != 0; + let has_min_w = (flags & 0x08) != 0; + let is_absolute = (flags & 0x10) != 0; + + let h_sizing = RawSizing::from(h_sizing); + let v_sizing = RawSizing::from(v_sizing); + 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 align_self = RawAlignSelf::from(align_self).try_into().ok(); + + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_flex_layout_child_data( + margin_top, + margin_right, + margin_bottom, + margin_left, + h_sizing.into(), + v_sizing.into(), + max_h, + min_h, + max_w, + min_w, + align_self, + is_absolute, + z_index, + ); + }); + Ok(()) +} + +fn apply_flex(cur: &mut Cursor<'_>) -> Result<()> { + let dir = cur.u8()?; + let align_items = cur.u8()?; + let align_content = cur.u8()?; + let justify_items = cur.u8()?; + let justify_content = cur.u8()?; + let wrap_type = cur.u8()?; + let _pad = cur.take(2)?; + let row_gap = cur.f32()?; + let column_gap = cur.f32()?; + let padding_top = cur.f32()?; + let padding_right = cur.f32()?; + let padding_bottom = cur.f32()?; + let padding_left = cur.f32()?; + + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_layout(); + shape.set_flex_layout_data( + RawFlexDirection::from(dir).into(), + row_gap, + column_gap, + RawAlignItems::from(align_items).into(), + RawAlignContent::from(align_content).into(), + RawJustifyItems::from(justify_items).into(), + RawJustifyContent::from(justify_content).into(), + RawWrapType::from(wrap_type).into(), + padding_top, + padding_right, + padding_bottom, + padding_left, + ); + }); + Ok(()) +} + +fn apply_shape_payload(payload: &[u8]) -> Result<()> { + let mut cur = Cursor::new(payload); + let raw = read_base_props(&mut cur)?; + let mask = cur.u32()?; + apply_base_props(&raw)?; + + if mask & SECTION_CHILDREN != 0 { + let n = cur.u32()? as usize; + let mut entries = Vec::with_capacity(n); + for _ in 0..n { + entries.push(cur.uuid()?); + } + with_state!(state, { + state.set_current_shape_children(entries)?; + }); + } + + if mask & SECTION_BLUR_LAYER != 0 { + let hidden = cur.u8()? != 0; + let _ = cur.take(3)?; + let value = cur.f32()?; + apply_blur(true, hidden, value); + } else { + clear_blur(true); + } + + if mask & SECTION_BLUR_BG != 0 { + let hidden = cur.u8()? != 0; + let _ = cur.take(3)?; + let value = cur.f32()?; + apply_blur(false, hidden, value); + } else { + clear_blur(false); + } + + if mask & SECTION_SHADOWS != 0 { + apply_shadows(&mut cur)?; + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_shadows(); + }); + } + + if mask & SECTION_MASKED != 0 { + let masked = cur.u8()? != 0; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_masked(masked); + }); + } + + if mask & SECTION_BOOL_TYPE != 0 { + let raw_bool = cur.u8()?; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_bool_type(RawBoolType::from(raw_bool).into()); + }); + } + + if mask & SECTION_GROW_TYPE != 0 { + let raw_grow = cur.u8()?; + let _ = cur.take(3)?; + with_current_shape_mut!(state, |shape: &mut Shape| { + if let Type::Text(text_content) = &mut shape.shape_type { + text_content.set_grow_type(RawGrowType::from(raw_grow).into()); + } + }); + } + + // FLEX before LAYOUT_ITEM: clear_layout must not wipe the item we just set. + // Only clear when this payload owns layout (workspace cold-load). Exporter / + // serialize-shape! omit both bits and must not clobber existing layout. + if mask & SECTION_FLEX != 0 { + apply_flex(&mut cur)?; + } else if mask & SECTION_LAYOUT_ITEM != 0 { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_layout(); + }); + } + + if mask & SECTION_LAYOUT_ITEM != 0 { + apply_layout_item(&mut cur)?; + } + + let is_text_shape = with_state!(state, { + state + .current_shape() + .is_some_and(|shape| matches!(shape.shape_type, Type::Text(_))) + }); + + if mask & SECTION_FILLS != 0 { + let fills = parse_fills(&mut cur)?; + if is_text_shape { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_deferred_batch_fills(fills); + }); + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_fills(fills); + }); + } + } + + if mask & SECTION_STROKES != 0 { + let strokes = parse_strokes(&mut cur)?; + if is_text_shape { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_deferred_batch_strokes(strokes); + }); + } else { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_strokes(); + for stroke in strokes { + shape.add_stroke(stroke); + } + }); + } + } + + Ok(()) +} + +fn parse_fills(cur: &mut Cursor<'_>) -> Result<Vec<crate::shapes::Fill>> { + let header = cur.take(4)?; + let n = header[0] as usize; + let bytes = if n == 0 { + &[][..] + } else { + cur.take(n * RAW_FILL_DATA_SIZE)? + }; + Ok(read_fills_from_bytes(bytes, n)) +} + +fn parse_strokes(cur: &mut Cursor<'_>) -> Result<Vec<Stroke>> { + let n = cur.u32()? as usize; + let mut strokes = Vec::with_capacity(n); + for _ in 0..n { + let width = cur.f32()?; + let style = cur.u8()?; + let align = cur.u8()?; + let cap_start = cur.u8()?; + let cap_end = cur.u8()?; + let dash = cur.f32()?; + let gap = cur.f32()?; + let has_sides = cur.u8()? != 0; + let _pad = cur.take(3)?; + let top = cur.f32()?; + let right = cur.f32()?; + let bottom = cur.f32()?; + let left = cur.f32()?; + let fill_bytes = cur.take(RAW_FILL_DATA_SIZE)?; + let fill = RawFillData::try_from(fill_bytes) + .map_err(|e| Error::CriticalError(format!("upload_batch stroke fill: {e}")))?; + + let stroke_style = RawStrokeStyle::from(style); + let cap_start = RawStrokeCap::from(cap_start); + let cap_end = RawStrokeCap::from(cap_end); + let dash = decode_optional_f32(dash); + let gap = decode_optional_f32(gap); + + let mut stroke = match align { + STROKE_ALIGN_INNER => Stroke::new_inner_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + STROKE_ALIGN_OUTER => Stroke::new_outer_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + _ => Stroke::new_center_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + }; + if has_sides { + stroke.widths = Some([top, right, bottom, left]); + } + stroke.fill = fill.into(); + strokes.push(stroke); + } + Ok(strokes) +} + +/// Apply a multi-shape upload buffer previously written via `_alloc_bytes`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_shapes_batch() -> Result<()> { + let bytes = mem::bytes(); + if bytes.len() < 4 { + return Ok(()); + } + + let mut cur = Cursor::new(&bytes); + let count = cur.u32()? as usize; + for _ in 0..count { + let payload_len = cur.u32()? as usize; + let payload = cur.take(payload_len)?; + apply_shape_payload(payload)?; + } + + Ok(()) +} diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 647510ff0b..02f9bf1d6e 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -2,6 +2,7 @@ use macros::ToJs; use crate::mem; use crate::shapes::{self, StrokeCap, StrokeStyle}; +use crate::utils::decode_optional_f32; use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] @@ -68,17 +69,6 @@ impl TryFrom<RawStrokeCap> for StrokeCap { } } -// A negative value means "unset" — the renderer falls back to its default -// dash pattern. We use a sentinel instead of passing a bool because adding -// two f32 params keeps the FFI signature flat and allocation-free. -fn decode_optional(value: f32) -> Option<f32> { - if value.is_finite() && value >= 0.0 { - Some(value) - } else { - None - } -} - #[no_mangle] pub extern "C" fn add_shape_center_stroke( width: f32, @@ -98,8 +88,8 @@ pub extern "C" fn add_shape_center_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } @@ -123,8 +113,8 @@ pub extern "C" fn add_shape_inner_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } @@ -148,8 +138,8 @@ pub extern "C" fn add_shape_outer_stroke( stroke_style.into(), cap_start.try_into().ok(), cap_end.try_into().ok(), - decode_optional(dash), - decode_optional(gap), + decode_optional_f32(dash), + decode_optional_f32(gap), )); }); } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 6e2575741f..c154afbf89 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -376,6 +376,7 @@ fn update_text_layout(shape: &mut Shape, force: bool) { text_content.force_next_layout_update(); } text_content.update_layout(shape.selrect); + shape.apply_deferred_batch_paint(); shape.invalidate_extrect(); } } From 03e6f119e5b616ce2b959fb19620ea5d284b9bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Wed, 2 Sep 2026 12:58:52 +0200 Subject: [PATCH 258/298] :recycle: Clean unnecessary methods (#11472) * :recycle: Remove duplicated/unused set-children code * :recycle: Remove unused methods --- .../src/app/common/render_wasm/api/props.cljs | 17 -- frontend/src/app/render_wasm/api.cljs | 55 +---- render-wasm/src/main.rs | 179 --------------- render-wasm/src/render.rs | 31 --- render-wasm/src/render/options.rs | 41 ---- render-wasm/src/shapes.rs | 1 + render-wasm/src/wasm/text_editor.rs | 217 +----------------- 7 files changed, 5 insertions(+), 536 deletions(-) diff --git a/common/src/app/common/render_wasm/api/props.cljs b/common/src/app/common/render_wasm/api/props.cljs index fe8b7bb264..816c154cab 100644 --- a/common/src/app/common/render_wasm/api/props.cljs +++ b/common/src/app/common/render_wasm/api/props.cljs @@ -17,7 +17,6 @@ [app.common.math :as mth] [app.common.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem] - [app.common.render-wasm.mem.heap32 :as mem.h32] [app.common.render-wasm.serializers :as sr] [app.common.render-wasm.serializers.color :as sr-clr] [app.common.render-wasm.wasm :as wasm] @@ -27,22 +26,6 @@ (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) -(def ^:const UUID-U8-SIZE 16) - -(defn set-shape-children - "Uploads the child id list via the dynamic `_set_children` path (handles any - count). The browser also has fixed-arity fast paths for the incremental edit - path; this dynamic one is the shared/batch version." - [children] - (let [children (into [] (filter uuid?) children)] - (if (empty? children) - (h/call wasm/internal-module "_set_children_0") - (let [heap (mem/get-heap-u32) - size (mem/get-alloc-size children UUID-U8-SIZE) - offset (mem/alloc->offset-32 size)] - (reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children) - (h/call wasm/internal-module "_set_children"))))) - (defn set-shape-bool-type [bool-type] (h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e50d3c15e9..f09e2d7db2 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -844,61 +844,8 @@ [children] (perf/begin-measure "set-shape-children") (let [children (into [] (filter uuid?) children)] - (case (count children) - 0 + (if (empty? children) (h/call wasm/internal-module "_set_children_0") - - 1 - (let [[c1] children - c1 (uuid/get-u32 c1)] - (h/call wasm/internal-module "_set_children_1" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3))) - - 2 - (let [[c1 c2] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2)] - (h/call wasm/internal-module "_set_children_2" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3))) - - 3 - (let [[c1 c2 c3] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3)] - (h/call wasm/internal-module "_set_children_3" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3))) - - 4 - (let [[c1 c2 c3 c4] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3) - c4 (uuid/get-u32 c4)] - (h/call wasm/internal-module "_set_children_4" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3) - (aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3))) - - 5 - (let [[c1 c2 c3 c4 c5] children - c1 (uuid/get-u32 c1) - c2 (uuid/get-u32 c2) - c3 (uuid/get-u32 c3) - c4 (uuid/get-u32 c4) - c5 (uuid/get-u32 c5)] - (h/call wasm/internal-module "_set_children_5" - (aget c1 0) (aget c1 1) (aget c1 2) (aget c1 3) - (aget c2 0) (aget c2 1) (aget c2 2) (aget c2 3) - (aget c3 0) (aget c3 1) (aget c3 2) (aget c3 3) - (aget c4 0) (aget c4 1) (aget c4 2) (aget c4 3) - (aget c5 0) (aget c5 1) (aget c5 2) (aget c5 3))) - - ;; Dynamic call for children > 5 (let [heap (mem/get-heap-u32) size (mem/get-alloc-size children UUID-U8-SIZE) offset (mem/alloc->offset-32 size)] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 433a68c94d..125f553a0b 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -51,47 +51,6 @@ pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_viewport_interest_area_threshold( - viewport_interest_area_threshold: i32, -) -> Result<()> { - let render_state = get_render_state(); - render_state.set_viewport_interest_area_threshold(viewport_interest_area_threshold)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_max_blocking_time_ms(max_blocking_time_ms: i32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_max_blocking_time_ms(max_blocking_time_ms); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_node_batch_threshold(node_batch_threshold: i32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_node_batch_threshold(node_batch_threshold); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_blur_downscale_threshold(blur_downscale_threshold: f32) -> Result<()> { - let render_state = get_render_state(); - render_state.set_blur_downscale_threshold(blur_downscale_threshold); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { - get_render_state().set_antialias_threshold(threshold); - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { @@ -469,16 +428,6 @@ pub extern "C" fn has_shape(a: u32, b: u32, c: u32, d: u32) -> Result<bool> { }); } -#[no_mangle] -#[wasm_error] -pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state!(state, { - let shape_id = uuid_from_u32_quartet(a, b, c, d); - state.touch_shape(shape_id); - }); - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { @@ -541,16 +490,6 @@ pub extern "C" fn set_shape_transform( Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_current_shape_mut!(state, |shape: &mut Shape| { - let id = uuid_from_u32_quartet(a, b, c, d); - shape.add_child(id); - }); - Ok(()) -} - fn set_children_set(entries: Vec<Uuid>) -> Result<()> { with_state!(state, { state.set_current_shape_children(entries)?; @@ -566,124 +505,6 @@ pub extern "C" fn set_children_0() -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_1(a1: u32, b1: u32, c1: u32, d1: u32) -> Result<()> { - let entries = vec![uuid_from_u32_quartet(a1, b1, c1, d1)]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_2( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_3( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_4( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, - a4: u32, - b4: u32, - c4: u32, - d4: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - uuid_from_u32_quartet(a4, b4, c4, d4), - ]; - set_children_set(entries)?; - Ok(()) -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_children_5( - a1: u32, - b1: u32, - c1: u32, - d1: u32, - a2: u32, - b2: u32, - c2: u32, - d2: u32, - a3: u32, - b3: u32, - c3: u32, - d3: u32, - a4: u32, - b4: u32, - c4: u32, - d4: u32, - a5: u32, - b5: u32, - c5: u32, - d5: u32, -) -> Result<()> { - let entries = vec![ - uuid_from_u32_quartet(a1, b1, c1, d1), - uuid_from_u32_quartet(a2, b2, c2, d2), - uuid_from_u32_quartet(a3, b3, c3, d3), - uuid_from_u32_quartet(a4, b4, c4, d4), - uuid_from_u32_quartet(a5, b5, c5, d5), - ]; - set_children_set(entries)?; - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_children() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 95bb7b0df0..b5769f9c35 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -911,37 +911,6 @@ impl RenderState { .ensure_tile_atlas_layout(self.tile_viewbox.interest_rect.len().max(1) as usize); } - pub fn set_antialias_threshold(&mut self, value: f32) { - self.options.set_antialias_threshold(value); - } - - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> Result<()> { - // Only when this function returns true (it means the value - // was changed properly) the tile_viewbox.set_interest is called. - if self.options.set_viewport_interest_area_threshold(value) { - // The TileViewbox stores its own copy of `interest` (set at - // construction). Without propagating, options change wouldn't - // affect pending_tiles generation. - self.tile_viewbox - .set_interest(self.options.dpr_viewport_interest_area_threshold); - self.tile_viewbox.update(&self.viewbox); - self.ensure_tile_atlas_layout(); - } - Ok(()) - } - - pub fn set_node_batch_threshold(&mut self, value: i32) { - self.options.set_node_batch_threshold(value); - } - - pub fn set_max_blocking_time_ms(&mut self, value: i32) { - self.options.set_max_blocking_time_ms(value); - } - - pub fn set_blur_downscale_threshold(&mut self, value: f32) { - self.options.set_blur_downscale_threshold(value); - } - pub fn set_background_color(&mut self, color: skia::Color) { self.background_color = color; } diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 5dfe0ac2fb..727073f18e 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -121,45 +121,4 @@ impl RenderOptions { pub fn show_wasm_info(&self) -> bool { self.flags & SHOW_WASM_INFO == SHOW_WASM_INFO } - - pub fn set_antialias_threshold(&mut self, value: f32) -> bool { - if value.is_finite() && value > 0.0 { - self.antialias_threshold = value; - return true; - } - false - } - - pub fn set_blur_downscale_threshold(&mut self, value: f32) -> bool { - if value.is_finite() && value > 0.0 { - self.blur_downscale_threshold = value; - return true; - } - false - } - - pub fn set_viewport_interest_area_threshold(&mut self, value: i32) -> bool { - if value >= 0 && self.viewport_interest_area_threshold != value { - self.viewport_interest_area_threshold = value; - self.update_dpr_viewport_interest_area_threshold(); - return true; - } - false - } - - pub fn set_node_batch_threshold(&mut self, value: i32) -> bool { - if value > 0 { - self.node_batch_threshold = value; - return true; - } - false - } - - pub fn set_max_blocking_time_ms(&mut self, value: i32) -> bool { - if value > 0 { - self.max_blocking_time_ms = value; - return true; - } - false - } } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 9b9989a271..2db654e799 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -652,6 +652,7 @@ impl Shape { self.background_blur.filter(|blur| !blur.hidden) } + #[cfg(test)] pub fn add_child(&mut self, id: Uuid) { self.children.push(id); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 7f0e75465f..d7134b3bc2 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,12 +1,12 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; -use crate::math::{Matrix, Point, Rect}; +use crate::math::{Matrix, Point}; use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; -use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::{State, TextEditorEvent, TextSelection}; +use crate::shapes::{TextAlign, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::state::{State, TextEditorEvent}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::uuid::Uuid; @@ -643,40 +643,6 @@ pub extern "C" fn text_editor_move_cursor( // RENDERING & EXPORT // ============================================================================ -#[no_mangle] -pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { - with_state!(state, { - if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible { - return std::ptr::null_mut(); - } - - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return std::ptr::null_mut(); - }; - - let Some(shape) = state.shapes.get(&shape_id) else { - return std::ptr::null_mut(); - }; - - let Type::Text(text_content) = &shape.shape_type else { - return std::ptr::null_mut(); - }; - - let cursor = &get_text_editor_state().selection.focus; - - if let Some(rect) = get_cursor_rect(text_content, cursor, shape) { - let mut bytes = vec![0u8; 16]; - bytes[0..4].copy_from_slice(&rect.left().to_le_bytes()); - bytes[4..8].copy_from_slice(&rect.top().to_le_bytes()); - bytes[8..12].copy_from_slice(&rect.width().to_le_bytes()); - bytes[12..16].copy_from_slice(&rect.height().to_le_bytes()); - return mem::write_bytes(bytes); - } - - std::ptr::null_mut() - }) -} - #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { with_state!(state, { @@ -838,47 +804,6 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { }) } -#[no_mangle] -pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { - with_state!(state, { - if !get_text_editor_state().has_focus { - return std::ptr::null_mut(); - } - - if get_text_editor_state().selection.is_collapsed() { - return std::ptr::null_mut(); - } - - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return std::ptr::null_mut(); - }; - - let Some(shape) = state.shapes.get(&shape_id) else { - return std::ptr::null_mut(); - }; - - let Type::Text(text_content) = &shape.shape_type else { - return std::ptr::null_mut(); - }; - - let selection = &get_text_editor_state().selection; - let rects = get_selection_rects(text_content, selection, shape); - if rects.is_empty() { - return std::ptr::null_mut(); - } - - let mut bytes = Vec::with_capacity(4 + rects.len() * 16); - bytes.extend_from_slice(&(rects.len() as u32).to_le_bytes()); - for rect in rects { - bytes.extend_from_slice(&rect.left().to_le_bytes()); - bytes.extend_from_slice(&rect.top().to_le_bytes()); - bytes.extend_from_slice(&rect.width().to_le_bytes()); - bytes.extend_from_slice(&rect.height().to_le_bytes()); - } - mem::write_bytes(bytes) - }) -} - #[no_mangle] pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { get_text_editor_state().update_blink(timestamp_ms); @@ -1124,139 +1049,3 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool { true }) } - -// ============================================================================ -// HELPERS: Cursor & Selection -// ============================================================================ - -fn get_cursor_rect( - text_content: &TextContent, - cursor: &TextPositionWithAffinity, - shape: &Shape, -) -> Option<Rect> { - let paragraphs = text_content.paragraphs(); - if cursor.paragraph >= paragraphs.len() { - return None; - } - - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let valign_offset = match shape.vertical_align() { - VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0, - VerticalAlign::Bottom => shape.selrect().height() - total_height, - _ => 0.0, - }; - - let mut y_offset = valign_offset; - for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { - if idx == cursor.paragraph { - let utf16_pos = paragraphs[cursor.paragraph].char_offset_to_utf16(cursor.offset); - - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let rects = laid_out_para.get_rects_for_range( - utf16_pos..utf16_pos, - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - - let (x, height) = if !rects.is_empty() { - (rects[0].rect.left(), rects[0].rect.height()) - } else { - let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - let height = laid_out_para.height(); - (pos.position as f32, height) - }; - - let selrect = shape.selrect(); - let base_x = selrect.x(); - let base_y = selrect.y() + y_offset; - - return Some(Rect::from_xywh(base_x + x, base_y, 1.0, height)); - } - y_offset += laid_out_para.height(); - } - - None -} - -/// Get selection rectangles for a given selection. -fn get_selection_rects( - text_content: &TextContent, - selection: &TextSelection, - shape: &Shape, -) -> Vec<Rect> { - let mut rects = Vec::new(); - - let start = selection.start(); - let end = selection.end(); - - let paragraphs = text_content.paragraphs(); - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - - let selrect = shape.selrect(); - - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let valign_offset = match shape.vertical_align() { - VerticalAlign::Center => (selrect.height() - total_height) / 2.0, - VerticalAlign::Bottom => selrect.height() - total_height, - _ => 0.0, - }; - - let mut y_offset = valign_offset; - - for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { - let para_height = laid_out_para.height(); - - if para_idx < start.paragraph || para_idx > end.paragraph { - y_offset += para_height; - continue; - } - - if para_idx >= paragraphs.len() { - y_offset += para_height; - continue; - } - - let para = ¶graphs[para_idx]; - let para_char_count: usize = para - .children() - .iter() - .map(|span| span.text.chars().count()) - .sum(); - let range_start = if para_idx == start.paragraph { - start.offset - } else { - 0 - }; - - let range_end = if para_idx == end.paragraph { - end.offset - } else { - para_char_count - }; - - if range_start < range_end { - use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; - let text_boxes = laid_out_para.get_rects_for_range( - para.char_offset_to_utf16(range_start)..para.char_offset_to_utf16(range_end), - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - - for text_box in text_boxes { - let r = text_box.rect; - rects.push(Rect::from_xywh( - selrect.x() + r.left(), - selrect.y() + y_offset + r.top(), - r.width(), - r.height(), - )); - } - } - - y_offset += para_height; - } - - rects -} From b92112da542b8373f367d338b56e0b1385283acb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Wed, 2 Sep 2026 13:40:05 +0200 Subject: [PATCH 259/298] :bug: Export multiple solid fills to WASM SVG (#11467) Shapes and text with stacked solid fills were exported without fill attributes because merge_fills builds a Skia shader that SkSVGDevice does not serialize. Draw each fill layer separately for SVG export, bottom to top, matching the classic exporter. Closes #11466 --- render-wasm/src/render/svg/fixtures.rs | 44 ++++++++++-- render-wasm/src/render/svg/frames.rs | 4 +- render-wasm/src/render/svg/mod.rs | 2 +- ...orts_a_rect_with_multiple_solid_fills.snap | 9 +++ ...xports_text_with_multiple_solid_fills.snap | 13 ++++ render-wasm/src/render/svg/tests.rs | 67 ++++++++++++++++++- render-wasm/src/render/text.rs | 13 +++- render-wasm/src/render/vector.rs | 24 +++++-- render-wasm/src/shapes/text.rs | 65 +++++++++++++++--- 9 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 1359cbd29a..46729b94b0 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -34,12 +34,29 @@ pub(super) fn add_solid_rect( parent: Uuid, (l, t, r, b): (f32, f32, f32, f32), color: skia::Color, +) { + add_rect_with_fills( + pool, + id, + parent, + (l, t, r, b), + vec![Fill::Solid(SolidColor(color))], + ); +} + +/// Adds a rectangle with the given fill stack (bottom → top). +pub(super) fn add_rect_with_fills( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + fills: Vec<Fill>, ) { let shape = pool.add_shape(id); shape.set_parent(parent); shape.set_shape_type(Type::Rect(Rect::default())); shape.set_selrect(l, t, r, b); - shape.set_fills(vec![Fill::Solid(SolidColor(color))]); + shape.set_fills(fills); } /// Adds a solid-filled frame (board) to the pool. @@ -84,6 +101,25 @@ pub(super) fn add_solid_text( text: &str, font_size: f32, fill: skia::Color, +) { + add_text_with_fills( + pool, + id, + (l, t, r, b), + text, + font_size, + vec![Fill::Solid(SolidColor(fill))], + ); +} + +/// Adds a single-line text shape with the given fill stack (top → bottom). +pub(super) fn add_text_with_fills( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + text: &str, + font_size: f32, + fills: Vec<Fill>, ) { let bounds = skia::Rect::from_ltrb(l, t, r, b); let mut content = TextContent::new(bounds, GrowType::Fixed); @@ -99,7 +135,7 @@ pub(super) fn add_solid_text( TextDirection::LTR, 400, Uuid::nil(), - vec![Fill::Solid(SolidColor(fill))], + fills, ); content.add_paragraph(Paragraph::new( TextAlign::Left, @@ -113,10 +149,6 @@ pub(super) fn add_solid_text( let shape = pool.add_shape(id); shape.set_parent(Uuid::nil()); - // Set the selrect before the text type: `set_selrect` on a text shape - // eagerly relayouts (needing the font collection), which isn't available - // until the export installs it. The render recomputes text layout from the - // selrect anyway. shape.set_selrect(l, t, r, b); shape.set_shape_type(Type::Text(content)); } diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index f060a66ce6..fead20bbf4 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -34,7 +34,7 @@ pub(super) fn render_frame( let canvas = builder.canvas(); canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); renderer.draw_fills(element, &element.fills)?; canvas.restore(); } @@ -51,7 +51,7 @@ pub(super) fn render_frame( let canvas = builder.canvas(); canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); renderer.draw_strokes(element, &visible_strokes)?; canvas.restore(); } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index b7b6e9c29a..e992b1ad66 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -194,7 +194,7 @@ fn render_leaf( let canvas = builder.canvas(); canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); render_leaf_content(&mut renderer, element)?; canvas.restore(); } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..e8f99d11a0 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"> + <rect fill="#003FFF" width="100" height="80"/> + <rect fill="#F50000" fill-opacity="0.50196081" width="100" height="80"/> +</svg> diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..6a2f690ee4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- +<?xml version="1.0" encoding="utf-8" ?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs> + <text fill="#003FFF" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181"> + HOLA + </text> + <text fill="#F50000" fill-opacity="0.50196081" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181"> + HOLA + </text> +</svg> diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 4076f27719..5ed0fc81c3 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1,11 +1,44 @@ use super::fixtures::*; -use crate::shapes::BlendMode; +use crate::shapes::{BlendMode, Fill, SolidColor}; use crate::state::ShapesPool; use crate::uuid::Uuid; use skia_safe as skia; +#[test] +fn exports_a_rect_with_multiple_solid_fills() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_rect_with_fills( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + vec![ + // fills[0] is topmost in Penpot (red 50%). + Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))), + // fills[1] is underneath (blue 100%). + Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))), + ], + ); + + let svg = render(&pool, id); + assert!( + svg.matches("fill=\"#").count() >= 2, + "each solid fill must emit a fill attribute: {svg}" + ); + let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\""); + let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\""); + assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}"); + assert!(red_pos.is_some(), "missing top red fill: {svg}"); + assert!( + blue_pos.unwrap() < red_pos.unwrap(), + "bottom fill must appear before top fill in SVG: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_a_solid_rect() { let mut pool = ShapesPool::new(); @@ -175,6 +208,38 @@ fn exports_an_unclipped_frame_with_overflowing_child() { insta::assert_snapshot!(svg); } +#[test] +fn exports_text_with_multiple_solid_fills() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_text_with_fills( + &mut pool, + id, + (0.0, 0.0, 560.0, 240.0), + "HOLA", + 200.0, + vec![ + Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))), + Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))), + ], + ); + + let svg = render(&pool, id); + assert!( + svg.matches("fill=\"#").count() >= 2, + "each solid fill must emit a fill attribute: {svg}" + ); + let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\""); + let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\""); + assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}"); + assert!(red_pos.is_some(), "missing top red fill: {svg}"); + assert!( + blue_pos.unwrap() < red_pos.unwrap(), + "bottom fill must appear before top fill in SVG: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_solid_text_with_font_face() { let mut pool = ShapesPool::new(); diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 291768f8d9..89c232be69 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -400,8 +400,17 @@ fn render_text_on_canvas( pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) { let text_content = shape.get_text_content(); let text_content = text_content.new_bounds(shape.selrect()); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false); + let max_layers = text_content.max_fill_layers(); + if max_layers == 0 { + return; + } + + // Each fill layer is painted separately so SkSVGDevice can emit `fill` + // attributes (merged shaders are dropped). Bottom layer first. + for layer in 0..max_layers { + let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer); + paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false); + } } /// Lays out and paints paragraph builders without any layer management. diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 269abd76aa..b19117b9d4 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -22,14 +22,24 @@ pub(super) struct VectorRenderer<'a> { canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, + /// When `true`, multiple fills are composited into a single shader (PDF). + /// When `false`, each fill is drawn separately so SkSVGDevice can emit + /// `fill` attributes (SVG export). + compose_fills: bool, } impl<'a> VectorRenderer<'a> { - pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self { + pub fn new( + canvas: &'a Canvas, + shared: &'a mut RenderResources, + scale: f32, + compose_fills: bool, + ) -> Self { Self { canvas, shared, scale, + compose_fills, } } } @@ -40,9 +50,9 @@ impl ShapeRenderer for VectorRenderer<'_> { return Ok(()); } - // Handle image fills individually let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); - if has_image_fills { + if !self.compose_fills || has_image_fills { + // fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG). for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) => { @@ -845,7 +855,7 @@ fn render_frame( if !element.fills.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; renderer.draw_fill_inner_shadows(element)?; canvas.restore(); @@ -862,7 +872,7 @@ fn render_frame( if !visible_strokes.is_empty() { canvas.save(); canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_strokes(element, &visible_strokes)?; canvas.restore(); } @@ -900,7 +910,7 @@ fn render_container_drop_shadows( ); if draw_fills && !element.fills.is_empty() { - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); renderer.draw_fills(element, &element.fills)?; } @@ -943,7 +953,7 @@ fn render_leaf( canvas.save_layer(&layer_rec); } - let mut renderer = VectorRenderer::new(canvas, shared, scale); + let mut renderer = VectorRenderer::new(canvas, shared, scale, true); // Layer blur (non-text shapes) let blur_layer = if !matches!(element.shape_type, Type::Text(_)) { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 3bc29e96fd..61dfd5d15b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -707,13 +707,32 @@ impl TextContent { &self, use_shadow: Option<bool>, ) -> Vec<ParagraphBuilderGroup> { - self.paragraph_builders(use_shadow, false, None) + self.paragraph_builders(use_shadow, false, None, None) } /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). /// Used as a clip mask for inner stroke rendering. pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> { - self.paragraph_builders(None, true, None) + self.paragraph_builders(None, true, None, None) + } + + /// Maximum number of stacked fills across every span in this text block. + pub fn max_fill_layers(&self) -> usize { + self.paragraphs() + .iter() + .flat_map(|p| p.children()) + .map(|s| s.fills.len()) + .max() + .unwrap_or(0) + } + + /// Builds paragraph builders that paint a single fill layer per span for SVG + /// export. `layer_from_bottom` is 0 for the bottommost fill (fills[last]). + pub fn paragraph_builder_group_for_fill_layer( + &self, + layer_from_bottom: usize, + ) -> Vec<ParagraphBuilderGroup> { + self.paragraph_builders(None, false, None, Some(layer_from_bottom)) } fn paragraph_builders( @@ -721,6 +740,7 @@ impl TextContent { use_shadow: Option<bool>, opaque: bool, align_override: Option<skia::textlayout::TextAlign>, + fill_layer: Option<usize>, ) -> Vec<ParagraphBuilderGroup> { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); @@ -736,11 +756,12 @@ impl TextContent { for span in paragraph.children() { let remove_alpha = opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); - let text_style = span.to_style( + let text_style = span.to_style_with_paint( &self.bounds(), fallback_fonts, remove_alpha, paragraph.line_height(), + fill_layer, ); let text: String = span.apply_text_transform(); if !text.is_empty() { @@ -762,7 +783,7 @@ impl TextContent { fn text_layout_auto_width(&self) -> TextContentLayoutResult { // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. let mut measure_builders = - self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left)); + self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None); let normalized_line_height = calculate_normalized_line_height(&mut measure_builders, f32::MAX); @@ -1349,15 +1370,41 @@ impl TextSpan { remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { - let mut style = skia::textlayout::TextStyle::default(); - let mut paint = paint::Paint::default(); + self.to_style_with_paint( + content_bounds, + fallback_fonts, + remove_alpha, + paragraph_line_height, + None, + ) + } - if remove_alpha { + fn to_style_with_paint( + &self, + content_bounds: &Rect, + fallback_fonts: &HashSet<String>, + remove_alpha: bool, + paragraph_line_height: f32, + fill_layer_from_bottom: Option<usize>, + ) -> skia::textlayout::TextStyle { + let mut style = skia::textlayout::TextStyle::default(); + let paint = if remove_alpha { + let mut paint = paint::Paint::default(); paint.set_color(skia::Color::BLACK); paint.set_alpha(255); + paint + } else if let Some(layer) = fill_layer_from_bottom { + if layer < self.fills.len() { + let fill_idx = self.fills.len() - 1 - layer; + self.fills[fill_idx].to_paint(content_bounds, true) + } else { + let mut paint = paint::Paint::default(); + paint.set_color(skia::Color::TRANSPARENT); + paint + } } else { - paint = merge_fills(&self.fills, *content_bounds); - } + merge_fills(&self.fills, *content_bounds) + }; let max_line_height = f32::max(paragraph_line_height, self.line_height); style.set_height(max_line_height); From b6b1a47a7be2f5f3892193cc396ef793ac1e134e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= <andres.gonzalez79@gmail.com> Date: Wed, 2 Sep 2026 13:54:01 +0200 Subject: [PATCH 260/298] :books: Update MCP Quick demo video (#11453) Replace the outdated Quick demo embed on the MCP docs with the new recording. --- docs/mcp/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 466144b3f1..44345bd631 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca title="Quick demo: Penpot MCP server in action" width="100%" height="480" - src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0" + src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" From 76203862150492d84222950eaf882fb09968b5fc Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Wed, 2 Sep 2026 13:55:38 +0200 Subject: [PATCH 261/298] :bug: Fix font family preview sample (#11473) * :bug: Fix font family preview sample * :bug: Fix font line height inside font selector --- .../sidebar/options/menus/typography.cljs | 31 ++++++++++++------- .../sidebar/options/menus/typography.scss | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 3d41372e2c..c397c24448 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -164,11 +164,13 @@ (defn- sample-container-style "Inline style that applies the typography font to the (clipped, fixed-height) - sample container." + sample container. Must be a real JS object (`#js`), not a ClojureScript map: + the `:style` value here is a runtime expression, not a literal recognized by + the hiccup macro, so it reaches React unconverted." [typography] - {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}) + #js {:fontFamily (:font-family typography) + :fontWeight (:font-weight typography) + :fontStyle (:font-style typography)}) (defn- sample-text-style "Inline style that optically centers the sample glyphs. Must be applied to @@ -177,7 +179,7 @@ would shift the whole box relative to the row instead of the glyphs inside it." [em] (when-not (zero? em) - {:transform (dm/str "translateY(" em "em)")})) + #js {:transform (dm/str "translateY(" em "em)")})) ;; --- FONT SELECTOR -------------------------------------------------------- @@ -219,13 +221,18 @@ :role "img" :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] - [:span {:class (stl/css :font-item-label) - :style (cond-> {} - loaded? - (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) - (not (zero? label-offset)) - (assoc :transform (dm/str "translateY(" label-offset "em)")))} - (:name font)]))) + ;; The vertical correction goes on an INNER span, not on `.font-item-label` + ;; itself: that class carries its own `overflow: hidden` (from the + ;; text-ellipsis mixin, needed to truncate long font names), and a + ;; transform applied to a self-clipping element moves its clip region + ;; along with it — a no-op. The inner span has no overflow of its own, so + ;; the shift actually moves the ink within the outer's fixed clip area. + [:span {:class (stl/css :font-item-label)} + [:span {:style #js {:fontFamily (when loaded? + (dm/str "\"" (:family font) "\", sans-serif")) + :transform (when-not (zero? label-offset) + (dm/str "translateY(" label-offset "em)"))}} + (:name font)]]))) (mf/defc font-item* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 99fd0a398f..b6159c2042 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -101,7 +101,7 @@ $font-preview-box-height: 28px; box-sizing: border-box; background-color: var(--font-name-wrapper-background-color); margin-block-end: var(--sp-s); - padding: var(--sp-s) 0 var(--sp-s) var(--sp-m); + padding-inline-start: var(--sp-m); &:focus-within { --font-name-wrapper-border-color: var(--color-accent-primary); From 88d715938f889cc7a661f9edd2119d052bd65173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= <andres.gonzalez79@gmail.com> Date: Wed, 2 Sep 2026 14:06:06 +0200 Subject: [PATCH 262/298] :books: Add migration guide page (#11454) Point First Steps at the enterprise migration PDF with a short summary, without duplicating the Community post. --- docs/user-guide/first-steps/index.njk | 6 ++++ .../first-steps/migration-guide.njk | 31 +++++++++++++++++++ .../first-steps/troubleshooting-webgl.njk | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/user-guide/first-steps/migration-guide.njk diff --git a/docs/user-guide/first-steps/index.njk b/docs/user-guide/first-steps/index.njk index c26777b24f..e707f6d46e 100644 --- a/docs/user-guide/first-steps/index.njk +++ b/docs/user-guide/first-steps/index.njk @@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial <p>Useful resources to better understand Penpot</p> </a> </li> + <li> + <a href="/user-guide/first-steps/migration-guide"> + <h2>Migration Guide →</h2> + <p>Move a design system from Figma to Penpot</p> + </a> + </li> </ul> diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk new file mode 100644 index 0000000000..cd8600ff68 --- /dev/null +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -0,0 +1,31 @@ +--- +title: Migration Guide +order: 6 +desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF. +--- + +<h1 id="migration-guide">Migration Guide</h1> + +<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p> + +<div class="advice"> + <p><strong>Open the full guide (PDF)</strong></p> + <p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&editing=false&openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p> +</div> + +<h2 id="what-the-guide-covers">What the guide covers</h2> +<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p> + +<ul> + <li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li> + <li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li> + <li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li> + <li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li> + <li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li> + <li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li> +</ul> + +<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p> + +<h2 id="discuss-the-guide">Questions and discussion</h2> +<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p> diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index 7188090ac8..c3893f90e8 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -1,6 +1,6 @@ --- title: Troubleshooting WebGL -order: 5 +order: 7 desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks. --- From 70b443a716ba86b6eb38544cf7676b98a20d3179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Wed, 2 Sep 2026 14:45:32 +0200 Subject: [PATCH 263/298] :sparkles: Render multiple-object SVG exports with render-wasm (#11475) --- exporter/src/app/renderer.cljs | 4 +- exporter/src/app/wasm.cljs | 42 +++++++-- exporter/src/app/wasm/render.cljs | 93 ++++++++++++++----- .../src/app/main/data/exports/assets.cljs | 1 - 4 files changed, 106 insertions(+), 34 deletions(-) diff --git a/exporter/src/app/renderer.cljs b/exporter/src/app/renderer.cljs index 2cc0c424e4..320ffa8060 100644 --- a/exporter/src/app/renderer.cljs +++ b/exporter/src/app/renderer.cljs @@ -41,8 +41,8 @@ (defn headless? "Whether `params` renders with render-wasm rather than a browser." - [{:keys [type is-wasm]}] - (and is-wasm (contains? cf/flags :wasm-export) (not= :svg type))) + [{:keys [is-wasm]}] + (and is-wasm (contains? cf/flags :wasm-export))) (defn render [{:keys [type is-wasm] :as params} on-object] diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index dfebf84d39..cd6cbc09ea 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -33,8 +33,8 @@ ;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. (def ^:private RASTER-HEADER-BYTES 12) -;; render_shape_pdf result header: [len u32] only. -(def ^:private PDF-HEADER-BYTES 4) +;; render_shape_pdf / render_shape_svg result header: [len u32] only. +(def ^:private LEN-HEADER-BYTES 4) ;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. (def ^:private FONT-ENTRY-BYTES 24) @@ -146,6 +146,20 @@ (aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style (boolean emoji?) (boolean fallback?)))) +(defn store-font-url! + "Registers the public URL a font family was loaded from. The SVG export emits + one `@font-face` per family from these, and skips families without one, so + this must run for every family `store-font!` uploads. + + Does NOT call `mem/free`, for the same reason as `store-font!`." + [{:keys [id weight style]} url] + (let [bytes (js/Buffer.from url "utf-8") + ptr (mem/alloc (.-byteLength bytes))] + (mem/write-buffer ptr (mem/get-heap-u8) bytes) + (h/call wasm/internal-module "_store_font_url" + (aget id 0) (aget id 1) (aget id 2) (aget id 3) + weight style))) + (defn clear-fonts! "Resets the WASM font store. Must be called once per render request because the shared module would otherwise accumulate fonts across requests." @@ -217,13 +231,18 @@ (defn provision-fonts! "Resolves and uploads every font needed by `shape-ids`, each family fetched once. `resolve-font` is an injected fn of the family map -> promise of TTF - bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom - assets / backend) out of the driver." - [shape-ids resolve-font] + bytes (or nil to skip); optional `font-url` is a fn of the family map -> the + public URL those bytes came from. This keeps the font *source* (gfonts proxy + / custom assets / backend) out of the driver." + [shape-ids resolve-font & {:keys [font-url]}] (->> (fonts-for-shapes shape-ids) (map (fn [family] (->> (resolve-font family) - (p/fmap (fn [bytes] (when bytes (store-font! family bytes))))))) + (p/fmap (fn [bytes] + (when bytes + (store-font! family bytes) + (when-let [url (when font-url (font-url family))] + (store-font-url! family url)))))))) (p/all))) ;; --- RENDER @@ -256,4 +275,13 @@ (-> (h/call wasm/internal-module "_render_shape_pdf" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) scale) - (read-render-result PDF-HEADER-BYTES)))) + (read-render-result LEN-HEADER-BYTES)))) + +(defn render-shape-svg + "Renders the shape subtree to SVG markup bytes (Uint8Array)." + [shape-id scale] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_svg" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result LEN-HEADER-BYTES)))) diff --git a/exporter/src/app/wasm/render.cljs b/exporter/src/app/wasm/render.cljs index d286c6cc1f..6993c53bba 100644 --- a/exporter/src/app/wasm/render.cljs +++ b/exporter/src/app/wasm/render.cljs @@ -19,8 +19,7 @@ because that namespace still exists as the proxy. Reviewable as a rename: `git show <base>:exporter/src/app/renderer/wasm.cljs | diff -u - <this file>`. - Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the - browser path." + Handles png/jpeg/webp (Skia encodes all three), pdf and svg." (:require ["node:fs" :as fs] ["undici" :as http] @@ -71,6 +70,15 @@ (u/join path) (str))) +(defn- public-uri + "Absolute URI for `path` on the public endpoint. Whoever opens an exported SVG + resolves its `@font-face` sources, so those cannot use the internal endpoint." + [path] + (-> (cf/get :public-uri) + (u/ensure-path-slash) + (u/join path) + (str))) + (defn- error-detail "Node's fetch reports every transport failure as a bare `TypeError: fetch failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a @@ -221,23 +229,32 @@ [ttf-file] (cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file))))) +(defn- family-uuid + [id] + (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))) + +(defn- find-variant + "Custom variant for a family: uuid+weight+style first, degrading to + uuid+weight then uuid." + [variants font-uuid weight style] + (let [style-str (if (zero? style) "normal" "italic")] + (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight) + (= (name (:font-style v)) style-str))) + variants) + (d/seek (fn [v] (and (= (:font-id v) font-uuid) + (= (:font-weight v) weight))) + variants) + (d/seek (fn [v] (= (:font-id v) font-uuid)) variants)))) + (defn- make-resolve-font "Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom - variants first, matching uuid+weight+style then degrading to uuid+weight then - uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps - every builtin family to; google catalog otherwise." + variants first; the bundled fonts for `uuid/zero`, which is what + `font-id->uuid` maps every builtin family to; google catalog otherwise." [variants params] (fn [{:keys [id weight style]}] - (let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3)) - style-str (if (zero? style) "normal" "italic") - variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid) - (= (:font-weight v) weight) - (= (name (:font-style v)) style-str))) - variants) - (d/seek (fn [v] (and (= (:font-id v) font-uuid) - (= (:font-weight v) weight))) - variants) - (d/seek (fn [v] (= (:font-id v) font-uuid)) variants))] + (let [font-uuid (family-uuid id) + variant (find-variant variants font-uuid weight style)] (cond (:ttf-file-id variant) (fetch-asset-bytes (:ttf-file-id variant) params) @@ -250,6 +267,25 @@ (fetch-gfont-bytes gurl) (p/resolved nil)))))) +(defn- make-font-url + "Builds a `font-url` fn (family map -> public URL of its TTF), the same + sources `make-resolve-font` downloads from but addressed publicly. The SVG + export emits one `@font-face` per family from these." + [variants] + (fn [{:keys [id weight style]}] + (let [font-uuid (family-uuid id) + variant (find-variant variants font-uuid weight style)] + (cond + (:ttf-file-id variant) + (public-uri (str "assets/by-id/" (:ttf-file-id variant))) + + (= uuid/zero font-uuid) + (public-uri (str "fonts/" (cfnt/resolve-ttf-file weight style))) + + :else + (some-> (cfnt/resolve-ttf-url font-uuid weight style) + (cfnt/gstatic->proxy-url (public-uri "internal/gfonts/font"))))))) + ;; --- fallback fonts (emoji + per-script noto fonts) ;; ;; Emoji and non-latin scripts render through fallback families, not through @@ -376,13 +412,19 @@ (defn- render-object-bytes [type id scale] - (if (= :pdf type) - (let [bytes (wasm/render-shape-pdf id scale)] - (l/dbg :hint "PDF generated via Skia (render-wasm headless)" - :object-id (str id) - :backend "skia-wasm" - :bytes (.-length bytes)) - bytes) + (case type + :pdf (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + :svg (let [bytes (wasm/render-shape-svg id scale)] + (l/dbg :hint "SVG generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) (wasm/render-shape-raster id scale type))) (defn- render* @@ -406,11 +448,14 @@ (provision-fallback-fonts! scene)]) (p/mcat (fn [[variants _]] - (let [resolve-font (make-resolve-font (or variants []) params)] + (let [variants (or variants []) + resolve-font (make-resolve-font variants params) + font-url (make-font-url variants)] ;; Before rendering, so the relayout below sees real ;; font metrics. Deduped across objects: shapes ;; sharing one family download its TTF once. - (wasm/provision-fonts! (map :id objects) resolve-font)))) + (wasm/provision-fonts! (map :id objects) resolve-font + :font-url font-url)))) (p/mcat (fn [_] (relayout-text! scene) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 4055da67ca..f351746b49 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -252,7 +252,6 @@ ;; there is nothing left to stop. (rx/catch (fn [_] settle))))))))) -;; TODO: Remove once we support WASM SVG export (def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg}) (defn- wasm-export-enabled? From b2fb63988f1116d14c47b4732aa93096423fe5a2 Mon Sep 17 00:00:00 2001 From: 0xTHAC0 <sumitridhal@gmail.com> Date: Wed, 2 Sep 2026 19:24:33 +0530 Subject: [PATCH 264/298] :bug: Fix font-size dropdown clipping multi-digit values in Firefox (#11162) The .custom-select-dropdown used width: fit-content which, in Firefox, is capped to the containing block width (60 px for the font-size input). This caused two- and three-digit font-size values (e.g. 48, 120, 1000) to be visually clipped in the dropdown list. Switching to width: max-content lets the dropdown expand to fit its content regardless of the containing block width, matching the behaviour Chrome already exhibited with fit-content. Fixes #11008 --- frontend/src/app/main/ui/components/editable_select.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index 4d6a17e001..490e549c0a 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -39,7 +39,7 @@ .custom-select-dropdown { @extend %dropdown-wrapper; - width: fit-content; + width: max-content; max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token .separator { margin: 0; From cb80ea98bafabf0c0d78111f951245de0802a5f4 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Wed, 2 Sep 2026 16:02:23 +0200 Subject: [PATCH 265/298] :tada: Add a new favicon for localhost enviroment (#11459) --- frontend/resources/images/favicon-local.png | Bin 0 -> 1973 bytes frontend/resources/templates/index.mustache | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 frontend/resources/images/favicon-local.png diff --git a/frontend/resources/images/favicon-local.png b/frontend/resources/images/favicon-local.png new file mode 100644 index 0000000000000000000000000000000000000000..3e556a34ae34ceaabb09c330723fa8876855ea1e GIT binary patch literal 1973 zcmV;m2TJ&fP)<h;3K|Lk000e1NJLTq002k;002k`0ssI2+K(g<000MSNkl<Zc%1B; zYfltc7{?D6FNj<ebOl^>5xhlAi$>F6Vq#*_G;K)JwCQJP`W4#m)3k5fH%3FEA~D_( zMMULZ?y#`jS+>8<V^1anyEA8Imd!T%4<TpIo}D@WInV7p&zaKp_I4Ev^Yim1B_$OV z6)FrQ5{b>t&FboE6%55H7&bOGdMq#-6&T56^39t!FJHb~Utd?j5QtbRm3sgF{q*$o z#Kgql;GhZ&E;2SYHa9oNWx2QtfI!5irl#J$eJkx(uU^g0&Sn@qGcyy9$1@Dz=iS}i z((dc)o1C0f0pOq5%F4>~=g;juFJ8P@TU*<4Fro!>WDL<wH$bxzJbU(Rd3jm+g->Ez zTU))oy>PR4E-Wm(d-ra;2+>17Hik9XQO4-$@9$@Ty_09eEx3{Lg;M1UBO@b2Lqjy$ z+S(2tJXlmz#I(A)x|nxOO-)^0oqh25_&D>HHloXbBS(%n2Ed%gwQJX~hg=QYc>n(W z;o;%h+S(H*PAGGfoI7_;8G@C0`0yc(mX?-=hK5oL(8K7Xbr=#l_wL;*gO`?;7;Jy= zC=Ubn?Aaq9kvAD|{`~oqCr`@N4jed8R#rA{fycCe|9)i%V^Ks&L>cPq>zRlBt5_@+ ziA1pbSbq$+`p<kjJOMIBr%+|U>({Rtz#8&%`>^B3kHen^w@as)8DK1mHS4uMeerW~ zaq+2Br}R_T_^$E$KRSN@tNTv`m5K6ybaa&d8#iv0mzOJRwME8Y5{*XnfQuI|I<Ch0 zU$}6A^~aok`0(M(nKOu}@(eR#X#Z!`t-pNzYlbn&L=s{cK-L6HNKb2PtBxd4uuGRN z@$J*6PgyP+bRIf%XwJg+t1%vKSP_(sw(<-!V*i>!w^&D8d|}11h(u{%3|Isf3ilvA z=~&sZy1J@N;L|E*0J=xqxVX4T8v}qzKyK{=kTE)02hlyO&gILO^|zQ-9?kXZ*BK@~ z7*b4#GDP^qu!op6X~U2ZX~$uem6Z~yFxVdQ@)2V6Xkz>J?NjCmpO_t!7E~^fMPkQ} z9mA|i8$;63(Lv+p&6|3_<HwINk}5keAV%P0w41<u>rI+=&=RJ6x_7C;-MKXr@NV%v zIcTm(V2+~{>zosCYhrHH(&Ki<<(Y964DrCg04`PK8bcO{uyFnOG>5TuAT2S32X8}A zi(S^8MlLd3;Gl`K@eqGoIC}Kx>C>m_Oh+#*F&tuNXQ#d>4~hT8xGF~=f`bgAi1^^v z#NOVzb&GI$$MNp!i<4ysaQN_Hqfjjyz#72~lY{qX(~x<vJ2Ap2!d4`^v9U3fjEz5V zYmzHO!^okR@m>wCNH&f3=(j2taHJtz;co8nMMU2!7hoGkM8-ndJDH4^9H#q<aUnJu zVj*`eJ|bM?j)cE<T@p)(WyYsTkevaZM3H!ui?TJurxCoW?Bn+B+xCy_7_f@2zRM1S zpa!dmZ6pFz*#TAxJMJ)dWFOeSAvLBp**FMxy#b10l*A<xK(2^vBvGZ&+}ups!?%Eh zAKkMxc4P=Mg8={T-MjW4wxMjgB}d{LS=_d;4g?Bp8MiBjgrJUmE1nlhI7~tU6&T?Y z)0+|2?cTk61Y&r09b_VSk`K0rpzM*)o;~X?f0%X}Dr{iEu3Wi7RY5k9(-!pR|G|R? z_Ph-$L|zS(yFHT^5aAO;@X!#9Co4l{TU?*kI?akT#3|uIjJBKJku4p=^)YE?4<!OT z$%+c#EW+vCDfvKllQ~Qs*M}F9aDg6z7+mcxY!tFfOaKjS6=>|v_9u(NkXsap<r@mb z@(l%I`Gx|qd_#d)zM()Y-%ucyZ}{I5Gu~*XluI(SsjB&h<{urMiMqs>k*{cw4xzrT z4AC>w-80)`^b_+;uyWb|HdQzAx5F2oE8qAnl`<t((6M}jJ2BFO)G~8eq_sm%4P-tp zE^ybeL?ZF%(IYgTEHo)wl_OBZA*0;e+w1X^Qd(kE2HM)%bTJ!xPo6v>$^K14&{sx$ zfUN7CJ9q47-=)}}p?r~+7}-BL)X5VX931@k@uRXTsP<yQu7R6sR@4Eh2D+94T||)| zS5;NDw6t(7YI9UcRPI0uIU0?6tZo(=y^Ta2wNBN?${OvRR8lCKWG-qU$&$Wzb<nI# zJRWzPr0q}ijittT#L6?w95hwN1r_gF>7-aJ$IA6r%s3W{r8~fbqGEw)T)leL@w$OP z4%i{^@}7dmr|M>9d6hC?rTpdYSsXY}2b-Fjf}Rv6M=kiMq)-anUXcl23iMx;;NBF! zBasMJm@=PKz~tQqkF~F4GHI`&3AA6)?^^gn&}s{nNS-V65PIS|XDn1yR7kaQ`xM0~ z>Bk{JQH4?{rND5?NR;_lI^2W2)gUf`;ySKR4$sLOA<8-}h!#2CWa76X!20BNa1;f9 zN7DhLL#aNF+;~Lzg+DpKgSSD=SR58`$6>yY+<2Wv=qu&`u8*bDZ#;sgrY1RZlhx8@ z)=RaxK6H$TK|APxM{Ydg9{>OV|NrzTRRaJ300v1!K~w_(QOX)Uv96}|00000NkvXX Hu0mjf!l1k8 literal 0 HcmV?d00001 diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index 453197068e..590408748d 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -23,7 +23,12 @@ <link href="css/debug.css?version={{& version_tag}}" rel="stylesheet" type="text/css" /> {{/isDebug}} + {{#isDebug}} + <link rel="icon" href="images/favicon-local.png?version={{& version_tag }}" /> + {{/isDebug}} + {{^isDebug}} <link rel="icon" href="images/favicon.png?version={{& version_tag }}" /> + {{/isDebug}} <script type="importmap">{{& manifest.importmap }}</script> From c5897bc50ad51d4bb6471f791e7d539922459c93 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Wed, 2 Sep 2026 16:17:06 +0200 Subject: [PATCH 266/298] :zap: Paint plain text directly onto Current (#11355) * :recycle: Share text layout paragraphs across modifier clones Store Skia paragraphs in Rc so TextContentLayout::clone keeps the cached layout for rotate/pan modifiers. Add layout.clear() and treat needs_update as paragraphs-empty only. * :zap: Reuse cached Skia paragraphs when painting text Add try_paint_from_layout_cache to paint from TextContent.layout when versions match, skipping ParagraphBuilder rebuild and layout on each frame. Wire into the layered text path for plain fills without strokes or effects. * :zap: Paint plain text directly onto Current Extend can_render_directly for stroke-free text and skip the empty save_layer in draw_text when no stroke-group opacity is set. Plain text paints into Current without the Fills/Strokes blit. --- .../render-wasm/ffi-rendering-subtleties.md | 14 + render-wasm/src/render.rs | 555 ++++++++++-------- render-wasm/src/render/text.rs | 153 ++++- render-wasm/src/shapes/text.rs | 238 +++++++- render-wasm/src/state/shapes_pool.rs | 9 + render-wasm/src/state/text_editor.rs | 9 +- render-wasm/src/wasm/text_editor.rs | 9 +- 7 files changed, 714 insertions(+), 273 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index 7d7d56d7fd..dadbae5c2d 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,20 @@ - Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and + for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles + live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans). + `draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct. +- Plain text fill paint reuses `TextContent.layout` paragraphs when + `has_usable_paint_layout` (paragraphs present + version match; during + interactive transforms rotation/move skips width check via + `modifier_changes_text_layout`, resize falls back to `layout_width` vs + `get_width(selrect.width())`), via `text::try_paint_from_layout_cache`. + The walker computes `text_layout_cache_rotation_only` from `tree` and + passes it into `render_shape`; stroke/shadow paths pass `false`. +- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones + (rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only. + Decorations are skipped when no span requests underline/strike. - Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is compose+present only. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b5769f9c35..932e8f80a4 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -29,8 +29,8 @@ pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; use crate::math; use crate::shapes::{ - all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, StrokeKind, TextContent, Type, + all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners, + Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; @@ -291,6 +291,12 @@ impl FocusMode { } } +fn text_layout_cache_rotation_only(tree: ShapesPoolRef, shape: &Shape) -> bool { + tree.get_raw(&shape.id) + .zip(tree.get_layout_modifier(&shape.id)) + .is_some_and(|(base, modifier)| !modifier_changes_text_layout(base, &modifier)) +} + /* * Sort by z_index descending (higher z renders on top). * The sort is stable so if the values are equal the index for the children @@ -1345,6 +1351,7 @@ impl RenderState { parent_shadows: Option<Vec<skia_safe::Paint>>, outset: Option<f32>, target_surface: SurfaceId, + text_layout_cache_rotation_only: bool, ) -> Result<()> { #[cfg(feature = "stats")] self.stats.count(shape.id); @@ -1405,6 +1412,17 @@ impl RenderState { // Stroke-only (fills_none) can go direct: empty fills are a no-op and // strokes paint into Current. Large files need mid-walk GPU drains so // release builds do not backlog a huge ops buffer in one Partial. + // + // Plain text (no strokes / effects) also paints into Current: span styles + // live in Skia Paragraph TextStyles, so multi-style text is fine. + // Text skips the nested_fills guard because fills are on spans, not + // shape.fills. Strokes stay layered (masking needs save_layers). + let is_direct_geometry = matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) + ) && !(shape.fills.is_empty() && has_nested_fills); + let is_direct_text = + matches!(shape.shape_type, Type::Text(_)) && !shape.has_visible_strokes(); let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1415,11 +1433,7 @@ impl RenderState { && shape.background_blur.is_none() && !has_inherited_blur && !shadows_need_layered - && matches!( - shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) - ) - && !(shape.fills.is_empty() && has_nested_fills) + && (is_direct_geometry || is_direct_text) && target_surface != SurfaceId::Export; if can_render_directly { @@ -1448,21 +1462,50 @@ impl RenderState { }); } - fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + if let Type::Text(stored_text_content) = &shape.shape_type { + self.tile_atlas_flushed = true; - // Clipped frames draw strokes in render_shape_exit over children. - let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; - if !skip_strokes { - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, + if !text::try_paint_from_layout_cache( + Some(self), + None, shape, - &visible_strokes, Some(target_surface), - antialias, - outset, - )?; + text_layout_cache_rotation_only, + )? { + let rebound_text_content = + stored_text_content.paint_content_for_selrect(shape.selrect()); + let text_content = rebound_text_content.as_ref(); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + text::render( + Some(self), + None, + shape, + &mut paragraph_builders, + Some(target_surface), + None, + None, + None, + None, + )?; + } + } else { + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } } self.surfaces.apply_mut(target_surface as u32, |s| { @@ -1583,141 +1626,38 @@ impl RenderState { s.canvas().concat(&matrix); }); - // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. - let selrect = shape.selrect(); - let stored_bounds = stored_text_content.bounds(); - let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01 - && (stored_bounds.height() - selrect.height()).abs() < 0.01; - let rebound_text_content = if bounds_match { - None - } else { - Some(stored_text_content.new_bounds(selrect)) - }; - let text_content: &TextContent = - rebound_text_content.as_ref().unwrap_or(stored_text_content); - let count_inner_strokes = shape.count_visible_inner_strokes(); - // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. - let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec<StrokeKind> = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); - if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( + // Plain fill (no strokes / parent shadows): reuse cached layout + // paragraphs when valid. Skip builder rebuild + Skia layout. + let can_use_layout_cache = !shape.has_visible_strokes() + && parent_shadows.is_none() + && (skip_effects + || (shape.blur.is_none() + && !shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale(self.get_scale())) + && shape.inner_shadow_paints().is_empty())); + if !(can_use_layout_cache + && text::try_paint_from_layout_cache( Some(self), None, &shape, - &mut paragraph_builders, Some(fills_surface_id), - None, - None, - text_fill_inset, - None, - )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } - } else { - let shape_scale = self.get_scale(); - let mut drop_shadows = if skip_drop_shadows { - Vec::new() - } else { - shape - .drop_shadows_visible() - .filter(|s| s.is_perceptible_at_scale(shape_scale)) - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - paint.set_image_filter(shadow.get_drop_shadow_filter()); - paint - }) - .collect() - }; - - if !skip_drop_shadows { - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape + text_layout_cache_rotation_only, + )?) + { + let rebound_text_content = + stored_text_content.paint_content_for_selrect(shape.selrect()); + let text_content = rebound_text_content.as_ref(); + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec<StrokeKind> = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -1725,60 +1665,12 @@ impl RenderState { text_content, stroke, &shape.selrect(), - Some(true), + None, ) }) .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !skip_drop_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - } - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1786,25 +1678,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1820,7 +1698,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1831,7 +1709,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1843,7 +1721,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -1861,41 +1739,219 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip(); + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_non_ascii() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } - } + } // end layout-cache miss fallback } _ => { self.surfaces.apply_mut(surface_ids, |s| { @@ -2964,6 +3020,7 @@ impl RenderState { None, None, target_surface, + false, )?; } @@ -3115,6 +3172,7 @@ impl RenderState { Some(vec![new_shadow_paint.clone()]), None, target_surface, + false, ) })?; self.surfaces.canvas(SurfaceId::DropShadows).restore(); @@ -3219,6 +3277,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3262,6 +3321,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3325,6 +3385,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3782,6 +3843,9 @@ impl RenderState { Cow::Borrowed(element) }; + let text_layout_cache_rotation_only = self.options.is_interactive_transform() + && text_layout_cache_rotation_only(tree, element); + self.render_shape( &element_for_inline, clip_bounds.clone(), @@ -3794,6 +3858,7 @@ impl RenderState { None, None, target_surface, + text_layout_cache_rotation_only, )?; self.surfaces diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 89c232be69..ef41565af7 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -4,7 +4,7 @@ use crate::{ math::Rect, shapes::{ add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - ParagraphLayout, Stroke, StrokeKind, TextContent, + ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -318,6 +318,141 @@ pub fn render_overlay_emoji( ) } +/// Paint fill glyphs from `TextContent.layout` when the cache is valid. +/// +/// Avoids rebuilding ParagraphBuilders and re-running Skia layout on every +/// paint. Only safe for the plain fill pass (no stroke/shadow-specific builders). +/// Returns `true` when painting was done from cache. +pub fn try_paint_from_layout_cache( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + surface_id: Option<SurfaceId>, + layout_cache_rotation_only: bool, +) -> Result<bool> { + let text_content = shape.get_text_content(); + let cache_usable = if layout_cache_rotation_only { + text_content.layout_cache_versions_match() + } else { + text_content.has_usable_paint_layout(shape) + }; + if !cache_usable { + return Ok(false); + } + + if let Some(render_state) = render_state { + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + if let Some(canvas) = canvas { + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + Ok(false) +} + +fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { + let selrect = shape.selrect(); + let x = selrect.x(); + let base_y = selrect.y(); + let paragraphs = &text_content.layout.paragraphs; + let draw_decorations = text_content.has_text_decorations(); + + let total_text_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + let vertical_offset = match shape.vertical_align() { + VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0, + VerticalAlign::Bottom => selrect.height() - total_text_height, + _ => 0.0, + }; + + let mut y_accum = base_y + vertical_offset; + for group in paragraphs.iter() { + let Some(paragraph) = group.first() else { + continue; + }; + paragraph.paint(canvas, (x, y_accum)); + if draw_decorations { + paint_decorations_for_paragraph(canvas, paragraph, x, y_accum); + } + y_accum += paragraph.height(); + } +} + +fn paint_decorations_for_paragraph( + canvas: &Canvas, + paragraph: &skia::textlayout::Paragraph, + x: f32, + y_accum: f32, +) { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&style_metrics, line_baseline); + for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { + let text_style = &style_metric.text_style; + let style_end = style_metrics + .get(i + 1) + .map(|(next_i, _)| *next_i) + .unwrap_or(line.end_index); + let seg_start = (*style_start).max(line.start_index); + let seg_end = style_end.min(line.end_index); + if seg_start >= seg_end { + continue; + } + let rects = paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (segment_width, actual_x_offset) = if !rects.is_empty() { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + let skia_x_offset = rects + .first() + .map(|r| r.rect.left - line.left as f32) + .unwrap_or(0.0); + (total_width, skia_x_offset) + } else { + (0.0, 0.0) + }; + let text_left = x + line.left as f32 + actual_x_offset; + let text_width = segment_width; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + draw_text_decorations( + canvas, + text_style, + Some(underline_y.unwrap_or(line_baseline)), + max_underline_thickness, + text_left, + text_width, + ); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + draw_text_decorations( + canvas, + text_style, + Some(strike_y.unwrap_or(line_baseline)), + max_strike_thickness, + text_left, + text_width, + ); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, @@ -331,6 +466,8 @@ fn render_text_on_canvas( ) { let layer_bounds = shape.layer_bounds(); + // Layer stack is managed here (blur / shadow / inset). `draw_text` is + // self-contained and only opens a layer when stroke-group opacity needs it. if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); @@ -391,8 +528,6 @@ fn render_text_on_canvas( if blur.is_some() { canvas.restore(); } - - canvas.restore(); } /// Paints text fill for vector SVG export. Skips `save_layer` wrappers that @@ -766,20 +901,22 @@ fn draw_text( layer_opacity: Option<f32>, overlay_emoji: bool, ) { - let layer_bounds = shape.layer_bounds(); - + // Multi-style spans are already encoded in each ParagraphBuilder's + // TextStyles; paragraph.paint handles them without an isolation layer. + // Only open a save_layer when stroke-group opacity must composite as one. if let Some(opacity) = layer_opacity { + let layer_bounds = shape.layer_bounds(); let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); let layer_rec = SaveLayerRec::default() .bounds(&layer_bounds) .paint(&opacity_paint); canvas.save_layer(&layer_rec); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); + canvas.restore(); } else { - canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } - - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders a text stroke masked to the glyph shape. diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 61dfd5d15b..0a64ee037a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -21,18 +21,57 @@ use skia_safe::{ Contains, }; +use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; +use std::rc::Rc; use super::FontFamily; use crate::math::Point; -use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; +use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec<ParagraphBuilder>; +/// True when the modifier changes the text layout container (resize), as opposed +/// to rotation/move where glyph layout can be reused. +pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool { + let Type::Text(text_content) = &base.shape_type else { + return false; + }; + let before = oriented_container_bounds(base); + let after = before.transform(modifier); + match text_content.grow_type() { + GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()), + GrowType::AutoHeight | GrowType::Fixed => { + !crate::math::is_close_to(before.width(), after.width()) + } + } +} + +fn oriented_container_bounds(shape: &Shape) -> Bounds { + let selrect = shape.selrect(); + let mut bounds = Bounds::new( + Point::new(selrect.x(), selrect.y()), + Point::new(selrect.x() + selrect.width(), selrect.y()), + Point::new( + selrect.x() + selrect.width(), + selrect.y() + selrect.height(), + ), + Point::new(selrect.x(), selrect.y() + selrect.height()), + ); + if !shape.transform.is_identity() { + let mut matrix = shape.transform; + let center = shape.center(); + matrix.post_translate(center); + matrix.pre_translate(-center); + bounds.transform_mut(&matrix); + } + bounds +} + #[repr(u8)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] pub enum GrowType { @@ -196,7 +235,9 @@ struct CachedExtrect { #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec<ParagraphBuilderGroup>, - pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>, + /// Shared across shape clones (e.g. modifier transforms) so rotation/pan + /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. + pub paragraphs: Rc<Vec<Vec<skia::textlayout::Paragraph>>>, cached_extrect: Cell<Option<CachedExtrect>>, } @@ -210,8 +251,8 @@ impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], - cached_extrect: Cell::new(None), + paragraphs: Rc::clone(&self.paragraphs), + cached_extrect: Cell::new(self.cached_extrect.get()), } } } @@ -226,7 +267,7 @@ impl TextContentLayout { pub fn new() -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], + paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), } } @@ -237,12 +278,18 @@ impl TextContentLayout { paragraphs: Vec<Vec<skia::textlayout::Paragraph>>, ) { self.paragraph_builders = paragraph_builders; - self.paragraphs = paragraphs; + self.paragraphs = Rc::new(paragraphs); + self.cached_extrect.set(None); + } + + pub fn clear(&mut self) { + self.paragraph_builders.clear(); + self.paragraphs = Rc::new(Vec::new()); self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { - self.paragraph_builders.is_empty() || self.paragraphs.is_empty() + self.paragraphs.is_empty() } } @@ -390,6 +437,20 @@ impl TextContent { self.bounds } + /// Text content for paint when [`Rect`] size may differ from stored bounds + /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise + /// clones paragraphs into a rebound copy with an empty layout cache. + pub fn paint_content_for_selrect<'a>(&'a self, selrect: Rect) -> Cow<'a, Self> { + let stored_bounds = self.bounds(); + if (stored_bounds.width() - selrect.width()).abs() < 0.01 + && (stored_bounds.height() - selrect.height()).abs() < 0.01 + { + Cow::Borrowed(self) + } else { + Cow::Owned(self.new_bounds(selrect)) + } + } + pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) { self.bounds = Rect::from_xywh(x, y, w, h); } @@ -471,7 +532,7 @@ impl TextContent { let mut has_lines = false; let mut y_accum = base_y + vertical_offset; - for group in paragraphs { + for group in paragraphs.iter() { if let Some(paragraph) = group.first() { let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { @@ -892,6 +953,41 @@ impl TextContent { self.layout.needs_update() } + /// True when cached Skia paragraphs can be painted as-is (no rebuild/layout). + pub fn has_usable_paint_layout(&self, shape: &Shape) -> bool { + if self.layout.needs_update() || self.layout_version != self.content_version { + return false; + } + self.layout_matches_paint_container(shape) + } + + pub(crate) fn layout_cache_versions_match(&self) -> bool { + !self.layout.needs_update() && self.layout_version == self.content_version + } + + pub(crate) fn layout_matches_paint_container(&self, shape: &Shape) -> bool { + if self.grow_type() == GrowType::AutoWidth { + return true; + } + let Some(layout_w) = self.layout_width else { + return false; + }; + let container_w = self.get_width(shape.selrect().width()); + (layout_w - container_w).abs() < f32::EPSILON + } + + /// True when any span requests underline/overline/line-through (custom draw path). + pub fn has_text_decorations(&self) -> bool { + self.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + matches!( + span.text_decoration, + Some(d) if d != skia::textlayout::TextDecoration::NO_DECORATION + ) + }) + }) + } + pub fn set_layout_from_result( &mut self, result: TextContentLayoutResult, @@ -1918,4 +2014,130 @@ mod tests { assert_eq!(para.char_utf16_len_at(1), 2); assert_eq!(para.char_utf16_len_at(2), 1); } + + fn sample_text_content() -> TextContent { + let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0); + let mut content = TextContent::new(bounds, GrowType::Fixed); + content.add_paragraph(test_paragraph(&["hello"])); + content + } + + #[test] + fn has_usable_paint_layout_false_when_paragraphs_empty() { + let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 50.0), GrowType::Fixed); + let shape = Shape::new(Uuid::nil()); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_versions_mismatch() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 1; + content.content_version = 2; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_true_when_cached_and_versions_match() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_selrect_width_changed() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 300.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + fn text_shape_with_cached_layout(content: TextContent) -> Shape { + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + #[test] + fn has_usable_paint_layout_false_when_rotated_and_resized() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let base_shape = text_shape_with_cached_layout(content); + let rotate = Matrix::rotate_deg(45.0); + let resize = Matrix::scale((1.5, 1.0)); + let mut modifier = rotate; + modifier.pre_concat(&resize); + assert!(modifier_changes_text_layout(&base_shape, &modifier)); + } + + #[test] + fn has_text_decorations_detects_underline() { + let mut content = sample_text_content(); + content.paragraphs_mut()[0].children_mut()[0].text_decoration = + Some(skia::textlayout::TextDecoration::UNDERLINE); + assert!(content.has_text_decorations()); + } + + #[test] + fn has_text_decorations_false_for_plain_text() { + let content = sample_text_content(); + assert!(!content.has_text_decorations()); + } + + #[test] + fn paint_content_for_selrect_borrows_when_bounds_match() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Borrowed(_) => {} + Cow::Owned(_) => panic!("expected borrowed content"), + } + } + + #[test] + fn paint_content_for_selrect_rebounds_when_size_differs() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Owned(rebound) => { + assert_eq!(rebound.bounds().width(), 300.0); + assert!(rebound.layout.needs_update()); + } + Cow::Borrowed(_) => panic!("expected rebound content"), + } + } + + #[test] + fn layout_clone_shares_skia_paragraphs() { + let mut layout = TextContentLayout::new(); + layout.paragraphs = Rc::new(vec![vec![]]); + let cloned = layout.clone(); + assert!(Rc::ptr_eq(&layout.paragraphs, &cloned.paragraphs)); + assert!(cloned.paragraph_builders.is_empty()); + } + + #[test] + fn layout_clear_empties_paragraphs() { + let mut layout = TextContentLayout::new(); + layout.paragraphs = Rc::new(vec![vec![]]); + layout.clear(); + assert!(layout.needs_update()); + } } diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7fcc9ab642..d09bb109b2 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -153,6 +153,15 @@ impl ShapesPoolImpl { self.modifiers.get(&idx) } + /// Modifier applied to `id`, including one inherited from an ancestor. + pub fn get_layout_modifier(&self, id: &Uuid) -> Option<skia::Matrix> { + if let Some(matrix) = self.get_modifier(id) { + return Some(*matrix); + } + let idx = *self.uuid_to_idx.get(id)?; + self.find_nearest_ancestor_modifier(idx) + } + /// Get a shape by UUID without applying modifiers/structure/scale-content. pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { let idx = *self.uuid_to_idx.get(id)?; diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index ecb792f775..0c14c69ae0 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -796,8 +796,7 @@ impl TextEditorState { } } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -822,8 +821,7 @@ impl TextEditorState { self.selection.set_caret(clamped); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -844,8 +842,7 @@ impl TextEditorState { self.selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index d7134b3bc2..87e0c61899 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -396,8 +396,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -448,8 +447,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { let cursor = get_text_editor_state().selection.focus; text_helpers::insert_text_with_newlines(text_content, &cursor, &text); - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -517,8 +515,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(TextEditorEvent::ContentChanged); From f22abc9861d0ac6d6f8003c64eff2be64a626c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= <david.barragan@kaleidos.net> Date: Wed, 2 Sep 2026 14:14:23 +0200 Subject: [PATCH 267/298] :recycle: Switch penpot images to sha-<commit> tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the content-hash build key (bundle_version + docker/images tree hash) used to tag and dedupe the backend/frontend/exporter/storybook/mcp image set with sha-<commit>, matching the scheme already used by admin-console, licenses-manager and payments across the org. The check→build→promote pattern with the S3 marker is unchanged; only the key used for the marker, the immutable tag and the local bundle cache filename moves from the composite build key to the git commit sha (the bundle cache now keys on bundle_version alone, which is what it actually caches). devenv is intentionally left out of this pass, it has no versioned tagging today. Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net> --- .github/workflows/build-docker.yml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 32205c5385..b7bb794776 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -41,7 +41,7 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} - build_key: ${{ steps.vars.outputs.build_key }} + sha: ${{ steps.vars.outputs.sha }} exists: ${{ steps.check.outputs.exists }} steps: @@ -59,6 +59,7 @@ jobs: run: | GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ @@ -67,15 +68,10 @@ jobs: --output text) echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - # Image content = bundle + docker build context, so the build key - # combines both. - CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) - echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT - # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every # image was built AND every branch tag was moved. Marker present - # means there is nothing at all to do for this build key. + # means there is nothing at all to do for this commit. - name: Check if this image set is already built id: check env: @@ -85,13 +81,13 @@ jobs: run: | if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-${{ steps.vars.outputs.build_key }}" \ + --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`." + echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -101,7 +97,7 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" @@ -173,7 +169,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" @@ -213,7 +209,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -249,7 +245,7 @@ jobs: for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" + "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}" done # The marker is written LAST: its presence certifies that all five @@ -261,11 +257,11 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" + "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}" { echo "### ✅ Image set promoted" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." + echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── From f633d82f51e44761b4e936bb69e1232906a453c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= <david.barragan@kaleidos.net> Date: Wed, 2 Sep 2026 14:14:23 +0200 Subject: [PATCH 268/298] :recycle: Switch penpot images to sha-<commit> tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the content-hash build key (bundle_version + docker/images tree hash) used to tag and dedupe the backend/frontend/exporter/storybook/mcp image set with sha-<commit>, matching the scheme already used by admin-console, licenses-manager and payments across the org. The check→build→promote pattern with the S3 marker is unchanged; only the key used for the marker, the immutable tag and the local bundle cache filename moves from the composite build key to the git commit sha (the bundle cache now keys on bundle_version alone, which is what it actually caches). devenv is intentionally left out of this pass, it has no versioned tagging today. Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net> --- .github/workflows/build-docker.yml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 0d03490194..ac900455e0 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -37,7 +37,7 @@ jobs: outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} - build_key: ${{ steps.vars.outputs.build_key }} + sha: ${{ steps.vars.outputs.sha }} exists: ${{ steps.check.outputs.exists }} steps: @@ -55,6 +55,7 @@ jobs: run: | GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ @@ -63,15 +64,10 @@ jobs: --output text) echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT - # Image content = bundle + docker build context, so the build key - # combines both. - CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) - echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $GITHUB_OUTPUT - # The image set is a single block, so a single set-level check is # enough: `promote` drops a marker object in S3 only after every # image was built AND every branch tag was moved. Marker present - # means there is nothing at all to do for this build key. + # means there is nothing at all to do for this commit. - name: Check if this image set is already built id: check env: @@ -81,13 +77,13 @@ jobs: run: | if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ - --key "markers/images-${{ steps.vars.outputs.build_key }}" \ + --key "markers/images-sha-${{ steps.vars.outputs.sha }}" \ > /dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT { echo "### ⏭️ Image set build skipped" echo "" - echo "The whole set was already built and promoted for \`${{ steps.vars.outputs.build_key }}\`." + echo "The whole set was already built and promoted for \`sha-${{ steps.vars.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" else echo "exists=false" >> $GITHUB_OUTPUT @@ -97,7 +93,7 @@ jobs: # prune stale bundles while at it. mkdir -p "$BUNDLE_CACHE" find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true - ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" @@ -169,7 +165,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | - ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" + ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.bundle_version }}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" @@ -209,7 +205,7 @@ jobs: sbom: true # Immutable tag only; branch tags are moved atomically for the # whole image set by the `promote` job. - tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:build-${{ needs.prepare.outputs.build_key }} + tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.sha }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max @@ -245,7 +241,7 @@ jobs: for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ - "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" + "${{ secrets.DOCKER_REGISTRY }}/$image:sha-${{ needs.prepare.outputs.sha }}" done # The marker is written LAST: its presence certifies that all five @@ -257,11 +253,11 @@ jobs: AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} run: | echo "${{ github.run_id }}" | aws s3 cp - \ - "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" + "s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}" { echo "### ✅ Image set promoted" echo "" - echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." + echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── From c6a32a2f5aca0e72ca0ee28adb77bce0c7c424ba Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Wed, 2 Sep 2026 17:19:37 +0200 Subject: [PATCH 269/298] :bug: Fix list on registration toast notification (#11479) --- frontend/src/app/main/ui/auth/register.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 41acc9e841..da3438640f 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -114,6 +114,7 @@ (str "<ul>" items "</ul>"))] (st/emit! (ntf/show {:content (tr "errors.weak-password") :detail detail + :is-html true :type :toast :level :error}))) From 15dff4a9e1f5791ff2c8ee546244a24b18009273 Mon Sep 17 00:00:00 2001 From: Pablo Alba <pablo.alba@kaleidos.net> Date: Thu, 3 Sep 2026 08:46:52 +0200 Subject: [PATCH 270/298] :lipstick: Fix sales email (#11478) --- .../app/main/ui/nitrate/nitrate_code_activation_modal.cljs | 4 ++-- frontend/src/app/main/ui/nitrate/nitrate_form.cljs | 4 ++-- frontend/src/app/main/ui/settings/subscription.cljs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs index 876852198e..4bc0f1f490 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -120,7 +120,7 @@ [:div (tr "nitrate.code-activation.footer-after") " " [:a {:class (stl/css :link) - :href "mailto:sales@nitrate.com"} - "sales@nitrate.com"] + :href "mailto:sales@penpot.app"} + "sales@penpot.app"] " " (tr "nitrate.code-activation.footer-before")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index c9c34bfb73..fc962e472c 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -118,8 +118,8 @@ (tr "nitrate.form.contact-upgrade") (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]] + [:a {:class (stl/css :link) :href "mailto:sales@penpot.net"} + "sales@penpot.net"]] [:div {:class (stl/css :activation-code)} [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index a8a7f3fed9..6163c90d41 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -848,8 +848,8 @@ [:div {:class (stl/css :modal-text)} (tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]]])]])) + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"} + "sales@penpot.net"]]])]])) (mf/defc nitrate-contact-sales-dialog {::mf/register modal/components @@ -898,7 +898,7 @@ :on-click handle-close-dialog} (tr "ds.confirm-cancel")] [:> button* {:variant "primary" :type "button" - :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) + :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) (mf/defc nitrate-cancel-contact-sales-dialog {::mf/register modal/components From 7c762d8a9816bca99d86b330a96916a590e7ecef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= <mavalroot@gmail.com> Date: Thu, 3 Sep 2026 11:59:20 +0200 Subject: [PATCH 271/298] :bug: Fix recommended plan (#11488) --- frontend/src/app/main/ui/settings/subscription.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 6163c90d41..ff38ffb1c6 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -745,7 +745,6 @@ :cta-link (if (and (contains? cf/flags :admin-console) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page - :recommended (= subscription-type "professional") :show-button-cta (= subscription-type "professional") :current-plan false}]) @@ -783,6 +782,7 @@ :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :code-action :activate + :recommended (= subscription-type "professional") :show-button-cta (not nitrate-license) :current-plan false :inline-error nitrate-start-error-message}])]]])) From 80dea409c623504dd5197410eec58b4a8cf2dd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Thu, 3 Sep 2026 12:15:35 +0200 Subject: [PATCH 272/298] :bug: Fix share-link viewers unable to load file fragments (#11484) --- backend/src/app/rpc/commands/files.clj | 18 ++++++++++++++---- frontend/src/app/main/data/viewer.cljs | 11 ++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 435b83afff..b7d04a29a3 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -241,6 +241,18 @@ (some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"}) (update :data blob/decode))) +(defn- check-fragment-scope! + "Checks that the fragment is reachable from the pages authorized by + the share-link. Raises a :not-found exception if the fragment is not reachable." + [cfg file-id fragment-id pages] + (let [fdata (-> (bfc/get-file cfg file-id :read-only? true) + (get :data) + (update :pages-index select-keys pages))] + (when-not (contains? (feat.fdata/get-used-pointer-ids fdata) fragment-id) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")))) + (sv/defmethod ::get-file-fragment "Retrieve a file fragment by its ID. Only authenticated users." {::doc/added "1.17" @@ -250,11 +262,9 @@ [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] - (when (= :share-link (:type perms)) - (ex/raise :type :not-found - :code :object-not-found - :hint "object not found")) (check-read-permissions! perms) + (when (= :share-link (:type perms)) + (check-fragment-scope! cfg file-id fragment-id (:pages perms))) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index f847cd862d..c5f20ce24d 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -326,11 +326,12 @@ (filter #(= page-id (:page-id %))) (d/index-by :id) (assoc state :comment-threads))) - (on-error [{:keys [type] :as err}] - (if (or (= :authentication type) - (= :not-found type)) - (rx/empty) - (rx/throw err)))] + (on-error [cause] + (let [{:keys [type]} (ex-data cause)] + (if (or (= :authentication type) + (= :not-found type)) + (rx/empty) + (rx/throw cause))))] (ptk/reify ::fetch-comment-threads ptk/WatchEvent From a19b3c8d62df0bf5b29717f1ba511c006f3d1364 Mon Sep 17 00:00:00 2001 From: Luis de Dios <luis.dedios@kaleidos.net> Date: Tue, 1 Sep 2026 15:48:09 +0200 Subject: [PATCH 273/298] :bug: Fix font preview in assets breaks the font row (#11428) * :bug: Fix font preview in assets breaks the font row * :bug: Fix font height problem also in the font dropdown * :bug: Fix a small bug within the changes --------- Co-authored-by: Eva Marco <evamarcod@gmail.com> --- frontend/src/app/main/ui/dashboard/grid.scss | 5 + .../sidebar/options/menus/typography.cljs | 150 +++++++++++++++--- .../sidebar/options/menus/typography.scss | 4 + frontend/src/app/util/dom.cljs | 30 ++++ 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index bd90a72894..dd23ce7128 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -419,9 +419,14 @@ $thumbnail-default-height: px2rem(168); } .library-typography-sample { + display: flex; + justify-content: center; + align-items: center; block-size: px2rem(20); + line-height: 1; margin-inline-end: var(--sp-xs); inline-size: px2rem(20); + overflow: hidden; } // ─── MISC ────────────────────────────────────── diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 8d20d7ab7b..b73d9dab19 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -90,6 +90,97 @@ (constantly nil))))) @loaded?)) +;; --- OPTICAL CENTERING OF SAMPLE TEXT -------------------------------------- + +;; Fonts with exaggerated vertical metrics (huge ascender/descender, small +;; caps) render their line box lower within a fixed-height row, so a plain +;; `align-items: center` leaves the visible glyphs sitting low. We measure the +;; font-wide vs glyph-ink bounding boxes once per font/sample and shift the +;; text by the computed offset so the visible glyphs are optically centered. +;; The offset is expressed in `em`, which makes it size-independent: the same +;; measurement corrects both the 16px `Ag` sample and the smaller font-name +;; labels in the font selector. + +(defonce ^:private optical-offset-cache (atom {})) + +(defn- optical-offset-key [family weight style text] + (dm/str family "|" weight "|" style "|" text)) + +(defn- optical-offset-em + "Vertical shift (in `em` units, i.e. relative to the font size) that centers + the ink of `text` within a single line box. + + For a centered line the shift reduces to the difference between the font-wide + and ink bounding boxes: + dy = ((ink-ascent - font-ascent) + (font-descent - ink-descent)) / 2. + Measuring at 16px and dividing the pixel shift by it yields the `em` value." + [family weight style text] + (when-some [{:keys [font-ascent font-descent ink-ascent ink-descent]} + (dom/measure-text-metrics family weight style text 16)] + (let [dy (/ (+ (- ink-ascent font-ascent) + (- font-descent ink-descent)) + 2) + em (/ dy 16)] + ;; Round to avoid float noise leaking into the transform string. + (/ (js/Math.round (* em 10000)) 10000)))) + +(defn- load-optical-offset + [font-id family weight style text] + (let [key (optical-offset-key family weight style text)] + (if-let [cached (get @optical-offset-cache key)] + (p/resolved cached) + (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + (let [em (or (optical-offset-em family weight style text) 0)] + (swap! optical-offset-cache assoc key em) + em))))))) + +(defn- use-optical-offset + "Lazily resolve the optical-centering offset (in `em`) for sample text in a + given font, measuring once per font/sample and caching it. Falls back to 0 + when the font isn't available or the metrics can't be measured." + [font-id family weight style text] + (let [offset* (mf/use-state 0)] + (mf/use-effect + (mf/deps font-id family weight style text) + (fn [] + (let [cancelled? (volatile! false) + key (optical-offset-key family weight style text)] + (if (contains? @optical-offset-cache key) + (reset! offset* (get @optical-offset-cache key)) + (let [task (tm/schedule-on-idle + (fn [] + (-> (load-optical-offset font-id family weight style text) + (p/then + (fn [em] + (when-not @cancelled? + (reset! offset* em)))))))] + (fn [] + (vreset! cancelled? true) + (tm/dispose! task))))) + nil)) + (deref offset*))) + +(defn- sample-container-style + "Inline style that applies the typography font to the (clipped, fixed-height) + sample container." + [typography] + {:font-family (:font-family typography) + :font-weight (:font-weight typography) + :font-style (:font-style typography)}) + +(defn- sample-text-style + "Inline style that optically centers the sample glyphs. Must be applied to + the text node itself, not to the clipped container: a transform on an + `overflow: hidden` element moves its own clip region along with it, so it + would shift the whole box relative to the row instead of the glyphs inside it." + [em] + (when-not (zero? em) + {:transform (dm/str "translateY(" em "em)")})) + +;; --- FONT SELECTOR -------------------------------------------------------- + (mf/defc font-item-preview* "Row content with previews: a vector preview from the shared sprite for catalog fonts, or the font's own name lazily loaded for custom fonts the sprite doesn't @@ -110,7 +201,18 @@ ;; we show the plain name rather than runtime-loading the whole catalog. in-sprite? (and attached? (contains? (:ids sprite) font-id)) fallback? (and (= :ready (:status sprite)) attached? (not in-sprite?)) - loaded? (use-font-lazy-load font-id fallback?)] + loaded? (use-font-lazy-load font-id fallback?) + + ;; Optical centering for the fallback name (custom fonts the sprite + ;; doesn't cover): extreme vertical metrics would push the name low in + ;; the row, so shift it by the measured offset once the font is known. + ;; The label renders at `body-medium` (400/normal), which is the weight + ;; and style we measure against. + label-offset (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -118,8 +220,11 @@ :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] [:span {:class (stl/css :font-item-label) - :style (when loaded? - #js {:fontFamily (dm/str "\"" (:family font) "\", sans-serif")})} + :style (cond-> {} + loaded? + (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) + (not (zero? label-offset)) + (assoc :transform (dm/str "translateY(" label-offset "em)")))} (:name font)]))) (mf/defc font-item* @@ -590,6 +695,11 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -624,10 +734,9 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -661,11 +770,9 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) :title (:name typography)} @@ -712,6 +819,11 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) + offset (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -769,10 +881,9 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:input {:class (stl/css :adv-typography-name) @@ -789,10 +900,9 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style {:font-family (:font-family typography) - :font-weight (:font-weight typography) - :font-style (:font-style typography)}} - (tr "workspace.assets.typography.sample")] + :style (sample-container-style typography)} + [:span {:style (sample-text-style offset)} + (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) :title (if name-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 6fbb3e4d1b..a8ad4b21ef 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -119,6 +119,8 @@ $font-preview-box-height: 28px; inline-size: $sz-24; block-size: 100%; font-size: px2rem(16); + line-height: 1; + overflow: hidden; color: var(--color-foreground-primary); } @@ -171,8 +173,10 @@ $font-preview-box-height: 28px; align-items: center; min-inline-size: $sz-24; font-size: px2rem(16); + line-height: 1; block-size: $sz-32; padding: 0; + overflow: hidden; color: var(--color-foreground-primary); } diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 779265b93d..ffb2bb06c1 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -949,6 +949,36 @@ {:ascent (.-fontBoundingBoxAscent measure) :descent (.-fontBoundingBoxDescent measure)})) +(defn measure-text-metrics + "Measure the font-wide (bounding-box) and glyph-ink vertical metrics of `text` + at `font-size` px for the given font. + + Returns `{:font-ascent :font-descent :ink-ascent :ink-descent}` in px, or nil + when the browser doesn't expose the bounding-box metrics. The font-wide + values track what CSS uses for the line box, while the ink ones track the + visible glyphs, which is what an optical centering shift needs." + ([family weight style] + (measure-text-metrics family weight style "Ag" 16)) + ([family weight style text font-size] + (let [element (.createElement globals/document "canvas") + context (.getContext element "2d") + _ (set! (.-font context) + (dm/str (or weight "400") " " (or style "normal") " " + font-size "px \"" family "\"")) + measure ^js (.measureText context (str text)) + font-ascent (.-fontBoundingBoxAscent measure) + font-descent (.-fontBoundingBoxDescent measure) + ink-ascent (.-actualBoundingBoxAscent measure) + ink-descent (.-actualBoundingBoxDescent measure)] + (when (and (number? font-ascent) + (number? font-descent) + (number? ink-ascent) + (number? ink-descent)) + {:font-ascent font-ascent + :font-descent font-descent + :ink-ascent ink-ascent + :ink-descent ink-descent})))) + (defn clone-node ([^js node] (clone-node node true)) From a4becb5d1f436198ff93ba05356945b7b34de72f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Wed, 2 Sep 2026 10:28:16 +0200 Subject: [PATCH 274/298] :bug: Fix size-limiting-stream read arity on v3 binfile import (#11468) The FilterInputStream proxy only implemented read() and read(byte[], int, int). Buffered reads call read(byte[]) (and read(byte[], int) via Clojure interop), causing ArityException while hashing storage objects and breaking v3 imports. Implement all read overloads and extract shared byte-count logic. --- backend/src/app/binfile/v3.clj | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 01e735d364..9d4801dd4a 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -467,25 +467,26 @@ Raises :validation :max-file-size-reached when the limit is exceeded." ^InputStream [^InputStream input ^long max-size] - (let [counter (atom 0)] + (let [counter (atom 0) + on-read (fn [n] + (when (pos? n) + (when (> (swap! counter + (long n)) max-size) + (ex/raise :type :validation + :code :max-file-size-reached + :hint (str "stream exceeded max size: " max-size)))) + n)] (proxy [FilterInputStream] [input] (read ([] (let [b (.read input)] - (when (pos? b) - (when (> (swap! counter inc) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) + (when (pos? b) (on-read 1)) b)) - ([buf off len] - (let [n (.read input buf off len)] - (when (pos? n) - (when (> (swap! counter + (long n)) max-size) - (ex/raise :type :validation - :code :max-file-size-reached - :hint (str "stream exceeded max size: " max-size)))) - n)))))) + ([^bytes buf] + (on-read (.read input buf 0 (alength buf)))) + ([^bytes buf off] + (on-read (.read input buf (int off) (- (alength buf) (int off))))) + ([^bytes buf off len] + (on-read (.read input buf (int off) (int len)))))))) (defn- zip-entry-reader [^ZipFile input ^ZipEntry entry] From 7b26949c76b08bb5a5c5e9b059896c79d6096367 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandroalonsofernandez@gmail.com> Date: Thu, 3 Sep 2026 12:34:54 +0200 Subject: [PATCH 275/298] :bug: Fix typography.cljs error from staging merge --- .../ui/workspace/sidebar/options/menus/typography.cljs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 8a8e41354c..6c4268cfce 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -221,15 +221,6 @@ :role "img" :aria-label (:name font)} [:use {:href (dm/str "#" fonts/preview-sprite-prefix font-id)}]] -<<<<<<< HEAD - [:span {:class (stl/css :font-item-label) - :style (cond-> {} - loaded? - (assoc :font-family (dm/str "\"" (:family font) "\", sans-serif")) - (not (zero? label-offset)) - (assoc :transform (dm/str "translateY(" label-offset "em)")))} - (:name font)]))) -======= ;; The vertical correction goes on an INNER span, not on `.font-item-label` ;; itself: that class carries its own `overflow: hidden` (from the ;; text-ellipsis mixin, needed to truncate long font names), and a @@ -242,7 +233,6 @@ :transform (when-not (zero? label-offset) (dm/str "translateY(" label-offset "em)"))}} (:name font)]]))) ->>>>>>> origin/staging (mf/defc font-item* {::mf/wrap [mf/memo]} From fb22c1547c7bcf0937e737295c3db9c7faf0c94a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Thu, 3 Sep 2026 13:43:36 +0200 Subject: [PATCH 276/298] :bug: Skip component sync for derived WASM text layout commits (#11490) Post-font-load selrect fixes and position-data regeneration write sync-attrs on texts inside mains. That made watch-component-changes treat them as edits and run touch/sync per component, freezing large files. Mark those commits with skip-component-sync? (same idea as translation?) so only real user edits propagate. --- .../src/app/common/files/changes_builder.cljc | 6 ++ frontend/src/app/main/data/changes.cljs | 8 +- frontend/src/app/main/data/workspace.cljs | 8 +- .../app/main/data/workspace/libraries.cljs | 3 + .../src/app/main/data/workspace/shapes.cljs | 11 +- .../app/main/data/workspace/wasm_text.cljs | 102 +++++++++--------- 6 files changed, 80 insertions(+), 58 deletions(-) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index e910978df9..a0c733fecb 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -74,6 +74,12 @@ translation? (assoc :translation? true))) +(defn set-skip-component-sync? + [changes skip-component-sync?] + (cond-> changes + skip-component-sync? + (assoc :skip-component-sync? true))) + (defn with-page [changes page] (vary-meta changes assoc diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 1ecb0f67b9..d45fb82b7a 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -160,7 +160,7 @@ "Create a commit event instance" [{:keys [commit-id redo-changes undo-changes origin save-undo? features file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm? - selected-before translation?]}] + selected-before translation? skip-component-sync?]}] (assert (cpc/check-changes redo-changes) "expect valid vector of changes for redo-changes") @@ -188,7 +188,8 @@ :stack-undo? stack-undo? :ignore-wasm? ignore-wasm? :selected-before selected-before - :translation? translation?}] + :translation? translation? + :skip-component-sync? skip-component-sync?}] (ptk/reify ::commit cljs.core/IDeref @@ -227,7 +228,7 @@ undo-group, they will be undone or redone in a single step " [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id - translation?] + translation? skip-component-sync?] :or {save-undo? true stack-undo? false undo-group (uuid/next) @@ -261,4 +262,5 @@ (assoc :redo-changes rchg) (assoc :selected-before selected) (assoc :translation? translation?) + (assoc :skip-component-sync? skip-component-sync?) (commit))))))))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index d9afb0cc62..245df6008f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -244,7 +244,8 @@ {:redo-changes changes :undo-changes [] :save-undo? false :origin it - :tags #{:position-data}})) + :tags #{:position-data} + :skip-component-sync? true})) (rx/empty))))))) (defn- workspace-initialized @@ -511,7 +512,7 @@ (rx/filter (ptk/type? :app.render-wasm.api/stale-text-selrects)) (rx/map deref) (rx/map (fn [{:keys [ids]}] - (dwwt/resize-wasm-text-all ids)))) + (dwwt/resize-wasm-text-all ids {:skip-component-sync? true})))) (let [local-commits-s (->> stream @@ -565,7 +566,8 @@ (dch/commit-changes {:redo-changes changes :undo-changes [] :save-undo? false - :tags #{:position-data}}))))) + :tags #{:position-data} + :skip-component-sync? true}))))) (rx/take-until stoper-s))) (->> stream diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index daf0876088..60baa7e15d 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1446,6 +1446,9 @@ (rx/filter #(= :local (:source %))) ;; Translation commits never propagate component changes. (rx/filter (complement :translation?)) + ;; Derived / corrective commits (font-load selrect fix, + ;; position-data regen) are not user component edits. + (rx/filter (complement :skip-component-sync?)) ;; Keep waits pending while component changes are checked. (rx/map start-sync-barrier) (rx/observe-on :async)) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 86ae4eff8c..2454b537a1 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -110,7 +110,7 @@ ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id ignore-touched undo-group with-objects? changed-sub-attr - translation?] + translation? skip-component-sync?] :or {reg-objects? false save-undo? true stack-undo? false @@ -152,7 +152,8 @@ :ignore-touched ignore-touched :with-objects? with-objects?}) (cond-> reg-objects? (pcb/resize-parents ids)) - (pcb/set-translation? translation?))))] + (pcb/set-translation? translation?) + (pcb/set-skip-component-sync? skip-component-sync?))))] ;; Check buffered text candidates when the buffer is committed. (if (or (empty? text-ids) (not (wrfs/text-reflow-candidate? state props))) @@ -187,7 +188,8 @@ ([ids update-fn {:as props :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr translation?] + ignore-touched undo-group with-objects? changed-sub-attr translation? + skip-component-sync?] :or {reg-objects? false save-undo? true stack-undo? false @@ -223,7 +225,8 @@ :translation? translation?}) (cond-> undo-group (pcb/set-undo-group undo-group)) - (pcb/set-translation? translation?)) + (pcb/set-translation? translation?) + (pcb/set-skip-component-sync? skip-component-sync?)) changed-objects (pcb/lookup-objects changes) diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index edbb7803e0..04495ad0f2 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -94,48 +94,55 @@ (rx/empty))] (wrf/with-pending :text-resize [id] resize-stream))))) +(defn- merge-resize-debounce-opts + [prev {:keys [undo-group undo-id skip-component-sync?]}] + (cond-> (or prev {}) + (some? undo-group) (assoc :undo-group undo-group) + (some? undo-id) (assoc :undo-id undo-id) + skip-component-sync? (assoc :skip-component-sync? true))) + (defn resize-wasm-text-debounce-commit - ([] - (resize-wasm-text-debounce-commit nil nil)) - ([undo-group undo-id] - (ptk/reify ::resize-wasm-text-debounce-commit - ptk/WatchEvent - (watch [_ state _] - (let [ids (get state ::resize-wasm-text-debounce-ids) - objects (dsh/lookup-page-objects state) + [] + (ptk/reify ::resize-wasm-text-debounce-commit + ptk/WatchEvent + (watch [_ state _] + (let [ids (get state ::resize-wasm-text-debounce-ids) + {:keys [undo-group undo-id skip-component-sync?]} (get state ::resize-wasm-text-debounce-opts) + objects (dsh/lookup-page-objects state) - modifiers - (reduce - (fn [modifiers id] - (let [shape (get objects id)] - (cond-> modifiers - (and (some? shape) - (cfh/text-shape? shape) - (not= :fixed (:grow-type shape))) - (merge (resize-wasm-text-modifiers shape))))) - {} - ids) + modifiers + (reduce + (fn [modifiers id] + (let [shape (get objects id)] + (cond-> modifiers + (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (merge (resize-wasm-text-modifiers shape))))) + {} + ids) - ;; When undo-id is present, extend the current undo transaction instead of - ;; creating a new one, and commit it after the resize (single undo action). - extend-tx? (some? undo-id) - apply-opts (cond-> {} - (some? undo-group) (assoc :undo-group undo-group) - extend-tx? (assoc :undo-transation? false))] - (cond - (not (empty? modifiers)) - (if extend-tx? - (rx/concat - (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts)) - (rx/of (dwu/commit-undo-transaction undo-id))) - (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts))) + ;; When undo-id is present, extend the current undo transaction instead of + ;; creating a new one, and commit it after the resize (single undo action). + extend-tx? (some? undo-id) + apply-opts (cond-> {} + (some? undo-group) (assoc :undo-group undo-group) + extend-tx? (assoc :undo-transation? false) + skip-component-sync? (assoc :skip-component-sync? true))] + (cond + (not (empty? modifiers)) + (if extend-tx? + (rx/concat + (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts)) + (rx/of (dwu/commit-undo-transaction undo-id))) + (rx/of (dwm/apply-wasm-modifiers modifiers apply-opts))) - extend-tx? - ;; No resize needed (e.g. :fixed grow-type) but we must commit the add - (rx/of (dwu/commit-undo-transaction undo-id)) + extend-tx? + ;; No resize needed (e.g. :fixed grow-type) but we must commit the add + (rx/of (dwu/commit-undo-transaction undo-id)) - :else - (rx/empty))))))) + :else + (rx/empty)))))) ;; This event will debounce the resize events so, if there are many, they ;; are processed at the same time and not one-by-one. This will improve @@ -144,7 +151,7 @@ (defn resize-wasm-text-debounce-inner ([id] (resize-wasm-text-debounce-inner id nil)) - ([id {:keys [undo-group undo-id]}] + ([id opts] (let [cur-event (js/Symbol) reflow-task (wrf/task :text-resize [id])] (ptk/reify ::resize-wasm-text-debounce-inner @@ -153,6 +160,8 @@ (-> state (update ::resize-wasm-text-debounce-ids (fnil conj []) id) (update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task) + (cond-> (seq opts) + (update ::resize-wasm-text-debounce-opts merge-resize-debounce-opts opts)) (cond-> (nil? (::resize-wasm-text-debounce-event state)) (assoc ::resize-wasm-text-debounce-event cur-event)))) @@ -167,14 +176,9 @@ (rx/filter (ptk/type? ::resize-wasm-text-debounce-inner)) (rx/debounce debounce-resize-text-time) (rx/take 1) - (rx/map (fn [evt] - (resize-wasm-text-debounce-commit - (some-> evt meta :undo-group) - (some-> evt meta :undo-id)))) + (rx/map (fn [_] (resize-wasm-text-debounce-commit))) (rx/take-until stopper)) - (rx/of (with-meta - (resize-wasm-text-debounce-inner id) - {:undo-group undo-group :undo-id undo-id}))) + (rx/of (resize-wasm-text-debounce-inner id opts))) ;; Cleanup, reached both after the commit and when the stopper ;; cancels the debounce, so the batch always drains and stays ;; pending until the resize is applied. All exact tasks in the @@ -184,13 +188,14 @@ (dissoc state ::resize-wasm-text-debounce-ids ::resize-wasm-text-reflow-tasks + ::resize-wasm-text-debounce-opts ::resize-wasm-text-debounce-event))))) (rx/empty))))))) (defn resize-wasm-text-debounce ([id] (resize-wasm-text-debounce id nil)) - ([id {:keys [undo-group undo-id] :as opts}] + ([id {:keys [undo-group undo-id skip-component-sync?] :as opts}] (ptk/reify ::resize-wasm-text-debounce ptk/WatchEvent (watch [_ state _] @@ -208,10 +213,11 @@ resize-wasm-stream (if fonts-ready? - (let [pass-opts (when (or (some? undo-group) (some? undo-id)) + (let [pass-opts (when (or (some? undo-group) (some? undo-id) skip-component-sync?) (cond-> {} (some? undo-group) (assoc :undo-group undo-group) - (some? undo-id) (assoc :undo-id undo-id)))] + (some? undo-id) (assoc :undo-id undo-id) + skip-component-sync? (assoc :skip-component-sync? true)))] (rx/of (resize-wasm-text-debounce-inner id pass-opts))) ;; Fonts not loaded; retry after 20 msecs From eb8d3a2fef37509508882b742d44e8501433e290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Thu, 3 Sep 2026 16:17:38 +0200 Subject: [PATCH 277/298] :bug: Fix paragraph selection (#11493) --- .../ui/workspace/shapes/text/v3_editor.cljs | 8 +- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 5 ++ render-wasm/src/shapes/text.rs | 13 ++- render-wasm/src/state/text_editor.rs | 84 +++++++++++-------- render-wasm/src/wasm/text/helpers.rs | 58 ++++++------- render-wasm/src/wasm/text_editor.rs | 34 ++++++-- 7 files changed, 133 insertions(+), 70 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index b61c7b4c07..7f0806fd34 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -159,6 +159,10 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- triple-click? + [^js native-event] + (>= (.-detail native-event) 3)) + (defn- input-surface-class "Class list for the contenteditable capture surface. @@ -492,7 +496,9 @@ (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-set-cursor-from-offset off-pt) + (if (triple-click? native-event) + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/text-editor-set-cursor-from-offset off-pt)) (wasm.api/render-text-editor-overlay!)))) on-double-click diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f09e2d7db2..ee37ae995b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -311,6 +311,7 @@ (def text-editor-get-active-shape-id text-editor/text-editor-get-active-shape-id) (def text-editor-select-all text-editor/text-editor-select-all) (def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) +(def text-editor-select-paragraph text-editor/text-editor-select-paragraph) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index ae914a31d8..cbf6bf6bbd 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -450,6 +450,11 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) +(defn text-editor-select-paragraph + [{:keys [x y]}] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_select_paragraph" x y))) + (defn text-editor-blur [] (when (wasm/ready?) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 0a64ee037a..1e9ae16aee 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -192,7 +192,7 @@ impl TextPositionWithAffinity { } } - pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self { + pub fn new_downstream_affinity(paragraph: usize, offset: usize) -> Self { Self { position_with_affinity: PositionWithAffinity { position: offset as i32, @@ -203,6 +203,17 @@ impl TextPositionWithAffinity { } } + pub fn new_upstream_affinity(paragraph: usize, offset: usize) -> Self { + Self { + position_with_affinity: PositionWithAffinity { + position: offset as i32, + affinity: Affinity::Upstream, + }, + paragraph, + offset, + } + } + pub fn reset(&mut self) { self.position_with_affinity.position = 0; self.position_with_affinity.affinity = Affinity::Downstream; diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 0c14c69ae0..73711a93af 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -9,10 +9,7 @@ use crate::shapes::{ use crate::uuid::Uuid; use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset}; use crate::wasm::text_editor::CursorDirection; -use skia_safe::{ - textlayout::{Affinity, PositionWithAffinity}, - Color, -}; +use skia_safe::Color; #[derive(Debug, Clone, Copy, Default)] pub struct TextSelection { @@ -329,7 +326,7 @@ impl TextComposition { let focus = selection.focus; let previous_len = self.previous.chars().count(); - let anchor = TextPositionWithAffinity::new_without_affinity( + let anchor = TextPositionWithAffinity::new_downstream_affinity( focus.paragraph, focus.offset + previous_len, ); @@ -437,9 +434,22 @@ impl TextEditorState { true } - pub fn select_all(&mut self, text_content: &TextContent) -> bool { + fn select_range( + &mut self, + text_content: &TextContent, + start: &TextPositionWithAffinity, + end: &TextPositionWithAffinity, + ) { self.is_pointer_selection_active = false; - self.set_caret_from_position(&TextPositionWithAffinity::empty()); + self.is_click_event_skipped = false; + self.set_caret_from_position(start); + self.extend_selection_from_position(end); + self.update_styles(text_content); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + + pub fn select_all(&mut self, text_content: &TextContent) -> bool { let num_paragraphs = text_content.paragraphs().len().saturating_sub(1); let Some(last_paragraph) = text_content.paragraphs().last() else { return false; @@ -449,17 +459,11 @@ impl TextEditorState { }; // Offsets are counted in characters, not bytes. let offset = text_helpers::paragraph_char_count(last_paragraph); - self.extend_selection_from_position(&TextPositionWithAffinity::new( - PositionWithAffinity { - position: offset as i32, - affinity: Affinity::Upstream, - }, - num_paragraphs, - offset, - )); - self.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + self.select_range( + text_content, + &TextPositionWithAffinity::empty(), + &TextPositionWithAffinity::new_upstream_affinity(num_paragraphs, offset), + ); true } @@ -469,8 +473,6 @@ impl TextEditorState { text_content: &TextContent, position: &TextPositionWithAffinity, ) { - self.is_pointer_selection_active = false; - let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; @@ -485,7 +487,7 @@ impl TextEditorState { let chars: Vec<char> = paragraph_text.chars().collect(); if chars.is_empty() { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, 0, )); @@ -507,7 +509,7 @@ impl TextEditorState { } if !text_helpers::is_word_char(chars[offset]) { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, position.offset.min(chars.len()), )); @@ -527,17 +529,31 @@ impl TextEditorState { end += 1; } - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( - position.paragraph, - start, - )); - self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity( - position.paragraph, - end, - )); - self.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, start), + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, end), + ); + } + + pub fn select_paragraph( + &mut self, + text_content: &TextContent, + position: &TextPositionWithAffinity, + ) { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + // Offsets are counted in characters, not bytes. + let offset = text_helpers::paragraph_char_count(¶graphs[position.paragraph]); + + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, 0), + &TextPositionWithAffinity::new_upstream_affinity(position.paragraph, offset), + ); } pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { @@ -838,7 +854,7 @@ impl TextEditorState { let cursor = self.selection.focus; if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); self.selection.set_caret(new_cursor); } diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index fedfdba215..95bec9f9a6 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -29,14 +29,14 @@ pub fn clamp_cursor( paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { if paragraphs.is_empty() { - return TextPositionWithAffinity::new_without_affinity(0, 0); + return TextPositionWithAffinity::new_downstream_affinity(0, 0); } let para_idx = position.paragraph.min(paragraphs.len() - 1); let para_len = paragraph_char_count(¶graphs[para_idx]); let char_offset = position.offset.min(para_len); - TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, char_offset) } /// Move cursor left by one character. @@ -47,7 +47,7 @@ pub fn move_cursor_backward( ) -> TextPositionWithAffinity { if !word_boundary { if cursor.offset > 0 { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset - 1, ); @@ -55,7 +55,7 @@ pub fn move_cursor_backward( if cursor.paragraph > 0 { let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); - return TextPositionWithAffinity::new_without_affinity(prev_para, char_count); + return TextPositionWithAffinity::new_downstream_affinity(prev_para, char_count); } return *cursor; } @@ -111,7 +111,7 @@ pub fn move_cursor_backward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor right by one character. @@ -124,13 +124,13 @@ pub fn move_cursor_forward( let para = ¶graphs[cursor.paragraph]; let char_count = paragraph_char_count(para); if cursor.offset < char_count { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset + 1, ); } if cursor.paragraph < paragraphs.len() - 1 { - return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + return TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); } return *cursor; } @@ -185,7 +185,7 @@ pub fn move_cursor_forward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor up by one line. @@ -203,9 +203,9 @@ pub fn move_cursor_up( let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(prev_para, new_offset) } else { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } } @@ -224,10 +224,10 @@ pub fn move_cursor_down( let next_para = cursor.paragraph + 1; let char_count = paragraph_char_count(¶graphs[next_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(next_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(next_para, new_offset) } else { let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } } @@ -237,7 +237,7 @@ pub fn move_cursor_line_start( _paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { // TODO: Implement proper line-start using line metrics - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } /// Move cursor to end of current line. @@ -247,7 +247,7 @@ pub fn move_cursor_line_end( ) -> TextPositionWithAffinity { // TODO: Implement proper line-end using line metrics let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } pub fn is_word_char(c: char) -> bool { @@ -299,7 +299,7 @@ pub fn replace_text_with_newlines( if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, lines[0]) { current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -309,9 +309,9 @@ pub fn replace_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph + 1, 0); if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -338,7 +338,7 @@ pub fn insert_text_with_newlines( if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -348,9 +348,9 @@ pub fn insert_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph + 1, 0); if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -584,7 +584,7 @@ pub fn delete_char_before( let para = &mut paragraphs[cursor.paragraph]; let delete_pos = cursor.offset - 1; delete_range_in_paragraph(para, delete_pos, cursor.offset); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, delete_pos, )) @@ -603,7 +603,7 @@ pub fn delete_char_before( paragraphs.remove(cursor.paragraph); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( prev_para_idx, prev_para_len, )) @@ -675,13 +675,13 @@ pub fn delete_word_before( } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( start_paragraph, start_offset, )) @@ -748,8 +748,8 @@ pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWi } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); @@ -912,8 +912,8 @@ mod tests { fn selection(start: (usize, usize), end: (usize, usize)) -> TextSelection { TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start.0, start.1), - focus: TextPositionWithAffinity::new_without_affinity(end.0, end.1), + anchor: TextPositionWithAffinity::new_downstream_affinity(start.0, start.1), + focus: TextPositionWithAffinity::new_downstream_affinity(end.0, end.1), } } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 87e0c61899..258acd6601 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -5,8 +5,8 @@ use crate::math::{Matrix, Point}; use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; -use crate::shapes::{TextAlign, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::{State, TextEditorEvent}; +use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::uuid::Uuid; @@ -133,8 +133,10 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } -#[no_mangle] -pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { +fn with_active_text_at_point<F>(x: f32, y: f32, apply: F) +where + F: FnOnce(&mut TextEditorState, &TextContent, &TextPositionWithAffinity), +{ with_state!(state, { if !get_text_editor_state().has_focus { return; @@ -154,11 +156,33 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { let point = Point::new(x, y); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - get_text_editor_state().select_word_boundary(text_content, &position); + apply(get_text_editor_state(), text_content, &position); } }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_word_boundary(text_content, position) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_select_paragraph(x: f32, y: f32) { + // A drag that produced a range must survive the trailing click; a jitter + // that left the caret collapsed must not suppress the paragraph select. + let editor = get_text_editor_state(); + if editor.is_click_event_skipped && editor.selection.is_selection() { + editor.is_click_event_skipped = false; + return; + } + + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_paragraph(text_content, position) + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { get_text_editor_state().poll_event() as u8 From 034707a9d7932de9889e3a93478735890d1fc881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= <david.barragan@kaleidos.net> Date: Thu, 3 Sep 2026 17:51:25 +0200 Subject: [PATCH 278/298] :zap: Enable parallel integration tests on staging branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the temporary serial exception introduced alongside sharding. Sharded runs on `develop` complete in ~13 min against ~52 min for the serial runs on `staging`, and the gap keeps widening as the suite grows. Also restore the JSON report as a 30-day artifact. It was dropped when the suite moved to sharded blob reporters, which left flakiness rates only obtainable by scraping job logs. Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net> --- .github/workflows/tests-integration.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index 81d67c2d11..343e0dcef6 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -102,17 +102,14 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests (${{ matrix.shard }})" runs-on: penpot-extended-runner - timeout-minutes: ${{ github.base_ref == 'staging' && 60 || 25 }} + timeout-minutes: 40 needs: build-integration - # TEMPORARY (release stabilization): PRs targeting `staging` run on a - # single serial shard, so new flakes cannot block the release work. - # Remove the `github.base_ref` branch below to restore full parallelism. strategy: fail-fast: false matrix: - shard: ${{ fromJSON(inputs.shards || (github.base_ref == 'staging' && '[1]' || '[1, 2, 3, 4]')) }} + shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }} container: image: penpotapp/devenv:latest @@ -153,12 +150,8 @@ jobs: working-directory: ./frontend env: WORKERS: ${{ inputs.workers }} - BASE_REF: ${{ github.base_ref }} run: | - # TEMPORARY (release stabilization): see the note on the matrix above. - if [ -z "$WORKERS" ]; then - if [ "$BASE_REF" = "staging" ]; then WORKERS=1; else WORKERS=2; fi - fi + WORKERS=${WORKERS:-2} echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers" pnpm exec playwright test --project default \ --workers="$WORKERS" \ @@ -236,6 +229,18 @@ jobs: fi jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY" + # Kept for 30 days so flakiness rates can be aggregated across runs + # without scraping job logs. + - name: Upload JSON report + uses: actions/upload-artifact@v7 + if: always() + with: + name: integration-json-report + path: frontend/report.json + overwrite: true + if-no-files-found: ignore + retention-days: 30 + - name: Upload HTML report uses: actions/upload-artifact@v7 with: From f2f352d06c925d319cdced19ae5cd3f5f6da43ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= <marina.lopez.yap@gmail.com> Date: Fri, 4 Sep 2026 10:13:25 +0200 Subject: [PATCH 279/298] :sparkles: Add props to invitations and organizations events (#11508) --- .../src/app/rpc/commands/teams_invitations.clj | 5 ++--- backend/src/app/rpc/commands/verify_token.clj | 6 +++++- backend/test/backend_tests/rpc_team_test.clj | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 729205c4c9..7051c2b594 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -235,9 +235,8 @@ :organization-name (:name organization) :member-email (:email-to invitation) :member-id (:id member) - :role role} - organization - (assoc :user-who-send-invitation (str profile-id)) + :role role + :user-who-send-invitation (str profile-id)} (not organization) (assoc :team-belongs-to-organization (boolean team-organization-id) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 8db7afcd8b..d46c7a8057 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -308,7 +308,9 @@ (assoc :name "accept-organization-invitation") (assoc :props (-> props - (assoc :organization-id organization-id-on-add) + (assoc :organization-id organization-id-on-add + :user-id (:id profile) + :user-who-send-invitation (:created-by invitation)) (audit/clean-props)))))) (cond-> (assoc claims :state :created) @@ -325,6 +327,8 @@ (assoc :organization-id organization-id-on-add :organization-member-add-source organization-add-source :belongs-to-team-on-add (boolean team-id) + :user-id (:id profile) + :user-who-send-invitation (:created-by invitation) :organization-member-count-before organization-member-count-before) (audit/clean-props))})))))) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 3d755d8231..b087eb6819 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -154,10 +154,14 @@ (get-in % [:props :member-email]))) events))] (doseq [event [create-organization update-organization]] + (t/is (= (str (:id owner)) + (get-in event [:props :user-who-send-invitation]))) (t/is (true? (get-in event [:props :team-belongs-to-organization]))) (t/is (true? (get-in event [:props :adds-invitee-to-organization]))) (t/is (true? (get-in event [:props :invitee-already-organization-member])))) + (t/is (= (str (:id owner)) + (get-in create-plain [:props :user-who-send-invitation]))) (t/is (false? (get-in create-plain [:props :team-belongs-to-organization]))) (t/is (false? (get-in create-plain [:props :adds-invitee-to-organization]))) (t/is (false? (get-in create-plain [:props :invitee-already-organization-member]))))))) @@ -521,6 +525,9 @@ (let [event (organization-event)] (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (= (:id invitee) (get-in event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in event [:props :user-who-send-invitation]))) (t/is (not (contains? (:props event) :organization-member-add-source))) (t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (not (contains? (:props event) :organization-member-count-before))) @@ -530,6 +537,10 @@ (:origin @frontend-event))) (t/is (= organization-id (get-in @frontend-event [:props :organization-id]))) + (t/is (= (:id invitee) + (get-in @frontend-event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in @frontend-event [:props :user-who-send-invitation]))) (t/is (= "direct-organization-invitation" (get-in @frontend-event [:props :organization-member-add-source]))) (t/is (false? (get-in @frontend-event [:props :belongs-to-team-on-add]))) @@ -570,6 +581,9 @@ (t/is (some #(= "accept-team-invitation-from" (:name %)) events)) (t/is (= (:id team) (get-in event [:props :team-id]))) (t/is (= organization-id (get-in event [:props :organization-id]))) + (t/is (= (:id invitee) (get-in event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in event [:props :user-who-send-invitation]))) (t/is (not (contains? (:props event) :organization-member-add-source))) (t/is (not (contains? (:props event) :belongs-to-team-on-add))) (t/is (not (contains? (:props event) :organization-member-count-before))) @@ -578,6 +592,10 @@ (t/is (= (:id team) (get-in @frontend-event [:props :team-id]))) (t/is (= organization-id (get-in @frontend-event [:props :organization-id]))) + (t/is (= (:id invitee) + (get-in @frontend-event [:props :user-id]))) + (t/is (= (:id inviter) + (get-in @frontend-event [:props :user-who-send-invitation]))) (t/is (= "team-invitation" (get-in @frontend-event [:props :organization-member-add-source]))) (t/is (true? (get-in @frontend-event [:props :belongs-to-team-on-add]))) From 214dbd0c8bca19c7ba6a0e51c7972f61d671cc73 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Fri, 4 Sep 2026 10:15:38 +0200 Subject: [PATCH 280/298] :bug: Stop opening Comments section from persisting display-comments (#11492) AI-assisted-by: claude-sonnet-5 --- frontend/playwright/ui/pages/WorkspacePage.js | 6 ++ .../ui/specs/workspace-comments.spec.js | 61 +++++++++++++++++++ .../src/app/main/data/workspace/drawing.cljs | 6 -- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index f6b6a5a11a..d2953a2c71 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -600,6 +600,12 @@ export class WorkspacePage extends BaseWebSocketPage { .getByRole("button", { name: "Comments (C)" }) .click(clickOptions); } + + async toggleCommentsVisibilityFromMenu(clickOptions = {}) { + await this.page.getByRole("button", { name: "Main menu" }).click(); + await this.page.getByText("view").last().click(); + await this.page.locator("#file-menu-comments").click(clickOptions); + } } export default WorkspacePage; diff --git a/frontend/playwright/ui/specs/workspace-comments.spec.js b/frontend/playwright/ui/specs/workspace-comments.spec.js index 8cc3cbe203..735b10b044 100644 --- a/frontend/playwright/ui/specs/workspace-comments.spec.js +++ b/frontend/playwright/ui/specs/workspace-comments.spec.js @@ -35,3 +35,64 @@ test("Group bubbles when zooming out if they overlap", async ({ page }) => { /unread/, ); }); + +test("Opening the Comments section only temporarily overrides a disabled global comments setting", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.setupFileWithComments(); + await workspacePage.goToWorkspace(); + + const bubble = page.getByTestId("floating-thread-bubble-1"); + + // "Display comments" is enabled by default, so the bubble is already visible. + await expect(bubble).toBeVisible(); + + // Turn the global "Display comments" setting off from the main menu. + await workspacePage.toggleCommentsVisibilityFromMenu(); + await expect(bubble).toBeHidden(); + + // Opening the Comments section shows comments regardless of the global setting. + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + // Closing the Comments section falls back to the (still disabled) global setting. + await workspacePage.showComments(); + await expect(bubble).toBeHidden(); + + // The global setting itself must be untouched by opening/closing the section. + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("view").last().click(); + await expect(page.locator("#file-menu-comments")).toContainText( + "Show comments", + ); + await page.keyboard.press("Escape"); +}); + +test("Comments stay visible through opening and closing the Comments section when the global setting is enabled", async ({ + page, +}) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.setupFileWithComments(); + await workspacePage.goToWorkspace(); + + const bubble = page.getByTestId("floating-thread-bubble-1"); + + // "Display comments" is enabled by default. + await expect(bubble).toBeVisible(); + + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + await workspacePage.showComments(); + await expect(bubble).toBeVisible(); + + await page.getByRole("button", { name: "Main menu" }).click(); + await page.getByText("view").last().click(); + await expect(page.locator("#file-menu-comments")).toContainText( + "Hide comments", + ); + await page.keyboard.press("Escape"); +}); diff --git a/frontend/src/app/main/data/workspace/drawing.cljs b/frontend/src/app/main/data/workspace/drawing.cljs index 4812cf32a0..193b5747af 100644 --- a/frontend/src/app/main/data/workspace/drawing.cljs +++ b/frontend/src/app/main/data/workspace/drawing.cljs @@ -15,7 +15,6 @@ [app.main.data.workspace.drawing.common :as common] [app.main.data.workspace.drawing.curve :as curve] [app.main.data.workspace.drawing.line :as line] - [app.main.data.workspace.layout :as dwlo] [app.main.data.workspace.path :as path] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -47,11 +46,6 @@ (when (= tool :path) (rx/of (start-drawing :path))) - ;; NOTE: comments are a special case and they manage they - ;; own interrupt cycle. - (when (= tool :comments) - (rx/of (dwlo/toggle-layout-flag :display-comments :force? true))) - (when (and (not= tool :comments) (not= tool :path)) (let [stopper (rx/filter (ptk/type? ::clear-drawing) stream)] From 064f200774de80e307bd1c2c3191ab5b596157a6 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Fri, 4 Sep 2026 10:21:31 +0200 Subject: [PATCH 281/298] :bug: Fix crash pressing Ctrl+D with no shape selected (#11491) AI-assisted-by: claude-sonnet-5 --- frontend/src/app/main/data/workspace/variants.cljs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 2d224facc2..a8d013012b 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -539,9 +539,10 @@ (let [objects (dsh/lookup-page-objects state) selected-ids (dsh/lookup-selected state) selected-shapes (map (d/getf objects) selected-ids) - add-new-variant? (every? ctc/is-variant? selected-shapes) + add-new-variant? (and (seq selected-shapes) (every? ctc/is-variant? selected-shapes)) undo-id (js/Symbol)] - (if add-new-variant? + (cond + add-new-variant? (rx/concat (rx/of (ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:shortcut-duplicate"}) @@ -549,7 +550,12 @@ (add-new-variant (first selected-ids) false)) (rx/from (map #(add-new-variant % true) (rest selected-ids))) (rx/of (dwu/commit-undo-transaction undo-id))) - (rx/of (dws/duplicate-selected true))))))) + + (seq selected-ids) + (rx/of (dws/duplicate-selected true)) + + :else + (rx/empty)))))) (defn rename-variant "Rename the variant container and all components belonging to this variant" From fdcc4666e31e65a3d14f7d31a2850cd6c9a0b405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Fri, 4 Sep 2026 11:00:35 +0200 Subject: [PATCH 282/298] :bug: Fix multiple shapes export on files rendered with webgl (#11506) --- exporter/src/app/renderer/bitmap.cljs | 8 +++----- frontend/src/app/render.cljs | 8 +++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index e04c60076b..814bce13f2 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -17,7 +17,7 @@ [promesa.core :as p])) (defn render - [{:keys [file-id page-id share-id token scale type objects skip-children is-wasm] :as params} on-object] + [{:keys [file-id page-id share-id token scale type objects skip-children] :as params} on-object] (letfn [(prepare-options [uri] #js {:screen #js {:width bw/default-viewport-width :height bw/default-viewport-height} @@ -25,7 +25,7 @@ :height bw/default-viewport-height} :locale "en-US" :storageState #js {:cookies (bw/create-cookies uri {:token token})} - :deviceScaleFactor (if is-wasm 1 scale) ;; wasm won't use deviceScaleFactor + :deviceScaleFactor scale :userAgent bw/default-user-agent}) (render-object [page {:keys [id] :as object}] @@ -59,9 +59,7 @@ :share-id share-id :object-id (mapv :id objects) :route "objects" - :skip-children skip-children - :wasm (when is-wasm "true") - :scale scale} + :skip-children skip-children} uri (-> (cf/get-internal-uri) (u/ensure-path-slash) (u/join "render.html") diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index 99dfed871a..3d1931beaf 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -136,7 +136,9 @@ (repo/cmd! :get-page {:file-id file-id :page-id page-id :share-id share-id - :object-id object-id + :object-id (if (uuid? object-id) + object-id + (set object-id)) :features features})) (rx/tap (fn [[fonts]] (when (seq fonts) @@ -155,7 +157,7 @@ [:embed {:optional true} :boolean] [:skip-children {:optional true} :boolean] [:object-id - [:or [::sm/set ::sm/uuid] ::sm/uuid]]]) + [:or [:vector ::sm/uuid] ::sm/uuid]]]) (def ^:private coerce-render-objects-params (sm/coercer schema:render-objects)) @@ -188,7 +190,7 @@ {:file-id file-id :page-id page-id :share-id share-id - :object-ids (into #{} object-id) + :object-ids (into [] (distinct) object-id) :embed embed :skip-children skip-children :wasm wasm From 86aebd3429ca890f0ed061ada0a7f7b6e2fc9d3c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso <alejandro.alonso@kaleidos.net> Date: Fri, 4 Sep 2026 12:24:37 +0200 Subject: [PATCH 283/298] :zap: Reduce WASM text selrect churn after batch page load (#11471) * :bug: Align WASM auto-width text size with HTML measurement Include the HTML paragraph-set 1px right margin in Skia auto-width so WASM and DOM measurements match. * :zap: Scope and defer post-font WASM text relayout Track pending font faces per shape, force-relayout only affected texts, defer stale-selrect sync until the first tiles paint, and batch request-render after attr updates. Tolerate missing font-face keys so callers that omit them do not blow up. --- .../ui/specs/workspace-modifers.spec.js | 3 +- frontend/src/app/render_wasm/api.cljs | 194 +++++++++++++----- frontend/src/app/render_wasm/shape.cljs | 7 +- render-wasm/src/shapes/text.rs | 17 +- 4 files changed, 166 insertions(+), 55 deletions(-) diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index bbea6199f8..1fc688fbeb 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -233,5 +233,6 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { name: "Width", exact: true, }); - await expect(widthInput).toHaveValue("23"); + // WASM auto-width includes the HTML paragraph-set 1px right margin. + await expect(widthInput).toHaveValue("24"); }); diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index ee37ae995b..07fb91e98b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1523,6 +1523,60 @@ [content] (or content (tc/v2-default-text-content))) +(def ^:private empty-text-font-state + {:font-index {} :pending-faces #{}}) + +(defn- text-layout-fonts + "Content and fallback faces (emoji, Noto, ...) that `set-shape-text-content` + uploads, must match that path for pending-face tracking." + [content] + (into #{} (concat (f/get-content-fonts content) + (fonts-from-text-content content false)))) + +(defn- text-font-face-keys-state + "All font-face keys for a text content, and the subset not WASM-ready yet." + [content] + (reduce + (fn [acc font] + (let [font-data (f/make-font-data font) + key (f/font-data-key font-data) + pending? (not (f/font-ready? font-data))] + (-> acc + (update :font-face-keys conj key) + (cond-> pending? + (update :pending-font-face-keys conj key))))) + {:font-face-keys #{} :pending-font-face-keys #{}} + (text-layout-fonts content))) + +(defn- acc-text-font-state + [{:keys [font-index pending-faces]} id font-face-keys pending-font-face-keys] + {:font-index (reduce (fn [idx face] + (update idx face (fnil conj #{}) id)) + font-index + (or font-face-keys #{})) + :pending-faces (into (or pending-faces #{}) + (or pending-font-face-keys #{}))}) + +(defn text-font-state-for-shape + "Build the font-face index for a single text shape (incremental updates)." + [shape] + (if (cfh/text-shape? shape) + (let [content (ensure-text-content (:content shape)) + {:keys [font-face-keys pending-font-face-keys]} + (text-font-face-keys-state content)] + (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)) + empty-text-font-state)) + +(defn- shape-ids-for-pending-fonts + [{:keys [font-index pending-faces]}] + (when (seq pending-faces) + (into #{} + (mapcat #(get font-index % [])) + pending-faces))) + (defn- set-object-host-attrs "Host-specific attrs after structural upload (text/svg-raw/grid; optionally fills/strokes). When `skip-layout?` is true, flex+layout-item were already in @@ -1562,6 +1616,8 @@ (set-layout-data shape))) (let [is-text? (= type :text) + {:keys [font-face-keys pending-font-face-keys]} + (when is-text? (text-font-face-keys-state content)) text-content-pending (when is-text? (set-shape-text-content id content)) pending-thumbnails (into [] (concat text-content-pending @@ -1574,12 +1630,13 @@ (set-shape-strokes id strokes false write-fills-strokes?)))] {:thumbnails pending-thumbnails :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])}))) + :font-face-keys (or font-face-keys #{}) + :pending-font-face-keys (or pending-font-face-keys #{})}))) (defn set-object [shape] (if-not (and shape (wasm/live?)) - {:thumbnails [] :full [] :font-pending-ids []} + {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") (let [shape (svg-filters/apply-svg-derived shape)] @@ -1587,6 +1644,7 @@ (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") result))))) + (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates to the worker index." @@ -1611,6 +1669,29 @@ :auto-height (not (mth/close? height (:height selrect) 0.1)) false))) +(defonce ^:private pending-stale-selrect-ids (atom #{})) +(defonce ^:private stale-selrect-sync-token (atom 0)) + +(defn- flush-stale-selrect-sync! + [] + (when-let [ids (seq (first (reset-vals! pending-stale-selrect-ids #{})))] + (st/emit! (ptk/data-event ::stale-text-selrects {:ids (vec ids)})))) + +(defn- schedule-stale-selrect-sync! + "Coalesce stale-selrect emissions and defer until the first viewport tile + pass completes, then run on idle so page load can paint first." + [stale-ids] + (swap! pending-stale-selrect-ids into stale-ids) + (let [token (swap! stale-selrect-sync-token inc) + flush-on-idle! + (fn [] + (when (= token @stale-selrect-sync-token) + (timers/schedule-on-idle + (fn [] + (when (= token @stale-selrect-sync-token) + (flush-stale-selrect-sync!))))))] + (listen-tiles-render-complete-once! flush-on-idle!))) + (defn- sync-stale-text-selrects! "Emit the ids of auto-grow text shapes whose selrect no longer matches the measured layout, so the workspace resizes them (data-event instead of a @@ -1624,24 +1705,26 @@ (map :id)) shapes)] (when (seq stale-ids) - (st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids}))))) + (schedule-stale-selrect-sync! stale-ids)))) (defn- relayout-after-fonts! "Relayout text shapes once their pending fonts have resolved. Font fetches - are deduped per URL and storing a font does not invalidate cached layouts, - so every text shape (not only the fetch triggers in `font-pending-ids`) - needs a forced relayout; then re-sync selrects that drifted." - [shapes font-pending-ids] - (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + are deduped per URL, so only shapes that use a not-yet-ready face at upload + time need a forced relayout; then re-sync selrects that drifted for those + shapes only." + [shapes text-font-state] + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes) + affected-ids (or (shape-ids-for-pending-fonts text-font-state) #{}) + shapes-by-id (d/index-by :id shapes)] (when (seq text-ids) - (if (seq font-pending-ids) - (do - (force-update-text-layouts text-ids) - (sync-stale-text-selrects! shapes)) + (if (seq affected-ids) + (let [affected-shapes (into [] (keep shapes-by-id) affected-ids)] + (force-update-text-layouts affected-ids) + (sync-stale-text-selrects! affected-shapes)) (update-text-layouts text-ids))))) (defn process-pending - [shapes thumbnails full font-pending-ids on-complete] + [shapes thumbnails full text-font-state on-complete] (let [pending-thumbnails (d/index-by :key :callback thumbnails) @@ -1664,18 +1747,24 @@ (rx/reduce conj []) (rx/catch #(rx/empty)))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-ids) - (request-render "images-loaded")) noop-fn - (fn [] (when (fn? on-complete) (on-complete))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state) + (request-render "images-loaded") + (when (fn? on-complete) (on-complete))))) ;; No pending images — complete immediately. (when on-complete (on-complete))))) (defn process-object [shape] - (let [{:keys [thumbnails full font-pending-ids]} (set-object shape)] - (process-pending [shape] thumbnails full font-pending-ids noop-fn))) + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape) + text-font-state (acc-text-font-state empty-text-font-state + (:id shape) + font-face-keys + pending-font-face-keys)] + (process-pending [shape] thumbnails full text-font-state noop-fn))) (defn process-objects "Like process-object but for multiple shapes at once. Accumulates all @@ -1684,19 +1773,26 @@ just the first shape that triggered the fetch." [shapes] (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] + {:keys [thumbnails full text-font-state]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-state-acc empty-text-font-state] (if (< index total-shapes) (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] + {:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object shape)] (recur (inc index) (reduce conj! thumbnails-acc thumbnails) (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (process-pending shapes thumbnails full font-pending-ids noop-fn))) + :text-font-state font-state-acc}))] + (process-pending shapes thumbnails full text-font-state noop-fn))) (def ^:private ^:const BATCH_MAX_SHAPES 512) @@ -1706,8 +1802,8 @@ Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; host-specific attrs (fills/strokes/text/grid/path) stay per-shape. - Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" - [shapes start-index thumbnails-acc full-acc font-pending-acc] + Returns {:thumbnails [...] :full [...] :text-font-state {...} :next-index n}" + [shapes start-index thumbnails-acc full-acc text-font-state-acc] (let [total (count shapes) end-index (min total (+ start-index BATCH_MAX_SHAPES)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) @@ -1734,17 +1830,20 @@ (loop [xs prepared t-acc (transient thumbnails-acc) f-acc (transient full-acc) - fp-acc (transient font-pending-acc)] + font-state-acc text-font-state-acc] (if-let [shape (first xs)] - (let [{:keys [thumbnails full font-pending-ids]} + (let [{:keys [thumbnails full font-face-keys pending-font-face-keys]} (set-object-host-attrs shape true :skip-fills-strokes? true)] (recur (next xs) (reduce conj! t-acc thumbnails) (reduce conj! f-acc full) - (reduce conj! fp-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! t-acc) :full (persistent! f-acc) - :font-pending-ids (persistent! fp-acc) + :text-font-state font-state-acc :next-index end-index})))) (defn- set-objects-async @@ -1755,16 +1854,16 @@ (let [total-shapes (count shapes)] (p/create (fn [resolve _reject] - (letfn [(process-next-chunk [index thumbnails-acc full-acc font-pending-acc] + (letfn [(process-next-chunk [index thumbnails-acc full-acc text-font-state-acc] (if (< index total-shapes) ;; Process one time-budgeted chunk - (let [{:keys [thumbnails full font-pending-ids next-index]} + (let [{:keys [thumbnails full text-font-state next-index]} (process-shapes-chunk shapes index - thumbnails-acc full-acc font-pending-acc)] + thumbnails-acc full-acc text-font-state-acc)] ;; Yield to browser, then continue with next chunk (-> (yield-to-browser) (p/then (fn [_] - (process-next-chunk next-index thumbnails full font-pending-ids))))) + (process-next-chunk next-index thumbnails full text-font-state))))) ;; All chunks done - finalize (do (perf/end-measure "set-objects") @@ -1815,12 +1914,12 @@ (if (fn? callback) (callback) (rx/empty)))) (rx/reduce conj []))) (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-acc) - (request-render "images-loaded")) noop-fn - noop-fn)))))))))] - (process-next-chunk 0 [] [] [])))))) + noop-fn + (fn [] + (relayout-after-fonts! shapes text-font-state-acc) + (request-render "images-loaded")))))))))))] + (process-next-chunk 0 [] [] empty-text-font-state)))))) ;; This is a version of process-pending that doesn't have sideffects @@ -1887,22 +1986,25 @@ (when (and (contains? #{:path :bool} type) (some? (get shape :content))) (props/set-shape-path-content (get shape :content)))))) (let [total-shapes (count prepared) - {:keys [thumbnails full font-pending-ids]} + {:keys [thumbnails full text-font-state]} (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) - font-acc (transient [])] + font-state-acc empty-text-font-state] (if (< index total-shapes) (let [shape (nth prepared index) - {:keys [thumbnails full font-pending-ids]} + {:keys [thumbnails full font-face-keys pending-font-face-keys]} (set-object-host-attrs shape true :skip-fills-strokes? true)] (recur (inc index) (reduce conj! thumbnails-acc thumbnails) (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) {:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] + :text-font-state font-state-acc}))] (perf/end-measure "set-objects") (when on-shapes-ready (on-shapes-ready)) (when (wasm/live?) @@ -1910,7 +2012,7 @@ ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") (reset! view-interaction-active? false) - (process-pending shapes thumbnails full font-pending-ids + (process-pending shapes thumbnails full text-font-state (fn [] (if render-callback (render-callback) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index d351710eaa..0dd5870675 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -252,12 +252,12 @@ (let [text-content-pending (api/set-shape-text-content id v) pending-thumbnails (vec text-content-pending) pending-full (vec (api/set-shape-text-images id v)) - font-pending-ids (when (some :callback text-content-pending) [id])] + text-font-state (api/text-font-state-for-shape shape)] ;; FIXME: this is a hack to process the pending tasks ;; asynchronously we should probably modify set-wasm-attr! ;; to return a list of callbacks to be executed in a ;; second pass. - (api/process-pending [shape] pending-thumbnails pending-full font-pending-ids api/noop-fn) + (api/process-pending [shape] pending-thumbnails pending-full text-font-state api/noop-fn) nil)) :grow-type @@ -341,7 +341,8 @@ (when (d/not-empty? shape-changes) (->> (rx/from shape-changes) (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs"))))))) + (rx/reduce conj []) + (rx/subs! (fn [_] (api/request-render "set-wasm-attrs")))))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 1e9ae16aee..7cbf859ab4 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -90,6 +90,11 @@ pub struct TextContentSize { const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01; +/// Matches `marginRight: "1px"` on `.paragraph-set` in the HTML text renderer +/// (`frontend/src/app/main/ui/shapes/text/styles.cljs`). DOM `getBoundingClientRect` +/// includes that margin in auto-width measurements; Skia `longest_line()` does not. +const PARAGRAPH_SET_MARGIN_RIGHT: f32 = 1.0; + impl TextContentSize { pub fn default() -> Self { Self { @@ -863,7 +868,7 @@ impl TextContent { let measure_paragraphs = build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); - let width = measure_paragraphs + let content_width = measure_paragraphs .iter() .flatten() .fold(0.0_f32, |auto_width, paragraph| { @@ -871,9 +876,10 @@ impl TextContent { }) .ceil(); - // Re-layout at that width with the real alignment. + // Re-layout at the intrinsic width (without the HTML margin slack). let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width); let height = paragraphs .iter() .flatten() @@ -881,10 +887,11 @@ impl TextContent { auto_height + paragraph.height() }); + let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT; let size = TextContentSize::new_with_normalized_line_height( - width, + reported_width, height.ceil(), - width, + reported_width, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) From df48c834e3cd659683f5ab59ce6d9d8a9a7d6f53 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Fri, 4 Sep 2026 12:31:26 +0200 Subject: [PATCH 284/298] :bug: Fix create nil typography token (#11489) * :bug: Fix stroke width token dropdown closing mid-interaction The stroke-row* key included a hash of applied-tokens, forcing a full remount whenever the async token-resolution pipeline updated that state (e.g. after a detach action settles). If the remount landed while the width dropdown was open, it destroyed the dropdown and reset its open state, permanently closing it before the user could pick a token. Drop the hash from the key so the row only remounts on actual structural changes (add/remove/reorder), not on every token resolution tick. AI-assisted-by: claude-sonnet-5 * :bug: Fix crash when a typography token has a nil value A composite typography token saved with no fields filled in stores a nil :value. That value reached StyleDictionary's tokens-studio font-styles preprocessor, which assumes a typography value is never null and threw, crashing token resolution for every other token in the file. Reject the nil value at the source in the typography token form validation, and as defense in depth, filter nil-valued tokens out of every resolve-tokens* entry point before they reach StyleDictionary, tagging them with the existing empty-input error instead. Also remove a stray debug prn left in find-refs. AI-assisted-by: claude-sonnet-5 * :wrench: Gitignore local CLAUDE.md Keep the commit-conventions instructions file local to each contributor's checkout instead of tracking it in the repo. AI-assisted-by: claude-sonnet-5 * :zap: Use a single transducer to tag invalid-value tokens merge-invalid-value-tokens ran three separate passes over the token map (remove, map, into) and then merged the result back in. Combine the remove/map steps into one ns-level transducer, defined once instead of rebuilt on every call, and pass resolved as the seed to into so the trailing merge isn't needed either. AI-assisted-by: claude-sonnet-5 * :recycle: Drop redundant t/testing wrapper in nil-value token test The outer t/testing just repeated the deftest's own name and added nothing the two inner t/testing blocks (each covering one concrete assertion group) don't already say. AI-assisted-by: claude-sonnet-5 * :bug: Fail the nil-value token test on a resolution error rx/sub! only handles the success case, so if token resolution ever errors instead, done is never called and the async test hangs instead of failing. Switch to rx/subs! with an error handler that reports the failure and calls done, matching the pattern already used elsewhere in the tokens test suite. AI-assisted-by: claude-sonnet-5 --- .gitignore | 1 + common/src/app/common/files/tokens.cljc | 1 - .../src/app/main/data/style_dictionary.cljs | 50 +++++++++++++++---- .../sidebar/options/menus/stroke.cljs | 2 +- .../tokens/management/forms/typography.cljs | 10 ++-- .../tokens/style_dictionary_test.cljs | 37 ++++++++++++++ 6 files changed, 85 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index ec067552ff..47840dca52 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ opencode.json /.idea *.iml /.claude +/CLAUDE.md /.playwright-mcp /.devenv/mcp/ /opencode.json diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 95ff5da4df..9232037bcf 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -166,7 +166,6 @@ (not (ctob/token-name-path-exists? token-name tokens-tree))) new-tokens))))]]) (defn find-refs [value] - (prn value) (cond (string? value) (cto/find-token-value-references value) diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index ba1f2dd67f..320231793f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -584,11 +584,38 @@ (into {}))] (merge resolved dropped))) +(defn- valid-token-value? + [[_ token]] + (some? (:value token))) + +(def ^:private xform-invalid-value-tokens + (comp + (remove valid-token-value?) + (map (fn [[k token]] + [k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])])))) + +(defn- merge-invalid-value-tokens + "Tokens with a `nil` value (e.g. a composite typography token saved with + no fields filled in) must never reach StyleDictionary: some of its + preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor, + in particular) assume a typography token's value is never null and throw + an uncaught exception when it is, taking down token resolution for the + whole file. + + `tokens` is the full, unfiltered token map; `resolved` only contains the + valid subset that was actually sent to StyleDictionary. Tag the invalid + ones with the same \"empty value\" error the token forms already use + instead of ever letting them reach the resolver." + [tokens resolved] + (into resolved xform-invalid-value-tokens tokens)) + (defn resolve-tokens [tokens] - (let [tokens-tree (ctob/tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + tokens-tree (ctob/tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-interactive "Interactive check of resolving tokens. @@ -610,15 +637,18 @@ computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." [tokens] - (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + {:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-with-verbose-errors [tokens] - (resolve-tokens-tree - (ctob/tokens-tree tokens) - #(get tokens (sd-token-name %)) - (StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens)] + (resolve-tokens-tree + (ctob/tokens-tree valid-tokens) + #(get valid-tokens (sd-token-name %)) + (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))) ;; === Hooks diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index d671e7294c..0f8c03b819 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -276,7 +276,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) + [:> stroke-row* {:key (dm/str "stroke-" index) :index index :stroke value :title (tr "workspace.options.stroke-color") diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 62b9a63410..b053c130e3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -18,7 +18,6 @@ [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] [app.util.i18n :refer [tr]] - [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,11 +42,14 @@ (defn- validate-typography-token [{:keys [token-value] :as props}] (cond - ;; Entering form without a value - show no error just resolve nil - (nil? token-value) (rx/of nil) ;; Validate refrence string (cto/composite-token-reference? token-value) (default-validate-token props) - ;; Validate composite token + ;; Validate composite token. `token-value` may be nil when the form is + ;; submitted without any composite field filled in — normalize it to `{}` + ;; so `check-empty-typography-token` catches it and rejects the submit, + ;; instead of silently saving a token with a `nil` value (which later + ;; crashes token resolution: the tokens-studio StyleDictionary + ;; preprocessor assumes a typography token's value is never null). :else (-> props (update :token-value diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 220dd1f36b..60222f6bae 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -122,6 +122,43 @@ (get-in resolved-tokens ["typography.bad" :errors 0 :error/code]))) (done)))))))) +;; Regression: a token with a `nil` value (e.g. a composite typography +;; token saved via the workspace form with no fields filled in) must never +;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a +;; typography token's value is never null and throws an uncaught exception +;; on it, which used to take down resolution for every other token in the +;; file. It should be tagged with an empty-input error instead. +(t/deftest resolve-tokens-nil-value-test + (t/async + done + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "typography.empty" + :value nil + :type :typography})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (->> (sd/resolve-tokens tokens) + (rx/subs! + (fn [resolved-tokens] + (t/testing "the nil-value token is tagged with an error instead of crashing" + (t/is (contains? resolved-tokens "typography.empty")) + (t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input + (get-in resolved-tokens ["typography.empty" :errors 0 :error/code])))) + (t/testing "other tokens still resolve normally" + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] + (done))))))) + (t/deftest resolve-tokens-interactive-test (t/async done From 0533be100d23a53ef59c5a6228b7e2a32169e214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Fri, 4 Sep 2026 12:38:31 +0200 Subject: [PATCH 285/298] :bug: Fix text selection render overlay and double/triple click flash (#11512) --- .../ui/workspace/shapes/text/v3_editor.cljs | 59 +++++++++++++----- frontend/src/app/render_wasm/api.cljs | 61 ++----------------- frontend/src/app/render_wasm/text_editor.cljs | 5 -- render-wasm/docs/text_editor.md | 2 +- render-wasm/src/render.rs | 24 ++++++++ render-wasm/src/wasm/text_editor.rs | 48 +-------------- 6 files changed, 75 insertions(+), 124 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 7f0806fd34..fe0dc8a03b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -159,6 +159,10 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- double-click? + [^js native-event] + (= (.-detail native-event) 2)) + (defn- triple-click? [^js native-event] (>= (.-detail native-event) 3)) @@ -194,6 +198,8 @@ ;; WASM `is_pointer_selection_active` guard), not on every hover move. dragging-ref (mf/use-ref false) + deferred-press-ref (mf/use-ref nil) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -459,23 +465,29 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event)] ;; Repositioning the caret abandons the pending caret style (also ;; covers click and double-click, which fire pointer-down first). (text-editor/clear-pending-caret-styles!) - (mf/set-ref-val! dragging-ref true) (if (.-shiftKey event) - (wasm.api/text-editor-pointer-down-extend off-pt) - (wasm.api/text-editor-pointer-down off-pt)) - ;; Repaint the caret over the cached tiles instead of a full render, - ;; which flashes at high zoom (see `render-text-editor-overlay!`). - (wasm.api/render-text-editor-overlay!)))) + (do + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down-extend off-pt) + ;; Repaint the caret over the cached tiles instead of a full + ;; render, which flashes at high zoom. + (wasm.api/render-text-editor-overlay!)) + (mf/set-ref-val! deferred-press-ref off-pt))))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event)] + (when-let [pressed-pt (and (pos? (.-buttons native-event)) + (mf/ref-val deferred-press-ref))] + (mf/set-ref-val! deferred-press-ref nil) + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down pressed-pt)) (wasm.api/text-editor-pointer-move off-pt) ;; Only while dragging: `text-editor-pointer-move` is a no-op ;; otherwise, so avoid repainting on plain hover. @@ -486,20 +498,37 @@ (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] + off-pt (dom/get-offset-position native-event) + dragging? (mf/ref-val dragging-ref)] (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! deferred-press-ref nil) (wasm.api/text-editor-pointer-up off-pt) - (wasm.api/render-text-editor-overlay!)))) + ;; Without a drag there is no pointer selection to close; the + ;; caret is placed by `on-click`. + (when dragging? + (wasm.api/render-text-editor-overlay!))))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) - off-pt (dom/get-offset-position native-event)] - (if (triple-click? native-event) - (wasm.api/text-editor-select-paragraph off-pt) - (wasm.api/text-editor-set-cursor-from-offset off-pt)) - (wasm.api/render-text-editor-overlay!)))) + off-pt (dom/get-offset-position native-event)] + (cond + (triple-click? native-event) + (do + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/render-text-editor-overlay!)) + + ;; `dblclick` selects the word right after. Shift+click still goes + ;; through: WASM consumes its skip-click flag there. + (and (double-click? native-event) + (not (.-shiftKey event))) + nil + + :else + (do + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!)))))) on-double-click (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 07fb91e98b..a9c1718580 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -397,11 +397,6 @@ (or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY))) -(defn- frame-presented-target? - "True when this frame recomposited Target (full or early viewport present)." - [] - (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)) - (def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause). (defn- internal-render @@ -507,18 +502,8 @@ (try (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) - ;; Only repaint the overlay when this frame recomposited Target (a full - ;; frame or early viewport present). A partial frame is flushed but not - ;; presented - Target still shows the last presented frame with the - ;; overlay already on it - so repainting the translucent selection over - ;; it stacks another layer every progressive frame: it darkens, then - ;; snaps back when the final frame presents from the clean Backbuffer - ;; (the blink at the end of a zoom over a selection, gh-10709). - (when (frame-presented-target?) - (text-editor/text-editor-render-overlay)) - ;; Drain editor events. Only content/layout changes need a full shape - ;; re-render; selection/style changes are already reflected by the - ;; overlay redrawn just above. + ;; The editor overlay is painted by the WASM frame composition; only + ;; content/layout changes need a full shape re-render here. (when (drain-text-editor-events!) (request-render-preserving-target "text-editor-content"))) (catch :default e @@ -1421,28 +1406,6 @@ (let [local (get @st/state :workspace-local)] (or (:panning local) (:zooming local)))) -(defn- render-text-editor-overlay-if-active! - "Redraw the editor caret/selection straight onto the current Target frame when - an editor is active (no-op otherwise). Used after the direct `_render_from_cache` - / `internal-render` calls of a view interaction, which bypass the rAF `render` - loop that normally repaints the overlay. Without it the selection blinks out - for the duration of a pan/zoom gesture over a text shape (gh-10709)." - [] - (when (is-text-editor-wasm-enabled @st/state) - (text-editor/text-editor-render-overlay))) - -(defn- render-text-editor-overlay-after-frame! - "Repaint the overlay after a direct `internal-render`, but only when that - render recomposited Target (a full frame or early viewport present). A partial - frame is only flushed - Target keeps the last presented frame with the overlay - already on it - so repainting the translucent selection then stacks another - layer and it visibly darkens across the progressive frames before snapping - back on the final present (the blink at the end of a zoom over a selection, - gh-10709). The final full frame's own repaint keeps the overlay in place." - [] - (when (frame-presented-target?) - (render-text-editor-overlay-if-active!))) - (defn finalize-view-interaction! "Ends an in-progress pan/zoom view interaction and triggers a full-quality render. No-ops when no view interaction is active. @@ -1461,12 +1424,7 @@ ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered ;; ends (e.g. selecting a shape opens the options panel and resizes the ;; viewport), which previously blanked. - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) - ;; The direct render above bypasses the rAF `render` loop, so repaint the - ;; editor overlay explicitly. Only when this was a full frame: a progressive - ;; render keeps painting through the rAF loop and its partial frames must not - ;; be over-stamped (see `render-text-editor-overlay-after-frame!`). - (render-text-editor-overlay-after-frame!))) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES))) (def render-finish (letfn [(do-render [] @@ -1475,9 +1433,7 @@ (when (initialized?) (if (view-gesture-active?) ;; Pan/zoom pause: render without ending the interaction. - (do - (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) - (render-text-editor-overlay-after-frame!)) + (internal-render (js/performance.now) RENDER-FLAG-SYNC-TILES) (finalize-view-interaction!))))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) @@ -1495,15 +1451,6 @@ (perf/begin-measure "render-from-cache") (h/call wasm/internal-module "_render_from_cache" 0) - ;; Keep the text-editor caret/selection glued to the shapes while the view - ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox - ;; but omits the editor overlay, so without this the selection would vanish for - ;; the whole pan/zoom gesture and only flash back when the debounced full - ;; render lands — the blink seen when zooming in/out over a selection at high - ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the - ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no - ;; editor is active. - (render-text-editor-overlay-if-active!) (render-finish) (perf/end-measure "render-from-cache")))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index cbf6bf6bbd..9e73ff4ce8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -245,11 +245,6 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms))) -(defn text-editor-render-overlay - [] - (when (wasm/ready?) - (h/call wasm/internal-module "_text_editor_render_overlay"))) - (defn text-editor-render-caret "Re-compose the frame from the Backbuffer (the last full render) and draw the caret/selection overlay on top, submitting one atomic frame. Pixel identical diff --git a/render-wasm/docs/text_editor.md b/render-wasm/docs/text_editor.md index 8b65fb1f22..21c73c55db 100644 --- a/render-wasm/docs/text_editor.md +++ b/render-wasm/docs/text_editor.md @@ -108,7 +108,7 @@ flowchart TB FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"] FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"] FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"] - FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"] + FFI_Render["_text_editor_render_caret<br/>_text_editor_update_blink"] end subgraph Rust["Rust Layer"] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 932e8f80a4..64767b29bb 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -27,6 +27,7 @@ use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; +use crate::globals::get_text_editor_state; use crate::math; use crate::shapes::{ all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners, @@ -985,11 +986,34 @@ impl RenderState { debug::render(self); } if !self.preview_mode { + self.render_text_editor_overlay(tree); ui::render(self, tree); } debug::render_wasm_label(self); } + /// Drawn on Target before the UI surface is composited, so rulers and guides + /// stay above the selection band + fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) { + let editor_state = get_text_editor_state(); + let Some(shape_id) = editor_state.active_shape_id else { + return; + }; + let Some(shape) = tree.get(&shape_id) else { + return; + }; + + let viewbox = self.viewbox; + let options = self.options; + text_editor::render_overlay( + self.surfaces.canvas(SurfaceId::Target), + &viewbox, + &options, + editor_state, + shape, + ); + } + /// Renders only the canvas background and UI surface (rulers/frame), without /// rebuilding or drawing any shape tiles. Used to show the viewport frame /// immediately before shape tiles are built (e.g., right after a DPR change). diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 258acd6601..5db04aab98 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -3,8 +3,6 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point}; use crate::mem; -use crate::render::text_editor as text_editor_render; -use crate::render::SurfaceId; use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; @@ -851,7 +849,7 @@ fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) { /// Repaint the caret/selection over the last fully rendered frame. /// /// Re-composes Target from the Backbuffer (which still holds the last complete -/// render) and draws the editor overlay on top, in a single submitted frame. +/// render); the compose step draws the editor overlay itself. /// /// This exists because the caret blink must erase the previous caret, which /// means restoring the pixels underneath it. Doing that via `render_from_cache` @@ -867,49 +865,7 @@ pub extern "C" fn text_editor_render_caret() { }; update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - get_render_state().compose_frame(&state.shapes); - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); - }); -} - -#[no_mangle] -pub extern "C" fn text_editor_render_overlay() { - with_state!(state, { - let Some(shape_id) = get_text_editor_state().active_shape_id else { - return; - }; - - update_text_layout_if_needed(state, shape_id); - - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - - let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); - let viewbox = get_render_state().viewbox; - text_editor_render::render_overlay( - canvas, - &viewbox, - &get_render_state().options, - get_text_editor_state(), - shape, - ); - get_render_state().flush_and_submit(); + get_render_state().present_frame(&state.shapes); }); } From 5452eb5c8a2ae622e10016d86c91431b55a63e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= <marina.lopez.yap@gmail.com> Date: Fri, 4 Sep 2026 13:36:10 +0200 Subject: [PATCH 286/298] :bug: Fix email sales (#11514) --- frontend/src/app/main/ui/nitrate/nitrate_form.cljs | 4 ++-- frontend/src/app/main/ui/settings/subscription.cljs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index fc962e472c..c9c34bfb73 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -118,8 +118,8 @@ (tr "nitrate.form.contact-upgrade") (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]] + [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index ff38ffb1c6..957ffc6fa0 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -848,8 +848,8 @@ [:div {:class (stl/css :modal-text)} (tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]]])]])) + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]]])]])) (mf/defc nitrate-contact-sales-dialog {::mf/register modal/components @@ -898,7 +898,7 @@ :on-click handle-close-dialog} (tr "ds.confirm-cancel")] [:> button* {:variant "primary" :type "button" - :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) + :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) (mf/defc nitrate-cancel-contact-sales-dialog {::mf/register modal/components @@ -908,7 +908,7 @@ (js/encodeURIComponent email) mailto-url - (dm/str "mailto:sales@penpot.net" + (dm/str "mailto:sales@penpot.app" "?subject=Request%20to%20Cancel%20Enterprise%20Subscription" "&body=Hello%2C%0A%0A" "I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A" @@ -930,8 +930,8 @@ [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-text-medium)} (tr "nitrate.subscription.settings.manual-contact-us")] - [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"] + [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"] [:div {:class (stl/css :action-buttons)} [:> button* {:class (stl/css :button-full-width) :variant "primary" From d82038a570320ab4e23471b2d18c88b0c8110f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Fri, 4 Sep 2026 13:43:23 +0200 Subject: [PATCH 287/298] :bug: Fix paragraph span restyle on line change (#11503) * :bug: Fix paragraph span restyle on line change * :bug: Fix text shape boundaries on resize --- frontend/src/app/render_wasm/text_editor.cljs | 75 ++++++++++++------- render-wasm/src/shapes/text.rs | 31 +++++++- render-wasm/src/wasm/text_editor.rs | 13 +++- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 9e73ff4ce8..2f60004eac 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -565,41 +565,54 @@ [] (reset! pending-caret-styles {})) -(defn- merge-exported-texts-into-content - "Merge exported span texts back into the existing content tree. +(defn merge-exported-texts-into-content + "Merge exported spans back into the existing content tree. - The WASM editor may split or merge paragraphs (Enter / Backspace at - paragraph boundary), so the exported structure can differ from the - original. When extra paragraphs or spans appear we clone styling from - the nearest existing sibling; when fewer appear we truncate. + The WASM editor may split or merge paragraphs (Enter / Backspace at a + paragraph boundary, paste of several lines), so the exported structure can + differ from the original one, and a positional merge would leave the text of + one paragraph wearing the styling of another. Every exported span carries the + position it had in the tree we last exchanged with WASM (`:p`/`:s`), so the + styling is taken from there; a span WASM never saw falls back to its position + and then to the last existing span. - exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]] - content existing Penpot content map (root -> paragraph-set -> …)" - [content exported-texts] + exported vector of paragraphs, each a vector of `{:p 0 :s 0 :t \"text\"}` + content existing Penpot content map (root -> paragraph-set -> …)" + [content exported] (let [para-set (first (get content :children)) orig-paras (get para-set :children) - num-orig (count orig-paras) last-orig-para (when (seq orig-paras) (last orig-paras)) template-span (when last-orig-para (-> last-orig-para :children last)) + + styling-para + (fn [para-idx spans] + (or (get orig-paras (get (first spans) :p)) + (get orig-paras para-idx) + last-orig-para)) + + styling-span + (fn [orig-para span-idx {:keys [p s]}] + (or (get-in orig-paras [p :children s]) + (get-in orig-para [:children span-idx]) + (-> orig-para :children last) + template-span)) + new-paras - (mapv (fn [para-idx exported-span-texts] - (let [orig-para (if (< para-idx num-orig) - (nth orig-paras para-idx) - (dissoc last-orig-para :children)) - orig-spans (get orig-para :children) - num-orig-spans (count orig-spans) - last-orig-span (when (seq orig-spans) (last orig-spans))] + (mapv (fn [para-idx spans] + (let [orig-para (styling-para para-idx spans)] (assoc orig-para :children - (mapv (fn [span-idx new-text] - (let [orig-span (if (< span-idx num-orig-spans) - (nth orig-spans span-idx) - (or last-orig-span template-span))] - (assoc orig-span :text new-text))) - (range (count exported-span-texts)) - exported-span-texts)))) - (range (count exported-texts)) - exported-texts) + (if (seq spans) + (mapv (fn [span-idx span] + (-> (styling-span orig-para span-idx span) + (assoc :text (get span :t)))) + (range (count spans)) + spans) + ;; A paragraph with no spans is dropped on the way + ;; back to WASM (and fails the content schema). + [(assoc (or template-span {}) :text "")])))) + (range (count exported)) + exported) new-para-set (assoc para-set :children new-paras)] (assoc content :children [new-para-set]))) @@ -629,9 +642,9 @@ [] (when (and (wasm/ready?) (text-editor-has-focus?)) (let [shape-id (text-editor-get-active-shape-id) - new-texts (text-editor-export-content)] + new-texts (when shape-id (text-editor-export-content))] (when (and shape-id new-texts) - (let [texts-clj (js->clj new-texts) + (let [texts-clj (js->clj new-texts :keywordize-keys true) ;; A brand-new empty text shape (single click) has no cached ;; content yet, so fall back to a default template so the first ;; keystrokes are synced back to the shape instead of dropped. @@ -670,7 +683,11 @@ span-end (+ pos span-len) ol-start (max pos sel-start) ol-end (min span-end sel-end) - has-overlap? (< ol-start ol-end)] + ;; An empty span has no range to overlap, but an empty + ;; line inside the selection still has to be restyled. + has-overlap? (or (< ol-start ol-end) + (and (zero? span-len) + (<= sel-start pos sel-end)))] (if (not has-overlap?) (recur (rest spans) span-end (conj acc span)) (let [before (when (> ol-start pos) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 7cbf859ab4..f0f2fa9f1e 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -485,11 +485,19 @@ impl TextContent { seen } - pub fn add_paragraph(&mut self, paragraph: Paragraph) { + pub fn add_paragraph(&mut self, mut paragraph: Paragraph) { + let index = self.paragraphs.len() as u32; + paragraph.set_span_positions(index); self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); } + pub fn reset_span_positions(&mut self) { + for (index, paragraph) in self.paragraphs.iter_mut().enumerate() { + paragraph.set_span_positions(index as u32); + } + } + pub fn paragraphs(&self) -> &[Paragraph] { &self.paragraphs } @@ -598,7 +606,11 @@ impl TextContent { return self.content_rect(selrect, valign); } - let tight = if !self.layout.paragraphs.is_empty() { + let layout_matches_container = self + .layout_width + .is_some_and(|w| w.ceil() == self.get_width(selrect.width()).ceil()); + + let tight = if !self.layout.paragraphs.is_empty() && layout_matches_container { self.rect_from_paragraphs(selrect, valign) } else { let mut text_content = self.clone(); @@ -1276,6 +1288,12 @@ impl Paragraph { &mut self.children } + fn set_span_positions(&mut self, index: u32) { + for (span_index, span) in self.children.iter_mut().enumerate() { + span.set_position(index, span_index as u32); + } + } + fn char_count(&self) -> usize { self.children .iter() @@ -1441,6 +1459,8 @@ pub struct TextSpan { pub text_transform: Option<TextTransform>, pub text_direction: TextDirection, pub fills: Vec<shapes::Fill>, + pub paragraph_position: u32, + pub span_position: u32, } impl TextSpan { @@ -1470,6 +1490,8 @@ impl TextSpan { font_weight, font_variant_id, fills, + paragraph_position: u32::MAX, + span_position: u32::MAX, } } @@ -1477,6 +1499,11 @@ impl TextSpan { self.text = text; } + pub fn set_position(&mut self, paragraph: u32, span: u32) { + self.paragraph_position = paragraph; + self.span_position = span; + } + pub fn to_style( &self, content_bounds: &Rect, diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 5db04aab98..08e5c9bf54 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -880,11 +880,11 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { return std::ptr::null_mut(); }; - let Some(shape) = state.shapes.get(&shape_id) else { + let Some(shape) = state.shapes.get_mut(&shape_id) else { return std::ptr::null_mut(); }; - let Type::Text(text_content) = &shape.shape_type else { + let Type::Text(text_content) = &mut shape.shape_type else { return std::ptr::null_mut(); }; @@ -899,12 +899,19 @@ pub extern "C" fn text_editor_export_content() -> *mut u8 { .replace('\n', "\\n") .replace('\r', "\\r") .replace('\t', "\\t"); - span_parts.push(format!("\"{}\"", escaped_text)); + span_parts.push(format!( + "{{\"p\":{},\"s\":{},\"t\":\"{}\"}}", + span.paragraph_position, span.span_position, escaped_text + )); } json_parts.push(format!("[{}]", span_parts.join(","))); } let json = format!("[{}]", json_parts.join(",")); + // The host rebuilds its content tree out of this JSON, so the current + // positions are what the next call has to report against. + text_content.reset_span_positions(); + let mut bytes = json.into_bytes(); bytes.push(0); crate::mem::write_bytes(bytes) From 00e0492bb53e93360795eb7feb3b0baefc06383d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Fri, 4 Sep 2026 13:49:37 +0200 Subject: [PATCH 288/298] :bug: Fix import libraries missing team-id (#11505) --- backend/src/app/http/debug.clj | 5 +++++ backend/src/app/rpc/commands/management.clj | 1 + 2 files changed, 6 insertions(+) diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj index 1908cbd045..edcef0bb50 100644 --- a/backend/src/app/http/debug.clj +++ b/backend/src/app/http/debug.clj @@ -366,10 +366,14 @@ (if clone? (let [profile (profile/get-profile pool profile-id) project-id (:default-project-id profile) + team (teams/get-team pool + :profile-id profile-id + :project-id project-id) cfg (assoc cfg ::bfc/overwrite false ::bfc/profile-id profile-id ::bfc/project-id project-id + ::bfc/team-id (:id team) ::bfc/input path ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] @@ -628,6 +632,7 @@ ::bfc/profile-id profile-id ::bfc/project-id project-id ::bfc/input path + ::bfc/team-id (:id team) ::bfc/features (cfeat/get-team-enabled-features cf/flags team) ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size) ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))] diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 7725320e12..ecb805d4a3 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -425,6 +425,7 @@ cfg (-> cfg (assoc ::bfc/project-id project-id) (assoc ::bfc/profile-id profile-id) + (assoc ::bfc/team-id (:id team)) (assoc ::bfc/input template) (assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)) (assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)) From c8691d0cdebe4d97525c1e886cd8c7f3ffe61120 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 7 Sep 2026 08:34:06 +0200 Subject: [PATCH 289/298] :bug: Fix crash when a nil shape id reaches WASM modifiers (#10896) * :bug: Fix crash when a nil shape id reaches WASM modifiers A modif-tree containing a nil shape id crashed the WASM heap write with "Cannot read properties of null (reading '__u32_buffer')" in propagate-modifiers. Production builds elide the upstream asserts, so a nil id (e.g. update-dimensions called with a missing shape's parent id in the variants flow) reached the uuid heap write. Drop nil-keyed modif-tree entries in apply-wasm-modifiers and set-wasm-modifiers before they reach propagate-modifiers and set-structure-modifiers, filter nil ids at the parse level, and guard the variant reposition/creation events against missing shapes. AI-assisted-by: hy3 * :bug: Fix let scoping bugs in modifiers.cljs Two let forms in set-wasm-modifiers and apply-wasm-modifiers had their bindings vectors closed prematurely, causing undeclared var warnings for snap-pixel?, translation?, ids, update-shape, options, bool-ids, undo-id, and other bindings. In set-wasm-modifiers: removed premature ) after ] on the let bindings so the let body wraps snap-pixel? and translation?. In apply-wasm-modifiers: the cond form was not explicitly closed, so all subsequent bindings (ignore-tree, options, modif-tree, ids, update-shape, bool-ids, undo-id) were parsed as cond clauses instead of let bindings. Added ) to close cond after :else, and moved ] to close the bindings vector after undo-id. AI-assisted-by: mimo-v2.5-pro --- .../app/main/data/workspace/modifiers.cljs | 399 +++++++++--------- .../src/app/main/data/workspace/variants.cljs | 74 ++-- .../logic/wasm_modifiers_nil_id_test.cljs | 109 +++++ frontend/test/frontend_tests/runner.cljs | 2 + 4 files changed, 357 insertions(+), 227 deletions(-) create mode 100644 frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index cb8be131cd..314e8e8224 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -15,6 +15,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.container :as ctn] @@ -579,44 +580,57 @@ modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)] (assoc state :workspace-modifiers modifiers)))))) +(defn- without-nil-ids + "Drop nil-keyed entries from a modif-tree. A nil shape id (possible in + production builds, where the upstream asserts are elided) would crash + the WASM heap write with `uuid/get-u32` being called on nil." + [modif-tree] + (if (contains? modif-tree nil) + (do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry") + (dissoc modif-tree nil)) + modif-tree)) + (defn- parse-structure-modifiers [modif-tree] (into [] - (mapcat - (fn [[parent-id data]] - (when (ctm/has-structure? (:modifiers data)) - (->> (concat - (get-in data [:modifiers :structure-parent]) - (get-in data [:modifiers :structure-child])) - (mapcat - (fn [modifier] - (case (:type modifier) - :remove-children - (->> (:value modifier) - (map (fn [child-id] - {:type :remove-children - :parent parent-id - :id child-id - :index 0 - :value 0}))) + (comp + (mapcat + (fn [[parent-id data]] + (when (ctm/has-structure? (:modifiers data)) + (->> (concat + (get-in data [:modifiers :structure-parent]) + (get-in data [:modifiers :structure-child])) + (mapcat + (fn [modifier] + (case (:type modifier) + :remove-children + (->> (:value modifier) + (map (fn [child-id] + {:type :remove-children + :parent parent-id + :id child-id + :index 0 + :value 0}))) - :add-children - (->> (:value modifier) - (map (fn [child-id] - {:type :add-children - :parent parent-id - :id child-id - :index (:index modifier) - :value 0}))) + :add-children + (->> (:value modifier) + (map (fn [child-id] + {:type :add-children + :parent parent-id + :id child-id + :index (:index modifier) + :value 0}))) - :scale-content - [{:type :scale-content - :parent parent-id - :id parent-id - :index 0 - :value (:value modifier)}] - nil))))))) + :scale-content + [{:type :scale-content + :parent parent-id + :id parent-id + :index 0 + :value (:value modifier)}] + nil))))))) + (filter (fn [{:keys [id parent]}] + (and (some? id) (some? parent))))) modif-tree)) @@ -624,7 +638,7 @@ (let [default-transform (gmt/matrix)] (keep (fn [[id data]] (cond - (= id uuid/zero) + (or (nil? id) (= id uuid/zero)) nil (ctm/has-geometry? (:modifiers data)) @@ -693,65 +707,66 @@ subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] - (ptk/reify ::set-wasm-modifiers - ptk/UpdateEvent - (update [_ state] - (let [property-changes (extract-property-changes modif-tree)] - (if (d/not-empty? property-changes) - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)) - state))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::set-wasm-modifiers + ptk/UpdateEvent + (update [_ state] + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) - ptk/WatchEvent - (watch [_ state _] - ;; Entering an interactive transform (drag/resize/rotate). Flip - ;; the renderer into fast + atlas-backdrop mode so the live - ;; preview is cheap, tiles never appear sequentially and the main - ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) - (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + ptk/WatchEvent + (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (if translation? - ;; Pure translation: no structure changes needed. If structure - ;; modifiers were active from a previous non-translation frame - ;; (e.g. shape hovered over a frame then dragged back out), - ;; clear them now so the shape is not clipped by the old frame. - (when @wasm-structure-modifiers-active? - (wasm.api/clean-modifiers) - (vreset! wasm-structure-modifiers-active? false)) - (let [objects (dsh/lookup-page-objects state)] - (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) - (vreset! wasm-structure-modifiers-active? true))) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) - wasm-ready? (wasm.api/initialized?) - ;; While the GL context is down (lost / mid-reload), keep the - ;; root transforms so SVG selection/preview can still move. - ;; `propagate-modifiers` returns [] when not ready, do not - ;; treat that as "no modifiers". - modifiers - (cond - (or (not wasm-ready?) - (and translation? (not snap-pixel?))) - root-modifiers + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + wasm-ready? (wasm.api/initialized?) + ;; While the GL context is down (lost / mid-reload), keep the + ;; root transforms so SVG selection/preview can still move. + ;; `propagate-modifiers` returns [] when not ready, do not + ;; treat that as "no modifiers". + modifiers + (cond + (or (not wasm-ready?) + (and translation? (not snap-pixel?))) + root-modifiers - :else - (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (if (seq propagated) propagated root-modifiers)))] - (when wasm-ready? - (wasm.api/set-modifiers modifiers)) - (let [ids (into [] xf:map-key geometry-entries) - selrect (when wasm-ready? - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids)))] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers)))))))) + :else + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (if (seq propagated) propagated root-modifiers)))] + (when wasm-ready? + (wasm.api/set-modifiers modifiers)) + (let [ids (into [] xf:map-key geometry-entries) + selrect (when wasm-ready? + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids)))] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers))))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -782,58 +797,44 @@ subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] - (ptk/reify ::apply-wasm-modifiers - ptk/WatchEvent - (watch [_ state _] - (let [translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (wasm.api/clean-modifiers) - (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::apply-wasm-modifiers + ptk/WatchEvent + (watch [_ state _] + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (cond - (and translation? (not snap-pixel?)) - ;; Mirror WASM `propagate_modifiers` in CLJS: splat the - ;; translation matrix onto every descendant. Without - ;; this step the commit would only touch the dragged - ;; primaries and descendants would snap back to their - ;; pre-drag positions on drop. - ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. - (reduce - (fn [acc [id data]] - (let [t (:transform data) - subtree-ids - (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) - {} - geometry-entries) - - ;; Context lost / mid-reload: do not call into WASM. Use - ;; root transforms (and splat translation onto descendants - ;; when we can) so the commit still lands in file data. - (not (wasm.api/initialized?)) - (if translation? + transforms + (cond + (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -843,71 +844,87 @@ (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) {} geometry-entries) - (into {} - (map (fn [[id data]] [id (:transform data)])) - geometry-entries)) - :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + ;; Context lost / mid-reload: do not call into WASM. Use + ;; root transforms (and splat translation onto descendants + ;; when we can) so the commit still lands in file data. + (not (wasm.api/initialized?)) + (if translation? + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} + (map (fn [[id data]] [id (:transform data)])) + geometry-entries)) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + :else + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info. - ;; `update-layout? false`: recalculating a bool path can never change - ;; `:hidden`, and the layout check would recompute the whole boolean - ;; path in WASM once per bool shape just to find that out. - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options - :with-objects? true - :update-layout? false))) + undo-id (js/Symbol)] - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty)))))))) + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) + + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info. + ;; `update-layout? false`: recalculating a bool path can never change + ;; `:hidden`, and the layout check would recompute the whole boolean + ;; path in WASM once per bool shape just to find that out. + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options + :with-objects? true + :update-layout? false))) + + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty))))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index a8d013012b..dba4d8b09c 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -335,14 +335,15 @@ (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape (get objects shape-id) - container (get objects (:parent-id shape)) - width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants - x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants - (rx/of - (dwt/update-dimensions [(:parent-id shape)] :width width) - (dwt/update-position shape-id - {:x x} - {:absolute? false})))))) + container (get objects (:parent-id shape))] + (when (and (some? shape) (some? container)) + (let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants + x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants + (rx/of + (dwt/update-dimensions [(:parent-id shape)] :width width) + (dwt/update-position shape-id + {:x x} + {:absolute? false})))))))) (defn add-new-variant "Create a new variant and add it to the variant-container" @@ -359,39 +360,40 @@ shape (get objects shape-id) shape (if (ctc/is-variant-container? shape) (get objects (last (:shapes shape))) - shape) - component-id (:component-id shape) - component (ctkl/get-component data component-id) + shape)] + (when (some? shape) + (let [component-id (:component-id shape) + component (ctkl/get-component data component-id) - container-id (:parent-id shape) - variant-container (get objects container-id) - has-layout? (ctsl/any-layout? variant-container) + container-id (:parent-id shape) + variant-container (get objects container-id) + has-layout? (ctsl/any-layout? variant-container) - new-component-id (uuid/next) - new-shape-id (uuid/next) + new-component-id (uuid/next) + new-shape-id (uuid/next) - prop-num (dec (count (:variant-properties component))) + prop-num (dec (count (:variant-properties component))) - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (pcb/with-page-id page-id) - (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (pcb/with-page-id page-id) + (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) - undo-id (js/Symbol)] - (rx/concat - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (when-not has-layout? - (resposition-and-resize-variant new-shape-id)) - (dwu/commit-undo-transaction undo-id) - (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) - (if multiselect? - (dws/shift-select-shapes new-shape-id) - (dws/select-shape new-shape-id))) - (->> (rx/of (focus-property (:id variant-container))) - (rx/delay 250)))))))) + undo-id (js/Symbol)] + (rx/concat + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (when-not has-layout? + (resposition-and-resize-variant new-shape-id)) + (dwu/commit-undo-transaction undo-id) + (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) + (if multiselect? + (dws/shift-select-shapes new-shape-id) + (dws/select-shape new-shape-id))) + (->> (rx/of (focus-property (:id variant-container))) + (rx/delay 250)))))))))) (defn transform-in-variant "Given the id of a main shape of a component, creates a variant structure for diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs new file mode 100644 index 0000000000..7e39bf91ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -0,0 +1,109 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.wasm-modifiers-nil-id-test + "Reproduces the production crash \"Cannot read properties of null + (reading '__u32_buffer')\". + + A modif-tree containing a nil shape id (production builds elide the + asserts that catch this upstream, e.g. `update-dimensions` called + with `[(:parent-id shape)]` when `shape` is missing) reached + `wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`, + and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while + writing to the WASM heap. + + These tests assert that no nil id ever crosses the WASM boundary and + that valid shapes in the same modif-tree are still processed." + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private captured-geometry-entries + "Entries passed to `wasm.api/propagate-modifiers` during a test." + (atom [])) + +(def ^:private captured-structure-entries + "Entries passed to `wasm.api/set-structure-modifiers` during a test." + (atom [])) + +(defn- install-capturing-spies! + "Replace the plain WASM mocks with variants that record their input. + Must run after `thw/setup-wasm-mocks!` so teardown still restores + the real implementations." + [] + (set! wasm.api/propagate-modifiers + (fn [entries _pixel-precision] + (swap! captured-geometry-entries into entries) + (into [] + (map (fn [[id data]] [id (:transform data)])) + entries))) + (set! wasm.api/set-structure-modifiers + (fn [entries] + (swap! captured-structure-entries into entries) + nil))) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! captured-geometry-entries []) + (reset! captured-structure-entries []) + (thw/setup-wasm-mocks!) + (install-capturing-spies!)) + :after (fn [] + (thw/teardown-wasm-mocks!))}) + +(t/deftest nil-id-does-not-reach-propagate-modifiers + ;; A nil-keyed entry must be dropped before the WASM heap write while + ;; the valid entry is still resized. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + resize (ctm/change-dimensions-modifiers rect :width 200) + modif-tree {nil {:modifiers resize} + (:id rect) {:modifiers resize}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [new-state] + (let [entry-ids (into #{} (map first) @captured-geometry-entries) + file' (ths/get-file-from-state new-state) + rect' (cths/get-shape file' :rect1) + width (-> rect' :points grc/points->rect :width)] + (t/is (not (contains? entry-ids nil))) + (t/is (contains? entry-ids (:id rect))) + (t/is (mth/close? 200 width)))))))) + +(t/deftest nil-id-does-not-reach-set-structure-modifiers + ;; A nil-keyed entry with structure modifiers must not produce + ;; structure entries with a nil :parent or :id. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)} + (:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (every? #(some? (:parent %)) @captured-structure-entries)) + (t/is (every? #(some? (:id %)) @captured-structure-entries))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 73749f9810..3430ceb41a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -39,6 +39,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] + [frontend-tests.logic.wasm-modifiers-nil-id-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -138,6 +139,7 @@ 'frontend-tests.main-errors-test 'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.update-position-test + 'frontend-tests.logic.wasm-modifiers-nil-id-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test From a8e0b3c1f8e72bed1ba5ba36925de50487dcb9f8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 30 Jul 2026 08:35:38 +0000 Subject: [PATCH 290/298] :bug: Add dedicated RPC methods for plugin registry operations Add `add-profile-plugin` and `remove-profile-plugin` RPC methods for atomic plugin registry operations, preventing manipulation via the broader `update-profile-props` endpoint. - Close the `:plugins` field in `update-profile-props` schema to eliminate the mass assignment attack vector for plugin data. - Define `valid-permissions` and a closed `schema:permissions` enum to restrict plugin permissions to known values. - Migrate the frontend to use the new granular RPC methods with optimistic updates and rollback on failure. - Add comprehensive backend tests covering valid/invalid permissions, updates, removal, and rejection via old endpoint. AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc.clj | 1 + backend/src/app/rpc/commands/plugins.clj | 75 ++++++++ backend/src/app/rpc/commands/profile.clj | 3 +- .../test/backend_tests/rpc_plugins_test.clj | 162 ++++++++++++++++++ common/src/app/common/types/plugins.cljc | 16 +- frontend/src/app/plugins/register.cljs | 35 ++-- 6 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 backend/src/app/rpc/commands/plugins.clj create mode 100644 backend/test/backend_tests/rpc_plugins_test.clj diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 8828044dde..6c7b1a480a 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -400,6 +400,7 @@ 'app.rpc.commands.management 'app.rpc.commands.media 'app.rpc.commands.nitrate + 'app.rpc.commands.plugins 'app.rpc.commands.profile 'app.rpc.commands.projects 'app.rpc.commands.search diff --git a/backend/src/app/rpc/commands/plugins.clj b/backend/src/app/rpc/commands/plugins.clj new file mode 100644 index 0000000000..989f59ef36 --- /dev/null +++ b/backend/src/app/rpc/commands/plugins.clj @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns app.rpc.commands.plugins + (:require + [app.common.exceptions :as ex] + [app.common.schema :as sm] + [app.common.types.plugins :as ctp] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [app.rpc.doc :as-alias doc] + [app.util.services :as sv])) + +(defn- validate-plugin-permissions! + "Validates that all permissions in the plugin are within the valid set." + [plugin] + (let [permissions (:permissions plugin) + invalid (remove ctp/valid-permissions permissions)] + (when (seq invalid) + (ex/raise :type :validation + :code :invalid-plugin-permissions + :hint (str "Invalid permissions: " (pr-str (set invalid))) + :invalid-permissions (set invalid))))) + +(def ^:private + schema:add-profile-plugin + [:map {:title "add-profile-plugin"} + [:plugin ctp/schema:registry-entry]]) + +(sv/defmethod ::add-profile-plugin + {::doc/added "2.18" + ::sm/params schema:add-profile-plugin + ::sm/result ctp/schema:registry-entry + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin]}] + (validate-plugin-permissions! plugin) + + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + plugins (get-in profile [:props :plugins] {:ids [] :data {}}) + plugin-id (:plugin-id plugin) + plugins (-> plugins + (update :ids #(vec (distinct (conj % plugin-id)))) + (assoc-in [:data plugin-id] plugin))] + (db/update! conn :profile + {:props (db/tjson (assoc (:props profile) :plugins plugins))} + {:id profile-id} + {::db/return-keys false}) + plugin)) + +(def ^:private + schema:remove-profile-plugin + [:map {:title "remove-profile-plugin"} + [:plugin-id ::sm/uuid]]) + +(sv/defmethod ::remove-profile-plugin + {::doc/added "2.18" + ::sm/params schema:remove-profile-plugin + ::sm/result :nil + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}] + (let [profile (profile/get-profile conn profile-id ::db/for-update true) + plugins (get-in profile [:props :plugins] {:ids [] :data {}}) + plugin-id-str (str plugin-id) + plugins (-> plugins + (update :ids #(vec (remove (partial = plugin-id-str) %))) + (update :data dissoc plugin-id-str))] + (db/update! conn :profile + {:props (db/tjson (assoc (:props profile) :plugins plugins))} + {:id profile-id} + {::db/return-keys false}) + nil)) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index a803050c71..174be162a9 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -12,7 +12,6 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] - [app.common.types.plugins :refer [schema:plugin-registry]] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -54,7 +53,7 @@ (def system-managed-props "Props keys managed by the system (not user-writable via RPC)." - #{:subscription}) + #{:subscription :plugins}) (def schema:props [:map {:title "ProfileProps" :closed true} diff --git a/backend/test/backend_tests/rpc_plugins_test.clj b/backend/test/backend_tests/rpc_plugins_test.clj new file mode 100644 index 0000000000..9850d90ba9 --- /dev/null +++ b/backend/test/backend_tests/rpc_plugins_test.clj @@ -0,0 +1,162 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns backend-tests.rpc-plugins-test + (:require + [app.common.uuid :as uuid] + [app.rpc :as-alias rpc] + [app.rpc.commands.profile :as profile] + [backend-tests.helpers :as th] + [clojure.test :as t])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private plugin-id-1 (str (uuid/next))) +(def ^:private plugin-id-2 (str (uuid/next))) + +(def ^:private valid-plugin + {:plugin-id plugin-id-1 + :name "Test Plugin" + :description "A test plugin" + :host "https://example.com" + :code "(function() { console.log('hello'); })()" + :icon "icon.svg" + :permissions #{"content:read" "content:write"}}) + +(t/deftest add-profile-plugin-accepts-valid-permissions + (let [profile (th/create-profile* 1) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + out (th/command! data)] + + (t/is (nil? (:error out))) + (t/is (some? (:result out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= [plugin-id-1] (:ids plugins))) + (t/is (= valid-plugin (get-in plugins [:data plugin-id-1])))))) + +(t/deftest add-profile-plugin-rejects-invalid-permissions + (let [profile (th/create-profile* 1) + plugin (assoc valid-plugin :permissions #{"content:read" "admin:delete"}) + data {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin} + out (th/command! data)] + + ;; Schema validation catches invalid permissions before custom validation + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (nil? plugins) "No plugins should be persisted when validation fails")))) + +(t/deftest add-profile-plugin-updates-existing-plugin + (let [profile (th/create-profile* 1) + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + _ (th/command! data1) + + updated-plugin (assoc valid-plugin :name "Updated Plugin") + data2 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin updated-plugin} + out (th/command! data2)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= 1 (count (:ids plugins))) "Should still have only one plugin") + (t/is (= "Updated Plugin" (get-in plugins [:data plugin-id-1 :name])))))) + +(t/deftest remove-profile-plugin-removes-plugin + (let [profile (th/create-profile* 1) + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin valid-plugin} + _ (th/command! data1) + + data2 {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/uuid plugin-id-1)} + out (th/command! data2)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= [] (:ids plugins))) + (t/is (empty? (:data plugins)))))) + +(t/deftest remove-profile-plugin-handles-nonexistent-plugin + (let [profile (th/create-profile* 1) + data {::th/type :remove-profile-plugin + ::rpc/profile-id (:id profile) + :plugin-id (uuid/next)} + out (th/command! data)] + + (t/is (nil? (:error out))) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (or (nil? plugins) + (and (empty? (:ids plugins)) + (empty? (:data plugins)))) + "Plugins should be nil or empty when no plugins exist")))) + +(t/deftest add-profile-plugin-multiple-plugins + (let [profile (th/create-profile* 1) + plugin1 valid-plugin + plugin2 (assoc valid-plugin + :plugin-id plugin-id-2 + :name "Second Plugin") + + data1 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin1} + _ (th/command! data1) + + data2 {::th/type :add-profile-plugin + ::rpc/profile-id (:id profile) + :plugin plugin2} + _ (th/command! data2)] + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved) + plugins (get-in props [:props :plugins])] + (t/is (= 2 (count (:ids plugins)))) + (t/is (contains? (set (:ids plugins)) plugin-id-1)) + (t/is (contains? (set (:ids plugins)) plugin-id-2)) + (t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name]))) + (t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name])))))) + +(t/deftest update-profile-props-rejects-plugins + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:plugins {:ids ["test"] :data {"test" valid-plugin}}}} + out (th/command! data)] + + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (nil? (get-in props [:props :plugins])) + ":plugins must not be writable via update-profile-props")))) diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index e90356e0e4..bb74bfcc09 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -27,6 +27,20 @@ schema:string schema:string]]) +(def valid-permissions + "Set of valid plugin permissions that can be granted to plugins." + #{"content:read" "content:write" + "library:read" "library:write" + "comment:read" "comment:write" + "clipboard:read" "clipboard:write" + "user:read" + "allow:downloads" + "allow:localstorage"}) + +(def schema:permissions + "Schema for plugin permissions - a set of valid permission strings." + [:set {:gen/max 11} (into [:enum] (sort valid-permissions))]) + (def schema:registry-entry [:map [:plugin-id :string] @@ -36,7 +50,7 @@ [:host :string] [:code :string] [:icon {:optional true} :string] - [:permissions [:set :string]]]) + [:permissions schema:permissions]]) (def schema:plugin-registry [:map diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 5655deec51..2a3720c65e 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -112,13 +112,6 @@ manifest (.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest)))))) -(defn save-to-store - [] - ;; TODO: need this for the transition to the new schema. We can remove eventually - (let [registry (update @registry :data d/update-vals d/without-nils)] - (->> (rp/cmd! :update-profile-props {:props {:plugins registry}}) - (rx/subs! identity)))) - (defn load-from-store [] (reset! registry (get-in @st/state [:profile :props :plugins] {}))) @@ -127,6 +120,8 @@ [] (load-from-store)) +(declare remove-plugin!) + (defn install-plugin! [plugin] (letfn [(update-ids [ids] @@ -136,17 +131,27 @@ (swap! registry #(-> % (update :ids update-ids) (update :data assoc (:plugin-id plugin) plugin))) - (save-to-store))) + (->> (rp/cmd! :add-profile-plugin {:plugin plugin}) + (rx/subs! identity + (fn [err] + (remove-plugin! plugin) + (.error js/console "Failed to install plugin:" err)))))) (defn remove-plugin! [{:keys [plugin-id]}] - (letfn [(update-ids [ids] - (->> ids - (remove #(= % plugin-id))))] - (swap! registry #(-> % - (update :ids update-ids) - (update :data dissoc plugin-id))) - (save-to-store))) + (let [plugin (get-plugin plugin-id)] + (letfn [(update-ids [ids] + (->> ids + (remove #(= % plugin-id))))] + (swap! registry #(-> % + (update :ids update-ids) + (update :data dissoc plugin-id))) + (->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id}) + (rx/subs! identity + (fn [err] + (when plugin + (install-plugin! plugin)) + (.error js/console "Failed to remove plugin:" err))))))) (defn check-permission [plugin-id permission] From 9289e676e159e321d3e5b74e08a5a2b6624e8899 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Thu, 30 Jul 2026 09:54:57 +0000 Subject: [PATCH 291/298] :sparkles: Require re-confirmation when plugin manifest differs on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the fetched manifest differs from the stored manifest (code, name, etc.), show the permissions dialog instead of silently updating — preventing execution of tampered/injected plugins. On fetch error, show a warning notification instead of loading with the old manifest. Bundled plugins (no URL) skip validation as they are trusted with no remote source. Completes the 3-layer defense for T3-N1-02: (1) closed permission schema, (2) dedicated RPC methods, (3) integrity validation on open. AI-assisted-by: qwen3.7-plus --- backend/src/app/rpc/commands/profile.clj | 3 ++- frontend/src/app/main/data/plugins.cljs | 26 +++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 174be162a9..4106141f0b 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -12,6 +12,7 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.plugins :as ctp] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -57,7 +58,7 @@ (def schema:props [:map {:title "ProfileProps" :closed true} - [:plugins {:optional true} schema:plugin-registry] + [:plugins {:optional true} ctp/schema:plugin-registry] [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index bbdba8ee20..33d898cf69 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -115,7 +115,7 @@ [{:keys [url] :as manifest} user-can-edit?] (if url ;; If the saved manifest has a URL we fetch the manifest to check - ;; for updates + ;; for updates and validate integrity (->> (fetch-manifest url) (rx/subs! (fn [new-manifest] @@ -127,6 +127,8 @@ (cond (and is-edition-plugin? (not user-can-edit?)) (st/emit! (ntf/warn (tr "workspace.plugins.error.need-editor"))) + + ;; Permissions changed - show permissions dialog (not= (:permissions new-manifest) (:permissions manifest)) (modal/show! :plugin-permissions-update @@ -136,15 +138,25 @@ (preg/install-plugin! new-manifest) (load-plugin! new-manifest))}) + ;; Manifest changed (code, name, etc.) - require re-confirmation + ;; This prevents execution of tampered/injected plugins (not= new-manifest manifest) - (do (preg/install-plugin! new-manifest) - (load-plugin! manifest)) + (modal/show! + :plugin-permissions-update + {:plugin new-manifest + :on-accept + #(do + (preg/install-plugin! new-manifest) + (load-plugin! new-manifest))}) + + ;; Manifests match exactly - safe to load :else (load-plugin! manifest)))) - (fn [] - ;; Error fetching the manifest we'll load the plugin with the - ;; old manifest - (load-plugin! manifest)))) + (fn [_err] + ;; Error fetching the manifest - can't verify integrity + ;; Show error instead of loading potentially tampered code + (st/emit! (ntf/warn (tr "workspace.plugins.error.unreachable")))))) + ;; Bundled plugins (no URL) - trusted, load directly (load-plugin! manifest))) (defn close-plugin! From 960209f1faeed4da1b6b6cd0e6d009ba8ad04455 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 17 Aug 2026 12:35:15 +0000 Subject: [PATCH 292/298] :bug: Fix update-profile-props not rejecting plugin props at validation The schema:props schema still included :plugins as a valid key, so the closed-map validation passed instead of rejecting it. The system-managed-props denylist only silently stripped the key after validation, never raised an error. Derive schema:props-writeable from schema:props using sm/dissoc-key to exclude system-managed keys, and use it in the update-profile-props params schema. The original schema:props remains intact for get-profile result validation. AI-assisted-by: mimo-v2.5 --- backend/src/app/rpc/commands/profile.clj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 4106141f0b..6d878e0ebd 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -78,6 +78,10 @@ [:map-of {:gen/max 10} :keyword [:map-of :keyword :string]]] [:nudge {:optional true} schema:nudge]]) +(def schema:props-writeable + "Props schema for user-writable fields (excludes system-managed keys)." + (reduce sm/dissoc-key schema:props system-managed-props)) + (def schema:profile [:map {:title "Profile"} [:id ::sm/uuid] @@ -463,7 +467,7 @@ (def ^:private schema:update-profile-props [:map {:title "update-profile-props"} - [:props schema:props]]) + [:props schema:props-writeable]]) (defn update-profile-props [{:keys [::db/conn] :as cfg} profile-id props] From 7e1139b906e28f5c0fc9bbb88ca7ceb070090632 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 7 Sep 2026 09:31:35 +0200 Subject: [PATCH 293/298] :bug: Validate origin and route messages to sender in plugin postMessage channel (#10970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix plugin postMessage channel allowing cross-plugin message injection The global postMessage listener was broadcasting incoming messages to all loaded plugins without validating the origin or routing to the correct sender. This allowed any plugin (or any iframe from any origin) to inject messages into other plugins. - Added origin validation — messages from origins other than window.location.origin are rejected. - Added sender-based routing — a message is only delivered to the plugin whose iframe contentWindow matches event.source. - Exposed iframeWindow getters in PluginManager, PluginModalElement, and createPlugin so the runtime can compare event.source against the correct iframe reference. - Updated documentation examples to include origin validation and recommend window.location.origin over '*' for postMessage targetOrigin. AI-assisted-by: qwen3.7-plus * :bug: Fix plugin origin check breaking cross-origin plugin messaging The origin check added in the previous commit compared event.origin against window.location.origin (Penpot own origin). Since plugins are cross-origin by design (hosted on the plugin author domain), this check rejected every legitimate message from every real plugin. The event.source-based sender routing (matching iframeWindow identity) is the correct and sufficient security mechanism - it cannot be forged cross-origin, so the redundant origin check was removed. - Removed event.origin check from load-plugin.ts message listener - Updated tests to use realistic plugin origins (localhost:4202/4203) and to verify rejection based on source identity, not origin - Fixed documentation examples: use event.source for receiving validation and '*' for postMessage targetOrigin AI-assisted-by: mimo-v2.5-pro --- docs/plugins/create-a-plugin.md | 8 +- .../plugins-runtime/src/lib/create-plugin.ts | 3 + .../src/lib/load-plugin.spec.ts | 75 ++++++++++++++++++- .../plugins-runtime/src/lib/load-plugin.ts | 6 +- .../src/lib/modal/plugin-modal.ts | 23 +++--- .../plugins-runtime/src/lib/plugin-manager.ts | 3 + 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 9a25d101b9..0a8f14a8fe 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the <code class="lan ```js window.addEventListener("message", (event) => { + // Validate the source to ensure messages come from the parent (Penpot) + if (event.source !== window.parent) { + return; + } // Handle the incoming message console.log(event.data); }); @@ -129,11 +133,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp ```js // Sending a message back to Penpot from your plugin -parent.postMessage(responseMessage, targetOrigin); +parent.postMessage(responseMessage, "*"); ``` -<code class="language-js">responseMessage</code> is the data you want to send back to Penpot. --<code class="language-js">targetOrigin</code> should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use<code class="language-js">'*'</code> to allow all. +- Using<code class="language-js">'*'</code> as the target origin is acceptable here because the message content is controlled by your plugin (the sender), not by untrusted input. If you know the exact Penpot origin, you can use it instead for stricter security. ### Summary diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index fd7bc6a0a8..fc4ca233f3 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -39,5 +39,8 @@ export async function createPlugin( plugin, manifest, compartment: sandbox, + get iframeWindow() { + return plugin.iframeWindow; + }, }; } diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index cff0f5e57a..810ebd54b3 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -120,15 +120,86 @@ describe('plugin-loader', () => { }); it('should handle messages sent to plugins', async () => { + const mockIframeWindow = { nodeType: 1 } as unknown as Window; + const mockPluginWithIframe = { + plugin: { + close: mockClose, + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); + await loadPlugin(manifest); - window.dispatchEvent(new MessageEvent('message', { data: 'test-message' })); + const event = new MessageEvent('message', { + data: 'test-message', + origin: 'http://localhost:4202', + }); + Object.defineProperty(event, 'source', { value: mockIframeWindow }); + window.dispatchEvent(event); - expect(mockPluginApi.plugin.sendMessage).toHaveBeenCalledWith( + expect(mockPluginWithIframe.plugin.sendMessage).toHaveBeenCalledWith( 'test-message', ); }); + it('should reject messages from unrecognized sources', async () => { + await loadPlugin(manifest); + + const event = new MessageEvent('message', { + data: 'malicious-message', + origin: 'https://evil.com', + }); + Object.defineProperty(event, 'source', { + value: { nodeType: 999 } as unknown as Window, + }); + window.dispatchEvent(event); + + expect(mockPluginApi.plugin.sendMessage).not.toHaveBeenCalled(); + }); + + it('should only route messages to the sender plugin', async () => { + const mockIframeWindow1 = { nodeType: 1 } as unknown as Window; + const mockIframeWindow2 = { nodeType: 2 } as unknown as Window; + + const mockPluginApi1 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow1, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + const mockPluginApi2 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow2, + manifest: { ...manifest, host: 'http://localhost:4203' }, + } as unknown as Awaited<ReturnType<typeof createPlugin>>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); + await loadPlugin(manifest); + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi2); + await loadPlugin(manifest); + + const event = new MessageEvent('message', { + data: 'test', + origin: 'http://localhost:4203', + }); + Object.defineProperty(event, 'source', { value: mockIframeWindow2 }); + window.dispatchEvent(event); + + expect(mockPluginApi2.plugin.sendMessage).toHaveBeenCalledWith('test'); + expect(mockPluginApi1.plugin.sendMessage).not.toHaveBeenCalled(); + }); + it('should load plugin using ɵloadPlugin', async () => { await ɵloadPlugin(manifest); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index 8a88f23050..d05178f1f7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -30,8 +30,10 @@ const closeAllPlugins = () => { window.addEventListener('message', (event) => { try { - for (const it of plugins) { - it.plugin.sendMessage(event.data); + const senderPlugin = plugins.find((it) => it.iframeWindow === event.source); + + if (senderPlugin) { + senderPlugin.plugin.sendMessage(event.data); } } catch (err) { console.error(err); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 6939b8097a..f943c510a1 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -18,6 +18,7 @@ export class PluginModalElement extends HTMLElement { wrapper = document.createElement('div'); #inner = document.createElement('div'); #dragEvents: ReturnType<typeof dragHandler> | null = null; + #iframe: HTMLIFrameElement | null = null; setTheme(theme: Theme) { if (this.wrapper) { @@ -110,15 +111,15 @@ export class PluginModalElement extends HTMLElement { header.appendChild(closeButton); - const iframe = document.createElement('iframe'); - iframe.src = iframeSrc; + this.#iframe = document.createElement('iframe'); + this.#iframe.src = iframeSrc; const allowList: string[] = []; if (allowClipboardRead) allowList.push('clipboard-read'); if (allowClipboardWrite) allowList.push('clipboard-write'); - iframe.allow = allowList.join('; '); + this.#iframe.allow = allowList.join('; '); - iframe.sandbox.add( + this.#iframe.sandbox.add( 'allow-scripts', 'allow-forms', 'allow-modals', @@ -129,10 +130,10 @@ export class PluginModalElement extends HTMLElement { ); if (allowDownloads) { - iframe.sandbox.add('allow-downloads'); + this.#iframe.sandbox.add('allow-downloads'); } - iframe.addEventListener('load', () => { + this.#iframe.addEventListener('load', () => { this.shadowRoot?.dispatchEvent( new CustomEvent('load', { composed: true, @@ -159,12 +160,12 @@ export class PluginModalElement extends HTMLElement { ); this.addEventListener('message', (e: Event) => { - if (!iframe.contentWindow) { + if (!this.#iframe?.contentWindow) { return; } try { - iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); + this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); } catch (err) { console.error( 'plugin modal: failed to send message to iframe via postMessage.', @@ -177,7 +178,7 @@ export class PluginModalElement extends HTMLElement { this.wrapper.appendChild(this.#inner); this.#inner.appendChild(header); - this.#inner.appendChild(iframe); + this.#inner.appendChild(this.#iframe); const style = document.createElement('style'); style.textContent = modalCss; @@ -187,6 +188,10 @@ export class PluginModalElement extends HTMLElement { this.calculateZIndex(); } + getIframeContentWindow(): Window | null { + return this.#iframe?.contentWindow ?? null; + } + size() { const width = Number(this.wrapper.style.width.replace('px', '') || '300'); const height = Number(this.wrapper.style.height.replace('px', '') || '400'); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 8b811f55eb..2a2b43e1c6 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -157,6 +157,9 @@ export async function createPluginManager( } }, getModal: () => modal, + get iframeWindow(): Window | null { + return modal?.getIframeContentWindow() ?? null; + }, registerListener, registerMessageCallback, sendMessage: (message: unknown) => { From f0680cf5f8790a5e11682daad91cf96f4d108eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= <elenatorro@gmail.com> Date: Mon, 7 Sep 2026 09:53:45 +0200 Subject: [PATCH 294/298] :bug: Handle empty variant options (#11499) * :bug: Handle empty variant options * :bug: Disable variant value select when there are no options --- .../workspace/sidebar/options/menus/component.cljs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index d2a8ff1c90..5036ad87a7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -290,14 +290,18 @@ get-components-with-duplicated-variant-props-and-values (map :main-instance-id))) +(defn- get-variant-option + [val] + {:id val + :label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)}) + (defn- get-variant-options "Get variant options for a given property name" [prop-name prop-vals] (->> (filter #(= (:name %) prop-name) prop-vals) first :value - (mapv (fn [val] {:id val - :label (if (str/blank? val) (str "(" (tr "labels.empty") ")") val)})))) + (mapv get-variant-option))) (mf/defc component-variant-property* [{:keys [pos prop options on-prop-name-blur on-prop-value-change on-reorder]}] @@ -527,8 +531,12 @@ (for [[pos prop] (map-indexed vector props-first)] (let [mixed-value? (not-every? #(= (:value prop) (:value (get % pos))) properties) base-options (get options-by-name (:name prop)) + no-options? (empty? base-options) boolean-pair (ctv/find-boolean-pair (mapv :id base-options)) options (cond-> base-options + no-options? + (conj (get-variant-option (:value prop))) + mixed-value? (conj {:id mixed-label :label mixed-label :dimmed true}))] @@ -549,6 +557,7 @@ [:> select* {:default-selected (if mixed-value? mixed-label (:value prop)) :options options :empty-to-end true + :disabled no-options? :on-change (partial switch-component pos) :key (str (:value prop) "-" key)}]])]))] From cd98a88c4d0730d8558e615e2baca00b4f1a9f19 Mon Sep 17 00:00:00 2001 From: Eva Marco <eva.marco@kaleidos.net> Date: Mon, 7 Sep 2026 10:14:09 +0200 Subject: [PATCH 295/298] :bug: Fix errors on shortcuts (#11081) * :bug: Fix shift + character recording * :bug: Fix importing conflicts * :bug: Fix paste as non customizable shortcut * :bug: Fix duplicate manage of custom shortcuts * :bug: Fix json file name * :tada: Add search by commands * :bug: Fix CI --- frontend/packages/mousetrap/index.js | 335 ++++++++++-------- frontend/playwright/ui/pages/ShortcutsPage.js | 4 +- .../app/main/data/workspace/shortcuts.cljs | 1 + frontend/src/app/main/ui/hooks.cljs | 11 +- .../src/app/main/ui/settings/shortcuts.cljs | 38 +- frontend/src/app/main/ui/shortcuts.cljs | 64 +++- frontend/src/app/main/ui/shortcuts.scss | 4 + .../main/ui/workspace/sidebar/shortcuts.cljs | 10 +- .../ui/settings_shortcuts_test.cljs | 70 ++++ 9 files changed, 361 insertions(+), 176 deletions(-) diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 12bcbab1b9..4d0576e417 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -30,29 +30,29 @@ const globalDocument = globalThis?.document; * @type {Object} */ var _MAP = { - 8: 'backspace', - 9: 'tab', - 13: 'enter', - 16: 'shift', - 17: 'ctrl', - 18: 'alt', - 20: 'capslock', - 27: 'esc', - 32: 'space', - 33: 'pageup', - 34: 'pagedown', - 35: 'end', - 36: 'home', - 37: 'left', - 38: 'up', - 39: 'right', - 40: 'down', - 45: 'ins', - 46: 'del', - 91: 'meta', - 93: 'meta', - 224: 'meta', - 219: '219' + 8: "backspace", + 9: "tab", + 13: "enter", + 16: "shift", + 17: "ctrl", + 18: "alt", + 20: "capslock", + 27: "esc", + 32: "space", + 33: "pageup", + 34: "pagedown", + 35: "end", + 36: "home", + 37: "left", + 38: "up", + 39: "right", + 40: "down", + 45: "ins", + 46: "del", + 91: "meta", + 93: "meta", + 224: "meta", + 219: "219", }; /** @@ -64,22 +64,22 @@ var _MAP = { * @type {Object} */ var _KEYCODE_MAP = { - 106: '*', - 107: '+', - 109: '-', - 110: '.', - 111 : '/', - 186: ';', - 187: '=', - 188: ',', - 189: '-', - 190: '.', - 191: '/', - 192: '`', - 219: '[', - 220: '\\', - 221: ']', - 222: '\'' + 106: "*", + 107: "+", + 109: "-", + 110: ".", + 111: "/", + 186: ";", + 187: "=", + 188: ",", + 189: "-", + 190: ".", + 191: "/", + 192: "`", + 219: "[", + 220: "\\", + 221: "]", + 222: "'", }; /** @@ -93,25 +93,25 @@ var _KEYCODE_MAP = { * @type {Object} */ var _SHIFT_MAP = { - '~': '`', - '!': '1', - '@': '2', - '#': '3', - '$': '4', - '%': '5', - '^': '6', - '&': '7', - '*': '8', - '(': '9', - ')': '0', - '_': '-', - '+': '=', - ':': ';', - '\"': '\'', - '<': ',', - '>': '.', - '?': '/', - '|': '\\' + "~": "`", + "!": "1", + "@": "2", + "#": "3", + $: "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + _: "-", + "+": "=", + ":": ";", + '\"': "'", + "<": ",", + ">": ".", + "?": "/", + "|": "\\", }; /** @@ -124,12 +124,12 @@ var _SHIFT_MAP = { var globalNavigator = globalThis.navigator; var _SPECIAL_ALIASES = { - 'option': 'alt', - 'command': 'meta', - 'return': 'enter', - 'escape': 'esc', - 'plus': '+', - 'mod': /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? 'meta' : 'ctrl' + option: "alt", + command: "meta", + return: "enter", + escape: "esc", + plus: "+", + mod: /Mac|iPod|iPhone|iPad/.test(globalNavigator?.platform) ? "meta" : "ctrl", }; /** @@ -146,14 +146,13 @@ var _REVERSE_MAP; * programatically */ for (var i = 1; i < 20; ++i) { - _MAP[111 + i] = 'f' + i; + _MAP[111 + i] = "f" + i; } /** * loop through to map numbers on the numeric keypad */ for (i = 0; i <= 9; ++i) { - // This needs to use a string cause otherwise since 0 is falsey // mousetrap will never fire for numpad 0 pressed as part of a keydown // event. @@ -176,7 +175,7 @@ function _addEvent(object, type, callback) { return; } - object.attachEvent('on' + type, callback); + object.attachEvent("on" + type, callback); } /** @@ -186,17 +185,16 @@ function _addEvent(object, type, callback) { * @return {string} */ function _characterFromEvent(e) { - // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. - if (e.code && e.code.indexOf('Numpad') === 0) { + if (e.code && e.code.indexOf("Numpad") === 0) { var suffix = e.code.substring(6); - if (suffix.length === 1 && suffix >= '0' && suffix <= '9') { - return 'num' + suffix; + if (suffix.length === 1 && suffix >= "0" && suffix <= "9") { + return "num" + suffix; } } // for keypress events we should return the character as is - if (e.type == 'keypress') { + if (e.type == "keypress") { var character = String.fromCharCode(e.which); // if the shift key is not pressed then it is safe to assume @@ -225,6 +223,9 @@ function _characterFromEvent(e) { } // if it is not in the special map + if (typeof e.key === "string") { + return e.key.toLowerCase(); + } // with keydown and keyup events the character seems to always // come in as an uppercase character whether you are pressing shift @@ -240,7 +241,7 @@ function _characterFromEvent(e) { * @returns {boolean} */ function _modifiersMatch(modifiers1, modifiers2) { - return modifiers1.sort().join(',') === modifiers2.sort().join(','); + return modifiers1.sort().join(",") === modifiers2.sort().join(","); } /** @@ -253,19 +254,19 @@ function _eventModifiers(e) { var modifiers = []; if (e.shiftKey) { - modifiers.push('shift'); + modifiers.push("shift"); } if (e.altKey) { - modifiers.push('alt'); + modifiers.push("alt"); } if (e.ctrlKey) { - modifiers.push('ctrl'); + modifiers.push("ctrl"); } if (e.metaKey) { - modifiers.push('meta'); + modifiers.push("meta"); } return modifiers; @@ -308,7 +309,7 @@ function _stopPropagation(e) { * @returns {boolean} */ function _isModifier(key) { - return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta'; + return key == "shift" || key == "ctrl" || key == "alt" || key == "meta"; } /** @@ -321,7 +322,6 @@ function _getReverseMap() { if (!_REVERSE_MAP) { _REVERSE_MAP = {}; for (var key in _MAP) { - // pull out the numeric keypad from here cause keypress should // be able to detect the keys from the character if (key > 95 && key < 112) { @@ -344,17 +344,16 @@ function _getReverseMap() { * @param {string=} action passed in */ function _pickBestAction(key, modifiers, action) { - // if no action was picked in we should try to pick the one // that we think would work best for this key if (!action) { - action = _getReverseMap()[key] ? 'keydown' : 'keypress'; + action = _getReverseMap()[key] ? "keydown" : "keypress"; } // modifier keys don't work as expected with keypress, // switch to keydown - if (action == 'keypress' && modifiers.length) { - action = 'keydown'; + if (action == "keypress" && modifiers.length) { + action = "keydown"; } return action; @@ -367,12 +366,12 @@ function _pickBestAction(key, modifiers, action) { * @return {Array} */ function _keysFromString(combination) { - if (combination === '+') { - return ['+']; + if (combination === "+") { + return ["+"]; } - combination = combination.replace(/\+{2}/g, '+plus'); - return combination.split('+'); + combination = combination.replace(/\+{2}/g, "+plus"); + return combination.split("+"); } /** @@ -403,9 +402,9 @@ function _getKeyInfo(combination, action) { // if this is not a keypress event then we should // be smart about using shift keys // this will only work for US keyboards however - if (action && action != 'keypress' && _SHIFT_MAP[key]) { + if (action && action != "keypress" && _SHIFT_MAP[key]) { key = _SHIFT_MAP[key]; - modifiers.push('shift'); + modifiers.push("shift"); } // if this key is a modifier then add it to the list of modifiers @@ -421,7 +420,7 @@ function _getKeyInfo(combination, action) { return { key: key, modifiers: modifiers, - action: action + action: action, }; } @@ -510,7 +509,7 @@ function Mousetrap(targetElement) { doNotReset = doNotReset || {}; var activeSequences = false, - key; + key; for (key in _sequenceLevels) { if (doNotReset[key]) { @@ -537,7 +536,14 @@ function Mousetrap(targetElement) { * @param {number=} level * @returns {Array} */ - function _getMatches(character, modifiers, e, sequenceName, combination, level) { + function _getMatches( + character, + modifiers, + e, + sequenceName, + combination, + level, + ) { var i; var callback; var matches = []; @@ -549,7 +555,7 @@ function Mousetrap(targetElement) { } // if a modifier key is coming up on its own we should allow it - if (action == 'keyup' && _isModifier(character)) { + if (action == "keyup" && _isModifier(character)) { modifiers = [character]; } @@ -560,7 +566,11 @@ function Mousetrap(targetElement) { // if a sequence name is not specified, but this is a sequence at // the wrong level then move onto the next match - if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) { + if ( + !sequenceName && + callback.seq && + _sequenceLevels[callback.seq] != callback.level + ) { continue; } @@ -577,15 +587,20 @@ function Mousetrap(targetElement) { // chrome will not fire a keypress if meta or control is down // safari will fire a keypress if meta or meta+shift is down // firefox will fire a keypress if meta or control is down - if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) { - + if ( + (action == "keypress" && !e.metaKey && !e.ctrlKey) || + _modifiersMatch(modifiers, callback.modifiers) + ) { // when you bind a combination or sequence a second time it // should overwrite the first one. if a sequenceName or // combination is specified in this call it does just that // // @todo make deleting its own method? var deleteCombo = !sequenceName && callback.combo == combination; - var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level; + var deleteSequence = + sequenceName && + callback.seq == sequenceName && + callback.level == level; if (deleteCombo || deleteSequence) { self._callbacks[character].splice(i, 1); } @@ -608,7 +623,6 @@ function Mousetrap(targetElement) { * @returns void */ function _fireCallback(callback, e, combo, sequence) { - // if this event should not happen stop here if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { return; @@ -628,7 +642,7 @@ function Mousetrap(targetElement) { * @param {Event} e * @returns void */ - self._handleKey = function(character, modifiers, e) { + self._handleKey = function (character, modifiers, e) { var callbacks = _getMatches(character, modifiers, e); var i; var doNotReset = {}; @@ -644,14 +658,12 @@ function Mousetrap(targetElement) { // loop through matching callbacks for this key event for (i = 0; i < callbacks.length; ++i) { - // fire for all sequence callbacks // this is because if for example you have multiple sequences // bound such as "g i" and "g t" they both need to fire the // callback for matching g cause otherwise you can only ever // match the first one if (callbacks[i].seq) { - // only fire callbacks for the maxLevel to prevent // subsequences from also firing // @@ -668,7 +680,12 @@ function Mousetrap(targetElement) { // keep a list of which sequences were matches for later doNotReset[callbacks[i].seq] = 1; - _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq); + _fireCallback( + callbacks[i].callback, + e, + callbacks[i].combo, + callbacks[i].seq, + ); continue; } @@ -700,12 +717,16 @@ function Mousetrap(targetElement) { // // we ignore keypresses in a sequence that directly follow a keydown // for the same character - var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress; - if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) { + var ignoreThisKeypress = e.type == "keypress" && _ignoreNextKeypress; + if ( + e.type == _nextExpectedAction && + !_isModifier(character) && + !ignoreThisKeypress + ) { _resetSequences(doNotReset); } - _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown'; + _ignoreNextKeypress = processedSequenceCallback && e.type == "keydown"; }; /** @@ -715,10 +736,9 @@ function Mousetrap(targetElement) { * @returns void */ function _handleKeyEvent(e) { - // normalize e.which for key events // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion - if (typeof e.which !== 'number') { + if (typeof e.which !== "number") { e.which = e.keyCode; } @@ -730,7 +750,7 @@ function Mousetrap(targetElement) { } // need to use === for the character check because the character can be 0 - if (e.type == 'keyup' && _ignoreNextKeyup === character) { + if (e.type == "keyup" && _ignoreNextKeyup === character) { _ignoreNextKeyup = false; return; } @@ -761,7 +781,6 @@ function Mousetrap(targetElement) { * @returns void */ function _bindSequence(combo, keys, callback, action) { - // start off by adding a sequence level record for this combination // and setting the level to 0 _sequenceLevels[combo] = 0; @@ -774,7 +793,7 @@ function Mousetrap(targetElement) { * @returns {Function} */ function _increaseSequence(nextAction) { - return function() { + return function () { _nextExpectedAction = nextAction; ++_sequenceLevels[combo]; _resetSequenceTimer(); @@ -794,7 +813,7 @@ function Mousetrap(targetElement) { // we should ignore the next key up if the action is key down // or keypress. this is so if you finish a sequence and // release the key the final key will not trigger a keyup - if (action !== 'keyup') { + if (action !== "keyup") { _ignoreNextKeyup = _characterFromEvent(e); } @@ -814,7 +833,9 @@ function Mousetrap(targetElement) { // ones are better suited to the key provided for (var i = 0; i < keys.length; ++i) { var isFinal = i + 1 === keys.length; - var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); + var wrappedCallback = isFinal + ? _callbackAndReset + : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); _bindSingle(keys[i], wrappedCallback, action, combo, i); } } @@ -829,15 +850,21 @@ function Mousetrap(targetElement) { * @param {number=} level - what part of the sequence the command is * @returns void */ - function _bindSingle(combination, callback, action, sequenceName, level, overwrite) { - + function _bindSingle( + combination, + callback, + action, + sequenceName, + level, + overwrite, + ) { // store a direct mapped reference for use with Mousetrap.trigger - self._directMap[combination + ':' + action] = callback; + self._directMap[combination + ":" + action] = callback; // make sure multiple spaces in a row become a single space - combination = combination.replace(/\s+/g, ' '); + combination = combination.replace(/\s+/g, " "); - var sequence = combination.split(' '); + var sequence = combination.split(" "); var info; // if this pattern is a sequence of keys then run through this method @@ -855,7 +882,14 @@ function Mousetrap(targetElement) { // remove an existing match if there is one if (overwrite) { - _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level); + _getMatches( + info.key, + info.modifiers, + { type: info.action }, + sequenceName, + combination, + level, + ); } // add this call back to the array @@ -864,13 +898,13 @@ function Mousetrap(targetElement) { // // this is important because the way these are processed expects // the sequence ones to come first - self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({ + self._callbacks[info.key][sequenceName ? "unshift" : "push"]({ callback: callback, modifiers: info.modifiers, action: info.action, seq: sequenceName, level: level, - combo: combination + combo: combination, }); } @@ -882,16 +916,23 @@ function Mousetrap(targetElement) { * @param {string|undefined} action * @returns void */ - self._bindMultiple = function(combinations, callback, action, overwrite) { + self._bindMultiple = function (combinations, callback, action, overwrite) { for (var i = 0; i < combinations.length; ++i) { - _bindSingle(combinations[i], callback, action, undefined, undefined, overwrite); + _bindSingle( + combinations[i], + callback, + action, + undefined, + undefined, + overwrite, + ); } }; if (targetElement) { - _addEvent(targetElement, 'keypress', _handleKeyEvent); - _addEvent(targetElement, 'keydown', _handleKeyEvent); - _addEvent(targetElement, 'keyup', _handleKeyEvent); + _addEvent(targetElement, "keypress", _handleKeyEvent); + _addEvent(targetElement, "keydown", _handleKeyEvent); + _addEvent(targetElement, "keyup", _handleKeyEvent); } } @@ -909,7 +950,7 @@ function Mousetrap(targetElement) { * @param {string=} action - 'keypress', 'keydown', or 'keyup' * @returns void */ -Mousetrap.prototype.bind = function(keys, callback, action, overwrite) { +Mousetrap.prototype.bind = function (keys, callback, action, overwrite) { var self = this; keys = keys instanceof Array ? keys : [keys]; self._bindMultiple.call(self, keys, callback, action, overwrite); @@ -933,9 +974,9 @@ Mousetrap.prototype.bind = function(keys, callback, action, overwrite) { * @param {string} action * @returns void */ -Mousetrap.prototype.unbind = function(keys, action) { +Mousetrap.prototype.unbind = function (keys, action) { var self = this; - return self.bind.call(self, keys, function() {}, action); + return self.bind.call(self, keys, function () {}, action); }; /** @@ -945,10 +986,10 @@ Mousetrap.prototype.unbind = function(keys, action) { * @param {string=} action * @returns void */ -Mousetrap.prototype.trigger = function(keys, action) { +Mousetrap.prototype.trigger = function (keys, action) { var self = this; - if (self._directMap[keys + ':' + action]) { - self._directMap[keys + ':' + action]({}, keys); + if (self._directMap[keys + ":" + action]) { + self._directMap[keys + ":" + action]({}, keys); } return self; }; @@ -960,7 +1001,7 @@ Mousetrap.prototype.trigger = function(keys, action) { * * @returns void */ -Mousetrap.prototype.reset = function() { +Mousetrap.prototype.reset = function () { var self = this; self._callbacks = {}; self._directMap = {}; @@ -978,20 +1019,20 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) { // if the element has the data attribute "mousetrap-dont-stop" then no need // to stop. It should be used like <div data-mousetrap-dont-stop>...</div> // or :div {:data-mousetrap-dont-stop true} - if ('mousetrapDontStop' in element.dataset) { - return false + if ("mousetrapDontStop" in element.dataset) { + return false; } - if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { + if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { return false; } // Keyup events need to be dispatched always. Otherwise some events can be stuck - if (e.type == 'keyup') { + if (e.type == "keyup") { return false; } - if ('composedPath' in e && typeof e.composedPath === 'function') { + if ("composedPath" in e && typeof e.composedPath === "function") { // For open shadow trees, update `element` so that the following check works. const initialEventTarget = e.composedPath()[0]; if (initialEventTarget !== e.target) { @@ -1000,20 +1041,22 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) { } // stop for input, select, textarea and button - const shouldStop = element.tagName == "INPUT" || - element.tagName == "SELECT" || - element.tagName == "TEXTAREA" || - (element.tagName == "BUTTON" && combo.includes("tab")) || - (element.contentEditable && (element.contentEditable == "true" || element.contentEditable === "plaintext-only")); + const shouldStop = + element.tagName == "INPUT" || + element.tagName == "SELECT" || + element.tagName == "TEXTAREA" || + (element.tagName == "BUTTON" && combo.includes("tab")) || + (element.contentEditable && + (element.contentEditable == "true" || + element.contentEditable === "plaintext-only")); return shouldStop; -} - +}; /** * exposes _handleKey publicly so it can be overwritten by extensions */ -Mousetrap.prototype.handleKey = function() { +Mousetrap.prototype.handleKey = function () { var self = this; return self._handleKey.apply(self, arguments); }; @@ -1028,7 +1071,7 @@ export function addKeycodes(object) { } } _REVERSE_MAP = null; -}; +} /** * Init the global mousetrap functions diff --git a/frontend/playwright/ui/pages/ShortcutsPage.js b/frontend/playwright/ui/pages/ShortcutsPage.js index 578311570e..d45bb1b970 100644 --- a/frontend/playwright/ui/pages/ShortcutsPage.js +++ b/frontend/playwright/ui/pages/ShortcutsPage.js @@ -318,7 +318,9 @@ export class ShortcutsPage extends BaseWebSocketPage { this.exportShortcuts(), ]); - expect(download.suggestedFilename()).toBe("penpot-shortcuts.json"); + expect(download.suggestedFilename()).toMatch( + /^penpot-shortcuts-Princesa_Leia-\d{4}-\d{2}-\d{2}\.json$/, + ); const path = await download.path(); const content = await readFile(path, "utf-8"); diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 0923a24f80..5c042575a4 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -110,6 +110,7 @@ :command (ds/c-mod "v") :subsections [:edit] :section [:workspace] + :customizable false :fn (constantly nil)} :paste-replace {:tooltip (ds/meta (ds/shift "V")) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 83b86d932d..3a4e76edeb 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -43,13 +43,12 @@ (defn use-shortcuts [key shortcuts group-key] - (let [custom-shortcuts (mf/deref refs/custom-shortcuts)] - (mf/use-effect - #js [(str key) shortcuts custom-shortcuts] + (mf/use-effect + #js [(str key) shortcuts] + (fn [] + (st/emit! (dsc/push-shortcuts key shortcuts group-key)) (fn [] - (st/emit! (dsc/push-shortcuts key shortcuts group-key)) - (fn [] - (st/emit! (dsc/pop-shortcuts key))))))) + (st/emit! (dsc/pop-shortcuts key)))))) (defn- set-timer [state ms func] diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 7800403adc..bb1ba9d383 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -93,10 +93,18 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (def ^:private known-shortcut-keys - "Known shortcut keys per context, derived from the default shortcuts maps." - {:workspace (set (keys wsc/shortcuts)) - :dashboard (set (keys dsc/shortcuts)) - :viewer (set (keys vsc/shortcuts))}) + "Known shortcut keys per context, derived from the default shortcuts maps. + Shortcuts marked :customizable false are excluded." + (letfn [(collect-keys [shortcuts] + (reduce-kv (fn [s k v] + (if (false? (:customizable v)) + s + (conj s k))) + #{} + shortcuts))] + {:workspace (into (collect-keys psc/shortcuts) (collect-keys wsc/shortcuts)) + :dashboard (collect-keys dsc/shortcuts) + :viewer (collect-keys vsc/shortcuts)})) (def ^:private schema:imported-shortcuts "Malli schema for an imported custom-shortcuts payload. @@ -373,7 +381,8 @@ (mf/with-memo [] (fn [_ shortcut search-term] (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))) filter-personalized (mf/use-fn @@ -385,8 +394,10 @@ customized? (and (contains? group-map shortcut-key) (not (str/blank? (get group-map shortcut-key))))] (and customized? + (not (false? (:customizable shortcut))) (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))))) filter-disabled (mf/use-fn @@ -398,8 +409,10 @@ in-group? (contains? group-map shortcut-key) blank? (str/blank? (get group-map shortcut-key))] (and in-group? blank? + (not (false? (:customizable shortcut))) (or (str/blank? search-term) - (matches-search (:translation shortcut) search-term)))))) + (matches-search (:translation shortcut) search-term) + (matches-search (ss/shortcut->command-string shortcut) search-term)))))) on-import-file (mf/use-fn @@ -414,11 +427,16 @@ on-export (mf/use-fn - (mf/deps shortcuts-json has-custom-shortcuts) + (mf/deps shortcuts-json has-custom-shortcuts (:fullname profile)) (fn [] (when has-custom-shortcuts - (->> (wapi/create-blob shortcuts-json "application/json") - (dom/trigger-download "penpot-shortcuts.json"))))) + (let [fullname (-> (or (:fullname profile) "user") + (str/replace #"[^a-zA-Z0-9\-_ ]" "") + (str/replace #"\s+" "_")) + date (.slice (.toISOString (js/Date.)) 0 10) + filename (str "penpot-shortcuts-" fullname "-" date ".json")] + (->> (wapi/create-blob shortcuts-json "application/json") + (dom/trigger-download filename)))))) on-file-selected (mf/use-fn diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 212f296c95..47454fa2c8 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -10,9 +10,13 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.dashboard.shortcuts.customize :as customize] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.store :as st] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] @@ -107,14 +111,24 @@ (def ^:private import-contexts [:workspace :dashboard :viewer]) +(def ^:private context->known-keys + {:workspace (into #{} (concat (keys psc/shortcuts) (keys wsc/shortcuts))) + :dashboard (into #{} (keys dsc/shortcuts)) + :viewer (into #{} (keys vsc/shortcuts))}) + +(defn- build-context-shortcuts + [all-shortcuts ctx] + (let [known-keys (get context->known-keys ctx)] + (into {} (filter (fn [[k _]] (contains? known-keys k))) all-shortcuts))) + (defn- import-context-group "Imports a single context group from the payload, disabling any default shortcut whose command collides with a newly imported one, and any previously-imported entry in the same batch with a duplicate command." - [group all-shortcuts] + [group context-shortcuts] (reduce (fn [acc [command recorded-command]] - (let [default-conflict (find-conflict recorded-command all-shortcuts command) + (let [default-conflict (find-conflict recorded-command context-shortcuts command) acc-conflict (some (fn [[k v]] (when (and (not= k command) (= v recorded-command)) k)) @@ -136,7 +150,8 @@ new-customs (reduce (fn [acc ctx] (if (contains? shortcuts ctx) - (assoc acc ctx (import-context-group (get shortcuts ctx) all-shortcuts)) + (let [ctx-sc (build-context-shortcuts all-shortcuts ctx)] + (assoc acc ctx (import-context-group (get shortcuts ctx) ctx-sc))) acc)) current-customs import-contexts)] @@ -147,6 +162,20 @@ [type item] (map (fn [[k v]] [k (assoc v :translation (translation-keyname type k))]) item)) +(defn shortcut->command-string + "Extract a lowercase searchable string from a shortcut entry's key combo(s). + Prefers `:show-command` (display override) over `:command` (Mousetrap format), + matching what the keycap UI renders. Joins vector commands (key sequences) + with a space so every token is searchable. Returns \"\" when there is no + command (e.g. a section/subsection node)." + [shortcut] + (let [cmd (or (:show-command shortcut) (:command shortcut))] + (-> (cond + (nil? cmd) "" + (vector? cmd) (str/join " " cmd) + :else (str cmd)) + (str/lower)))) + (defn shortcuts->subsections [shortcuts] (let [subsections (into #{} (mapcat :subsections) (vals shortcuts)) @@ -576,11 +605,19 @@ [{:keys [elements filter-term is-match-section is-match-subsection editable? custom-shortcuts section-key conflicts hidden subsection-name]}] (let [shortcut-translations (->> elements vals (map :translation) sort) - match-shortcut? (some #(matches-search % filter-term) shortcut-translations) + match-shortcut? (some (fn [info] + (or (matches-search (:translation info) filter-term) + (matches-search (shortcut->command-string info) filter-term))) + (vals elements)) filtered (if (and (or is-match-section is-match-subsection) (not match-shortcut?)) shortcut-translations - (filter #(matches-search % filter-term) shortcut-translations)) - sorted-filtered (sort filtered) + (->> (vals elements) + (filter (fn [info] + (or (matches-search (:translation info) filter-term) + (matches-search (shortcut->command-string info) filter-term)))) + (map :translation) + sort)) + sorted-filtered filtered trigger-ref (mf/use-ref nil)] [:ul {:class (stl/css :sub-menu) @@ -597,8 +634,9 @@ (get custom-shortcuts section-key)) group-map (if (map? group-map) group-map {}) customized? (contains? group-map command) - has-conflict? (contains? conflicts command)] - (if editable? + has-conflict? (contains? conflicts command) + customizable? (not (false? (:customizable command-info)))] + (if (and editable? customizable?) [:> shortcut-row-editable* {:elements elements :custom-shortcuts custom-shortcuts :section-key section-key @@ -611,9 +649,13 @@ :data-conflict (str has-conflict?) :aria-label command-translate :key command-translate} - [:span {:class (stl/css :command-name) - :id (dm/str command-translate "-label")} - command-translate] + [:span + [:span {:class (stl/css-case :command-name true + :not-customizable-label (not customizable?)) + :id (dm/str command-translate "-label")} + command-translate] + (when (not customizable?) + [:span {:class (stl/css :not-customizable-label)} "(not customizable)"])] [:div {:class (stl/css :shortcut-actions) :aria-labelledby (dm/str command-translate "-label")} (if (and customized? (str/blank? content)) diff --git a/frontend/src/app/main/ui/shortcuts.scss b/frontend/src/app/main/ui/shortcuts.scss index 4576fe8d50..11eef8a482 100644 --- a/frontend/src/app/main/ui/shortcuts.scss +++ b/frontend/src/app/main/ui/shortcuts.scss @@ -78,6 +78,10 @@ text-align: start; } +.not-customizable-label { + padding-inline-start: px2rem(6); +} + // Editable rows .shortcuts-name-editable { diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index 542d01aa83..3b36a79318 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -66,7 +66,12 @@ (ss/build-all-shortcuts workspace-shortcuts dashboard-shortcuts viewer-shortcuts) all-item-names (concat all-sc-names all-sub-names all-section-names) - match-any? (some #(matches-search % filter-term) all-item-names) + all-command-strings (->> (concat (vals workspace-shortcuts) + (vals dashboard-shortcuts) + (vals viewer-shortcuts)) + (map ss/shortcut->command-string)) + all-searchable-names (concat all-item-names all-command-strings) + match-any? (some #(matches-search % filter-term) all-searchable-names) manage-sections (fn [item] @@ -89,7 +94,8 @@ (fn [section term] (let [node-seq (tree-seq :children #(vals (:children %)) (get all-shortcuts section))] (reduce (fn [acc node] - (if (matches-search (:translation node) term) + (if (or (matches-search (:translation node) term) + (matches-search (ss/shortcut->command-string node) term)) (add-ids acc node) acc)) [] diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index 8bbf5af6b2..ba668650d7 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -5,6 +5,7 @@ [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] [app.main.ui.shortcuts :as ui-shortcuts] + [app.util.strings :refer [matches-search]] [cljs.test :as t :include-macros true] [clojure.string :as str])) @@ -221,3 +222,72 @@ (let [result (restore-modal/extract-shortcut-keys :next-frame {} :viewer)] (t/is (nth result 3) "Should return a default command for :next-frame in :viewer context"))) + +;; --- shortcut->command-string + command-based search -------------------- +;; The search in both the settings shortcuts page and the workspace sidebar +;; matches shortcut entries by their translated name AND by their key-combo +;; string. `shortcut->command-string` (in `app.main.ui.shortcuts`) extracts the +;; searchable form from `:command`/`:show-command`; `matches-search` does the +;; case-insensitive substring match. These tests pin that contract so searching +;; e.g. "ctrl" surfaces every shortcut whose combo includes ctrl. + +(t/deftest shortcut->command-string-extracts-string-command + (t/testing "a plain string command is returned lowercased" + (t/is (= "ctrl+z" (ui-shortcuts/shortcut->command-string + {:command "ctrl+z"}))))) + +(t/deftest shortcut->command-string-joins-vector-command + (t/testing "a vector command (key sequence) is joined with spaces so every + token is individually searchable" + (t/is (= "g v" (ui-shortcuts/shortcut->command-string + {:command ["g" "v"]}))))) + +(t/deftest shortcut->command-string-prefers-show-command + (t/testing ":show-command (display override) wins over :command" + (t/is (= "shift+x" (ui-shortcuts/shortcut->command-string + {:command "ctrl+z" :show-command "shift+x"}))))) + +(t/deftest shortcut->command-string-empty-for-section-node + (t/testing "a node without :command/:show-command (e.g. a section or + subsection heading) yields an empty string so it never matches a + non-blank command search" + (t/is (= "" (ui-shortcuts/shortcut->command-string + {:translation "workspace"}))))) + +(t/deftest shortcut->command-string-lowercases + (t/testing "the result is lowercased so search is case-insensitive" + (t/is (= "ctrl+shift+z" (ui-shortcuts/shortcut->command-string + {:command "Ctrl+Shift+Z"}))))) + +(t/deftest command-search-matches-ctrl-prefix + (t/testing "searching 'ctrl' matches a shortcut whose command contains ctrl" + (let [shortcut {:command "ctrl+shift+s" + :translation "Save all"}] + (t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "ctrl"))))) + +(t/deftest command-search-does-not-match-when-command-lacks-term + (t/testing "searching 'alt' does not match a shortcut with no alt in its combo" + (let [shortcut {:command "ctrl+z" + :translation "Undo"}] + (t/is (not (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "alt")))))) + +(t/deftest command-search-matches-key-sequence-vector + (t/testing "searching a single key in a key-sequence vector command matches" + (let [shortcut {:command ["g" "v"] + :translation "Group"}] + (t/is (matches-search (ui-shortcuts/shortcut->command-string shortcut) + "g"))))) + +(t/deftest search-matches-by-translation-or-command + (t/testing "a search term matches if it appears in either the translation or + the command string — the OR that the filter predicates use" + (let [shortcut {:command "ctrl+s" + :translation "Save"}] + ;; by translation + (t/is (or (matches-search (:translation shortcut) "save") + (matches-search (ui-shortcuts/shortcut->command-string shortcut) "save"))) + ;; by command + (t/is (or (matches-search (:translation shortcut) "ctrl") + (matches-search (ui-shortcuts/shortcut->command-string shortcut) "ctrl")))))) From 5c10ea5bd6132795342599ceaf8c040a89e190c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 7 Sep 2026 08:41:54 +0000 Subject: [PATCH 296/298] :books: Document branch naming convention The creating-prs memory described a branch format (issue/... with a <type>/<short-description> fallback) that does not match actual repo practice, where issue-driven branches are issue-NNNN. Replace it with a Branch Naming section: issue-NNNN as primary, descriptive name without slashes as fallback. Add the matching public convention to CONTRIBUTING.md under Pull Requests, which previously defined no branch naming at all. AI-assisted-by: omen-alpha --- .serena/memories/workflow/creating-prs.md | 8 +++++++- CONTRIBUTING.md | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md index a92c199e22..fa94a9afe0 100644 --- a/.serena/memories/workflow/creating-prs.md +++ b/.serena/memories/workflow/creating-prs.md @@ -1,6 +1,12 @@ # Creating Pull Requests -PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`). +PR only on explicit request. + +## Branch Naming + +- Primary: `issue-NNNN` — one branch per GitHub issue (e.g. `issue-11525`). +- No issue: free-form descriptive name, dash-separated, no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`). +- If the user already created the branch, use it as-is — never rename. ## Target Branch diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63b931900f..d905aae9e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ Center](https://help.penpot.app/). - [Reporting Bugs](#reporting-bugs) - [Pull Requests](#pull-requests) - [Workflow](#workflow) + - [Branch naming](#branch-naming) - [Format](#format) - [Title format](#title-format) - [Description](#description) @@ -73,6 +74,18 @@ Advisories](https://github.com/penpot/penpot/security/advisories) 4. **Format and lint** — run the checks described in [Formatting and Linting](#formatting-and-linting) before submitting. +### Branch naming + +Branch names are not enforced, but we recommend the following: + +- **`issue-NNNN`** — when working from a GitHub issue, name the branch after + it (e.g. `issue-11525`). This makes each PR's origin self-evident. +- Otherwise, use a short, descriptive name with words separated by hyphens + and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`). + +Since PRs are squash-merged, the branch name does not survive into the +commit history — what matters is the [PR title](#title-format). + ### Format #### Title From 9462543fb575cbc1603f0d3a1a122c3285593922 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Mon, 7 Sep 2026 11:15:17 +0200 Subject: [PATCH 297/298] :sparkles: Add skip-onboarding option to create-demo-profile (#11526) Add optional skip-onboarding param to create-demo-profile. When true, the demo profile is created with onboarding-viewed and release-notes-viewed set, so it skips the onboarding flow. Default keeps the current behavior. Cover both cases with RPC tests. AI-assisted-by: muse-spark-1.3-contributor --- backend/src/app/rpc/commands/demo.clj | 20 +++++++++-- backend/test/backend_tests/rpc_demo_test.clj | 36 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index 56baa1916b..9106298915 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -9,6 +9,7 @@ (:require [app.auth :refer [derive-password-weak]] [app.common.exceptions :as ex] + [app.common.schema :as sm] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -21,14 +22,21 @@ [buddy.core.codecs :as bc] [buddy.core.nonce :as bn])) +(def ^:private + schema:create-demo-profile + [:map + [:skip-onboarding {:optional true} ::sm/boolean]]) + (sv/defmethod ::create-demo-profile "A command that is responsible of creating a demo purpose profile. It only works if the `demo-users` flag is enabled in the configuration." {::rpc/auth false ::doc/added "1.15" - ::doc/changes ["1.15" "This method is migrated from mutations to commands."]} - [cfg _] + ::doc/changes [["1.15" "This method is migrated from mutations to commands."] + ["2.18" "Add optional `skip-onboarding` param. When true, the profile is created with `onboarding-viewed` and `release-notes-viewed` (current version) set, skipping the onboarding flow."]] + ::sm/params schema:create-demo-profile} + [cfg {:keys [skip-onboarding]}] (when-not (contains? cf/flags :demo-users) (ex/raise :type :validation @@ -48,7 +56,13 @@ :is-active true :is-demo true :password (derive-password-weak password) - :props {}} + :props (cond-> {} + skip-onboarding (assoc :onboarding-viewed true + ;; Redundant today: auth/create-profile + ;; overwrites this with the current + ;; version, kept so the skip does not + ;; depend on that default. + :release-notes-viewed (:main cf/version)))} profile (db/tx-run! cfg (fn [cfg] (->> (auth/create-profile cfg params) (auth/create-profile-rels cfg))))] diff --git a/backend/test/backend_tests/rpc_demo_test.clj b/backend/test/backend_tests/rpc_demo_test.clj index d990ec8a32..3bda13fc61 100644 --- a/backend/test/backend_tests/rpc_demo_test.clj +++ b/backend/test/backend_tests/rpc_demo_test.clj @@ -8,6 +8,7 @@ (:require [app.auth :as auth] [app.config :as cf] + [app.rpc.commands.profile :as profile] [backend-tests.helpers :as th] [clojure.test :as t])) @@ -38,3 +39,38 @@ (with-redefs [cf/flags (disj cf/flags :demo-users)] (let [{:keys [error]} (th/command! {::th/type :create-demo-profile})] (t/is (th/ex-of-code? error :demo-users-not-allowed))))) + +(t/deftest create-demo-profile-keeps-onboarding-by-default + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (nil? (get-in decoded [:props :onboarding-viewed]))))))) + +(t/deftest create-demo-profile-skips-onboarding-when-requested + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile + :skip-onboarding true})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (true? (get-in decoded [:props :onboarding-viewed]))) + (t/is (= (:main cf/version) + (get-in decoded [:props :release-notes-viewed]))))))) + +(t/deftest create-demo-profile-explicit-false-keeps-onboarding + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error result]} (th/command! {::th/type :create-demo-profile + :skip-onboarding false})] + (t/is (nil? error)) + (let [saved (th/db-get :profile {:email (:email result)}) + decoded (profile/decode-row saved)] + (t/is (nil? (get-in decoded [:props :onboarding-viewed]))))))) + +(t/deftest create-demo-profile-rejects-non-boolean-skip-onboarding + (with-redefs [cf/flags (conj cf/flags :demo-users)] + (let [{:keys [error]} (th/command! {::th/type :create-demo-profile + :skip-onboarding "yes"})] + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :params-validation))))) From 1f12561427f7e89d95832758212061aaf687bbcf Mon Sep 17 00:00:00 2001 From: Alexei Bratuhin <alexei.bratuhin@googlemail.com> Date: Mon, 7 Sep 2026 13:15:41 +0200 Subject: [PATCH 298/298] :books: Fix typo in docker-compose.yaml comment (#11517) Signed-off-by: Alexei Bratuhin <alexei.bratuhin@googlemail.com> --- docker/images/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index a39bae29b0..63e92d9916 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -38,7 +38,7 @@ x-body-size: &penpot-http-body-size ## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems ## (eg http sessions, or invitations) are derived. ## -## We recommend to use a trully randomly generated +## We recommend to use a truly randomly generated ## 512 bits base64 encoded string here. You can generate one with: ## ## python3 -c "import secrets; print(secrets.token_urlsafe(64))"