From 31478c6afc253067797d6023b899e9c7af6817f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Moya?= Date: Mon, 2 Mar 2026 16:17:12 +0100 Subject: [PATCH 01/27] :bug: Fix validation of shadow token with missing keys (#8507) --- CHANGES.md | 1 + frontend/src/app/main/data/style_dictionary.cljs | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ff49037c8f..546cca95b8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -32,6 +32,7 @@ - Fix viewer can update library [Taiga #13186](https://tree.taiga.io/project/penpot/issue/13186) - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) +- Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) ## 2.13.3 diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 474fa93552..63a076f93f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -377,7 +377,15 @@ (defn- parse-single-shadow "Parses a single shadow map with properties: x, y, blur, spread, color, type." [shadow-map shadow-index] - (let [add-keyed-errors (fn [shadow-result k errors] + (let [shadow-map (merge {:offset-x nil ;; Ensure that all keys are processed, even if missing in the original token + :offset-y nil + :blur nil + :spread nil + :color nil + :inset false} + shadow-map) + + add-keyed-errors (fn [shadow-result k errors] (update shadow-result :errors concat (map #(assoc % :shadow-key k :shadow-index shadow-index) errors))) parsers {:offset-x parse-sd-token-general-value From 58e86a545a55b54871fdfc0fbd6e7ac9edeaeecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Tue, 3 Mar 2026 09:01:48 +0100 Subject: [PATCH 02/27] :books: Add info about grouping tokens (#8508) --- docs/img/design-tokens/40-tokens-groups.webp | Bin 0 -> 14806 bytes .../design-systems/design-tokens.njk | 44 ++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 docs/img/design-tokens/40-tokens-groups.webp diff --git a/docs/img/design-tokens/40-tokens-groups.webp b/docs/img/design-tokens/40-tokens-groups.webp new file mode 100644 index 0000000000000000000000000000000000000000..3e482f1c26780eb1ec43c28e288425ba7ad95ee2 GIT binary patch literal 14806 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 literal 0 HcmV?d00001 diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index b259a4bff0..b83b984a63 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -690,6 +690,50 @@ ExtraBold Italic
Exporting tokens as a single file.
+

Token groups

+

Token names are rarely short and simple. They often contain multiple sections that represent token type, state, property, variant, and more. To help manage this complexity, Penpot automatically organizes tokens into groups based on the structure of their names.

+ +

How token groups work

+

When Penpot encounters a dot (.) in a token name, it breaks down the name and structures it as nested groups. For example, a token named button.primary.default.background-color is organized into groups like this:

+
    +
  • button (group)
  • +
  • primary (group)
  • +
  • → → default (group)
  • +
  • → → → background-color (token)
  • +
+

If you have another token like button.primary.hover.background-color, it shares the same group structure (button.primary) and appears nested within those groups.

+

This structure matches how tokens are organized in JSON format. When you export tokens, a token like button.primary.default.background-color is structured like this:

+
{
+  "button": {
+    "primary": {
+      "default": {
+        "background-color": {
+          "$value": "#f00",
+          "$type": "color"
+        }
+      }
+    }
+  }
+}
+ +

Visual appearance

+

In the Tokens panel, token groups appear as nested, collapsible folders. Only the last segment of the token name (the actual token) appears as a pill. The segments before it appear as group folders that you can expand or collapse.

+

When you create a new token, Penpot automatically unfolds the required path so you can see your newly created token. If you manually unfold a group path, it stays open even when you navigate to other areas of the app (this state resets if you reload the page).

+
+ Design Tokens Grouping +
+ +

Working with token groups

+

Token pills keep the same actions as before: you can delete, edit, and duplicate tokens from the context menu. When editing a token name, you'll see the full token path including all group segments.

+ +

Moving tokens between groups

+

When you edit a token name and change the group segments, the token moves to its new group automatically. If the new group doesn't exist, Penpot creates it. If the group already exists, the token is moved there.

+

For example, if you rename color.background.secondary to c.bg.sec, the token moves from the color.background group to a new c.bg group.

+ +

Deleting token groups

+

When you delete a token, if it's the last token in its group, the empty group is automatically removed as well. You can also delete entire token groups directly, which removes all tokens within that group.

+

Deleting a token group removes all tokens it contains. Make sure you want to delete all tokens in a group before removing it.

+ {% if report %} @@ -21,7 +20,8 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)

Hint:
{{hint}}

Reported at:
{{created-at}}

-

Report ID:
{{id}}

+

Origin:
{{origin}}

+

HREF:
{{href}}

diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index 5f4704168f..aaa2e5d47d 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -117,7 +117,8 @@ {:context (-> (into (sorted-map) context) (pp/pprint-str :length 50)) - :props (pp/pprint-str props :length 50) + :origin (::audit/name record) + :href (get props :href) :hint (get props :hint) :report (get props :report)})) diff --git a/common/src/app/common/exceptions.cljc b/common/src/app/common/exceptions.cljc index 032690f8b4..e104be775b 100644 --- a/common/src/app/common/exceptions.cljc +++ b/common/src/app/common/exceptions.cljc @@ -299,3 +299,8 @@ (js/console.log (format-throwable cause)) (finally (js/console.groupEnd)))))) + +(defn get-hint + [cause] + (or (some-> (ex-data cause) (get :hint) first-line) + (some-> (ex-message cause) first-line))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 51058b5fe4..222b86fdac 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -72,7 +72,6 @@ (when-let [file-id (or (:file-id data) file-id)] (println "File ID: " (str file-id))) (println "Version: " (:full cf/version)) - (println "URI: " (str cf/public-uri)) (println "HREF: " (rt/get-current-href)) (println) @@ -88,24 +87,36 @@ (.error js/console "error on generating report" cause) nil))) -(defn- show-not-blocking-error - "Show a non user blocking error notification" - [cause] - (let [data (ex-data cause) - hint (or (some-> (:hint data) ex/first-line) - (ex-message cause))] - +(defn submit-report + "Report the error report to the audit log subsystem" + [& {:keys [event-name report hint] :or {event-name "unhandled-exception"}}] + (when (and (not (str/empty? hint)) + (string? report) + (string? event-name)) (st/emit! - (ev/event {::ev/name "unhandled-exception" + (ev/event {::ev/name event-name :hint hint :href (rt/get-current-href) - :type (get data :type :unknown) - :report (generate-report cause)}) + :report report})))) - (ntf/show {:content (tr "errors.unexpected-exception" hint) - :type :toast - :level :error - :timeout 3000})))) +(defn flash + "Show error notification banner and emit error report" + [& {:keys [type hint cause] :or {type :handled}}] + (when (ex/exception? cause) + (when-let [event-name (case type + :handled "handled-exception" + :unhandled "unhandled-exception" + :silent nil)] + (let [report (generate-report cause)] + (submit-report :event-name event-name + :report report + :hint (ex/get-hint cause))))) + + (st/emit! + (ntf/show {:content (or ^boolean hint (tr "errors.generic")) + :type :toast + :level :error + :timeout 5000}))) (defmethod ptk/handle-error :default [error] @@ -114,7 +125,7 @@ (ptk/handle-error (assoc error :type :assertion)) (when-let [cause (::instance error)] (ex/print-throwable cause :prefix "Unexpected Error") - (show-not-blocking-error cause)))) + (flash :cause cause :type :unhandled)))) ;; We receive a explicit authentication error; If the uri is for ;; workspace, dashboard, viewer or settings, then assign the exception @@ -203,7 +214,7 @@ (defmethod ptk/handle-error :assertion [error] (when-let [cause (::instance error)] - (show-not-blocking-error cause) + (flash :cause cause :type :handled) (ex/print-throwable cause :prefix "Assertion Error"))) ;; ;; All the errors that happens on worker are handled here. @@ -307,7 +318,7 @@ :else (when-let [cause (::instance error)] (ex/print-throwable cause :prefix "Restriction Error") - (show-not-blocking-error cause)))) + (flash :cause cause :type :unhandled)))) ;; This happens when the backed server fails to process the ;; request. This can be caused by an internal assertion or any other @@ -333,14 +344,14 @@ (set! last-exception cause) (when-not (is-ignorable-exception? cause) (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/schedule #(show-not-blocking-error cause))))) + (ts/schedule #(flash :cause cause :type :unhandled))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] (set! last-exception cause) (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/schedule #(show-not-blocking-error cause))))] + (ts/schedule #(flash :cause cause :type :unhandled))))] (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 7f0020ccd3..a9f69a2c8e 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -9,13 +9,12 @@ (:require ["rxjs" :as rxjs] [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.pprint :as pp] - [app.common.uri :as u] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.auth :refer [is-authenticated?]] [app.main.data.common :as dcm] - [app.main.data.event :as ev] [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] @@ -448,22 +447,22 @@ (mf/defc exception-section* {::mf/private true} - [{:keys [data route] :as props}] + [{:keys [data] :as props}] (let [type (get data :type) - report (mf/with-memo [data] - (some-> data ::errors/instance errors/generate-report)) + 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 [data route report] - (let [params (:query-params route) - params (u/map->query-string params)] - (st/emit! (ev/event {::ev/name "exception-page" - :type (get data :type :unknown) - :href (rt/get-current-href) - :hint (get data :hint) - :path (get route :path) - :report report - :params params})))) + (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 From 86e851f408c6cf7e1b2f8afec66a6ef1b32a2af2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 4 Mar 2026 09:27:51 +0100 Subject: [PATCH 09/27] :bug: Fix incorrect version visibility on workspace (#8463) * :bug: Add missing order by clause to snapshot query This fixes the incorrect snapshot visibility when file has a lot of versions. * :zap: Reduce allocation on milestone-group* component * :bug: Fix milestone group timestamp formatting * :paperclip: Update changelog * :bug: Fix scroll on history panel --------- Co-authored-by: Eva Marco --- CHANGES.md | 7 +++++++ backend/src/app/features/file_snapshots.clj | 4 ++-- common/src/app/common/time.cljc | 2 +- .../src/app/main/ui/ds/layout/tab_switcher.scss | 1 + .../app/main/ui/ds/product/milestone_group.cljs | 17 +++++++++++------ frontend/src/app/main/ui/ds/utilities/date.cljs | 13 +++---------- frontend/src/app/main/ui/workspace/sidebar.scss | 4 ++++ 7 files changed, 29 insertions(+), 19 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 824a7a8da4..0111be3ce4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.13.4 + +### :bug: Bugs fixed + +- Fix incorrect query for file versions [Github #8463](https://github.com/penpot/penpot/pull/8463) + + ## 2.13.3 ### :bug: Bugs fixed diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index cf40a08a79..192030cbf8 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -138,6 +138,7 @@ c.deleted_at FROM snapshots1 AS c WHERE c.file_id = ? + ORDER BY c.created_at DESC ), snapshots3 AS ( (SELECT * FROM snapshots2 WHERE created_by = 'system' @@ -150,8 +151,7 @@ AND deleted_at IS NULL LIMIT 500) ) - SELECT * FROM snapshots3 - ORDER BY created_at DESC")) + SELECT * FROM snapshots3;")) (defn get-visible-snapshots "Return a list of snapshots fecheable from the API, it has a limited diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index b0e2b6fe84..de46d0aaba 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -208,7 +208,7 @@ (dfn-format v "p") :localized-date-time - (dfn-format v "PPPp") + (dfn-format v "PPP . p") (if (string? fmt) (dfn-format v fmt) 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 f25fc4ccd9..56ecfe27f0 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -114,4 +114,5 @@ width: 100%; height: 100%; outline: $b-1 solid var(--tab-panel-outline-color); + overflow-y: auto; } 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 2b8944d0fd..73452fc30b 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.cljs +++ b/frontend/src/app/main/ui/ds/product/milestone_group.cljs @@ -39,7 +39,6 @@ (mf/spread-props props {:class [class class'] :data-testid "milestone"}) - open* (mf/use-state false) @@ -57,7 +56,13 @@ (dom/get-data "index") (d/parse-integer))] (when (fn? on-menu-click) - (on-menu-click index event)))))] + (on-menu-click index event))))) + + snapshots + (mf/with-memo [snapshots] + (map-indexed (fn [index date] + (d/vec2 date index)) + snapshots))] [:> :div props [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] @@ -76,14 +81,14 @@ :icon-arrow-toggled open?)}]] (when ^boolean open? - (for [[idx d] (d/enumerate snapshots)] - [:div {:key (dm/str "entry-" idx) + (for [[date index] snapshots] + [:div {:key (dm/str "entry-" index) :class (stl/css :version-entry)} - [:> date* {:date d :class (stl/css :date) :typography t/body-small}] + [:> date* {:date date :class (stl/css :date) :typography t/body-small}] [:> icon-button* {:class (stl/css :entry-button) :variant "ghost" :icon i/menu :aria-label (tr "workspace.versions.version-menu") - :data-index idx + :data-index index :on-click on-menu-click}]]))]])) diff --git a/frontend/src/app/main/ui/ds/utilities/date.cljs b/frontend/src/app/main/ui/ds/utilities/date.cljs index f24cd11ac6..eadeeb187a 100644 --- a/frontend/src/app/main/ui/ds/utilities/date.cljs +++ b/frontend/src/app/main/ui/ds/utilities/date.cljs @@ -6,10 +6,8 @@ (ns app.main.ui.ds.utilities.date (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.time :as ct] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] @@ -30,15 +28,10 @@ (mf/defc date* {::mf/schema schema:date} [{:keys [class date selected typography] :rest props}] - (let [class (d/append-class class (stl/css-case :date true :is-selected selected)) - date (cond-> date (not (ct/inst? date)) ct/inst) + (let [date (cond-> date (not (ct/inst? date)) ct/inst) typography (or typography t/body-medium)] [:> text* {:as "time" :typography typography - :class class + :class [class (stl/css-case :date true :is-selected selected)] :date-time (ct/format-inst date :iso)} - (dm/str - (ct/format-inst date :localized-date) - " . " - (ct/format-inst date :localized-time) - "h")])) + (ct/format-inst date :localized-date-time)])) diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index acceaf4f7d..3c5360b4f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -159,3 +159,7 @@ overflow: hidden; height: calc(100vh - deprecated.$s-88); } + +.history-tab { + overflow-y: auto; +} From c3f511775730c6d4807564b43ae39936de8274ae Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 4 Mar 2026 09:47:14 +0100 Subject: [PATCH 10/27] :bug: Fix unhandled exception on using decimals on stroke row (#8405) --- .../ui/workspace/sidebar/options/rows/stroke_row.cljs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 81a0d050d3..ec5770eabb 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 @@ -94,12 +94,12 @@ (mf/use-fn (mf/deps index on-stroke-width-change) (fn [value] - (if (or (string? value) (int? value)) + (if (or (string? value) (number? value)) (on-stroke-width-change index value) - (do - (st/emit! (dwta/toggle-token {:token (first value) - :attrs #{:stroke-width} - :shape-ids ids})))))) + + (st/emit! (dwta/toggle-token {:token (first value) + :attrs #{:stroke-width} + :shape-ids ids}))))) stroke-alignment (or (:stroke-alignment stroke) :center) From e1d556f4aadbe049a89a7239e25495ff75491e61 Mon Sep 17 00:00:00 2001 From: Xaviju Date: Wed, 4 Mar 2026 10:33:29 +0100 Subject: [PATCH 11/27] :bug: Sort tokens by name (#8488) --- common/src/app/common/path_names.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 74774c0044..00038cdf6c 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -188,7 +188,7 @@ Some naming conventions: [segments separator] (let [sorted (sort-by-children segments separator) grouped (group-by-first-segment sorted separator)] - grouped)) + (into (sorted-map) grouped))) (defn- build-tree-node "Builds a single tree node with lazy children." From cc3033735b3f7b6719da761716a874513be094c4 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 4 Mar 2026 10:58:36 +0100 Subject: [PATCH 12/27] :bug: Fix showing warning when no shape is selected (#8515) --- .../app/main/ui/workspace/tokens/management/group.cljs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 ed196a6673..08178d5a8c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -140,10 +140,11 @@ (if (and not-editing? (seq selected-shapes) (not= (:type token) :number)) (st/emit! (dwta/toggle-token {:token token :shape-ids selected-ids})) - (st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition") - :type :toast - :level :warning - :timeout 3000}))))))] + (when (seq selected-shapes) + (st/emit! (ntf/show {:content (tr "workspace.tokens.error-text-edition") + :type :toast + :level :warning + :timeout 3000})))))))] [:div {:class (stl/css :token-section-wrapper) :data-testid (dm/str "section-" (name type))} From 5a6be141fdee1f80192e104c3ffd12939cfad17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Wed, 4 Mar 2026 14:59:04 +0100 Subject: [PATCH 13/27] :books: Add info about using math in tokens (#8510) --- .../design-systems/design-tokens.njk | 70 ++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/design-systems/design-tokens.njk b/docs/user-guide/design-systems/design-tokens.njk index 15373b1241..878cfb4230 100644 --- a/docs/user-guide/design-systems/design-tokens.njk +++ b/docs/user-guide/design-systems/design-tokens.njk @@ -42,20 +42,74 @@ desc: Learn how to create, manage and apply Penpot Design Tokens using W3C DTCG

If the value of the referenced token changes, this will also change the value of the tokens where it is referenced.

References to existing tokens are case sensitive.

-

Using equations

-

Token types with numerical values also accept mathematical equations. If, for example, you create a spacing.small token with the value of 2, and you then want to create a spacing.medium token that is twice as large, you could do so by writing {spacing.small} * 2 in its value. As a result, spacing.medium would have a value of 4.

-

Say you have a spacing.scale token with a value of 2. You could also use this token in the equation to calculate the value of spacing.medium by writing {spacing.small} * {spacing.scale} in its value.

+

Using math in token values

+

Token types with numerical values accept mathematical equations to calculate their values. This allows you to create dynamic relationships between tokens and build flexible design systems.

+

For example, if you create a spacing.small token with the value of 2, and you want to create a spacing.medium token that is twice as large, you can write {spacing.small} * 2 in its value. As a result, spacing.medium would have a value of 4.

+

You can also reference other tokens in your equations. Say you have a spacing.scale token with a value of 2. You could use this token in the equation to calculate the value of spacing.medium by writing {spacing.small} * {spacing.scale} in its value.

Tokens math
-

Mathematical equations can be performed using:

+ +

Basic operators

+

Mathematical equations can be performed using these basic operators:

    -
  • + for addition.
  • -
  • - for subtraction.
  • -
  • * for multiplication.
  • -
  • / for division.
  • +
  • + for addition
  • +
  • - for subtraction
  • +
  • * for multiplication
  • +
  • / for division
  • +
  • % for modulo (remainder)
  • +
  • ^ for exponentiation
+

Math functions

+

In addition to basic operators, you can use various math functions in your token values:

+
    +
  • abs(x) - absolute value
  • +
  • ceil(x) - round up to nearest integer
  • +
  • floor(x) - round down to nearest integer
  • +
  • round(x) - round to nearest integer
  • +
  • max(x, y, ...) - maximum value
  • +
  • min(x, y, ...) - minimum value
  • +
  • sqrt(x) - square root
  • +
  • pow(x, y) - x raised to the power of y
  • +
  • log(x) - natural logarithm
  • +
  • exp(x) - e raised to the power of x
  • +
  • sin(x) - sine
  • +
  • cos(x) - cosine
  • +
  • tan(x) - tangent
  • +
  • asin(x) - arcsine
  • +
  • acos(x) - arccosine
  • +
  • atan(x) - arctangent
  • +
  • atan2(y, x) - arctangent of y/x
  • +
+ +

Syntax and best practices

+

When writing math equations in token values, keep these guidelines in mind:

+
    +
  • Simple equations can be written with or without brackets. For example, 8 * 8 and (8 * 8) both resolve to 64.
  • +
  • Complex formulas require spaces between operators to ensure tokens are transformed correctly. For example, use 8 * 8 instead of 8*8.
  • +
  • Reference tokens using curly braces: {token.name}.
  • +
  • You can combine hard-coded values with token references: {spacing.base} * 1.5.
  • +
+ +

Practical examples

+

Here are some common use cases for math in token values:

+ +

Round to the nearest whole number

+

When using multipliers that result in decimals, you can use the round() function to get whole numbers. For example, if sizing.sm has a value of 2:

+
round({sizing.sm} * 1.33)
+

This calculates 2 * 1.33 = 2.66, which rounds to 3.

+ +

Create a percentage from a unitless number

+

You can convert unitless numbers to percentages. For example, a Number token called lineHeights.heading.relaxed with a value of 1.5 can be written in a Line Height token as:

+
{lineHeights.heading.relaxed} * 100%
+

This calculates a resolved value of 150%.

+ +

Calculate maximum or minimum values

+

Use max() or min() to ensure values stay within bounds. For example:

+
max({spacing.base}, 8)
+

This ensures the spacing is at least 8, even if the base token is smaller.

+

Editing a token

Tokens can be edited by right-clicking the token and selecting Edit token. This will allow you to change the tokens name, value and description. Once the changes are made, click Save.

From 591d63e47086959758e8a79a47728c25edf78bce Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 18 Feb 2026 17:12:50 +0100 Subject: [PATCH 14/27] :sparkles: Add better error report on wrong input on logging helpers --- common/src/app/common/logging.cljc | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/common/src/app/common/logging.cljc b/common/src/app/common/logging.cljc index db7bafe0c8..81b4dd4131 100644 --- a/common/src/app/common/logging.cljc +++ b/common/src/app/common/logging.cljc @@ -128,7 +128,9 @@ :warn "#f5871f" :info "#4271ae" :debug "#969896" - :trace "#8e908c")) + :trace "#8e908c" + (let [hint (str "invalid level provided to `level->color` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn- level->name [level] @@ -137,7 +139,9 @@ :trace "TRC" :info "INF" :warn "WRN" - :error "ERR")) + :error "ERR" + (let [hint (str "invalid level provided to `level->name` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn level->int [level] @@ -146,7 +150,9 @@ :debug 20 :info 30 :warn 40 - :error 50)) + :error 50 + (let [hint (str "invalid level provided to `level->int` function: " (pr-str level))] + (throw (ex-info hint {:level level}))))) (defn build-message [props] From 151238e51883d7343727d1b31d9d286d686a1862 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Feb 2026 11:38:52 +0100 Subject: [PATCH 15/27] :lipstick: Add cosmetic change to link-file-to-library rpc method impl --- backend/src/app/rpc/commands/files.clj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 9cbdf08be6..7ce7d28d74 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1005,19 +1005,19 @@ "Link a file to a library. Returns the recursive list of libraries used by that library" {::doc/added "1.17" ::webhooks/event? true - ::sm/params schema:link-file-to-library} - [cfg {:keys [::rpc/profile-id file-id library-id] :as params}] + ::sm/params schema:link-file-to-library + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}] + (when (= file-id library-id) (ex/raise :type :validation :code :invalid-library :hint "A file cannot be linked to itself")) - (db/tx-run! cfg - (fn [{:keys [::db/conn]}] - (check-edition-permissions! conn profile-id file-id) - (check-edition-permissions! conn profile-id library-id) - (link-file-to-library conn params) - (bfc/get-libraries cfg [library-id])))) + (check-edition-permissions! conn profile-id file-id) + (check-edition-permissions! conn profile-id library-id) + (link-file-to-library conn params) + (bfc/get-libraries cfg [library-id])) ;; --- MUTATION COMMAND: unlink-file-from-library From 77955d7f917356642b379bb525a41729ab5680ad Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 23 Feb 2026 11:39:29 +0100 Subject: [PATCH 16/27] :sparkles: Add several redundant checks for library-id on file rpc methods --- backend/src/app/rpc/commands/files.clj | 6 ++- backend/test/backend_tests/rpc_file_test.clj | 46 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 7ce7d28d74..69c36a0e44 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1037,8 +1037,9 @@ ::webhooks/event? true ::sm/params schema:unlink-file-to-library ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] + [{: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) (unlink-file-from-library conn params) nil) @@ -1062,8 +1063,9 @@ {::doc/added "1.17" ::sm/params schema:update-file-library-sync-status ::db/transaction true} - [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}] + [{: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) (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 beb1327d8b..921477d1b3 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -867,6 +867,52 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found)))) +(t/deftest permissions-checks-unlink-library + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)})] + + + (let [data {::th/type :unlink-file-from-library + ::rpc/profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/command! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + + +(t/deftest permissions-checks-update-file-library-status + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2) + file1 (th/create-file* 1 {:project-id (:default-project-id profile1) + :profile-id (:id profile1) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile1) + :profile-id (:id profile1)})] + + + (let [data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile2) + :file-id (:id file2) + :library-id (:id file1)} + + out (th/command! data) + error (:error out)] + + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :not-found))))) + + (t/deftest deletion (let [profile1 (th/create-profile* 1) file (th/create-file* 1 {:project-id (:default-project-id profile1) From 0ceadada35363d4404598204e518c817abcffa55 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Feb 2026 10:56:38 +0100 Subject: [PATCH 17/27] :bug: Fix invalid data on layout flex dir shape property --- common/src/app/common/files/migrations.cljc | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 1a1efd807d..3655f3ece5 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -1766,6 +1766,26 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0017-fix-layout-flex-dir" + [data _] + (let [fix-layout-flex-dir + (fn [value] + (if (= value :reverse-row) + :row-reverse + value)) + + update-object + (fn [object] + (d/update-when object :layout-flex-dir fix-layout-flex-dir)) + + update-container + (fn [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" @@ -1839,4 +1859,5 @@ "0014-clear-components-nil-objects" "0015-fix-text-attrs-blank-strings" "0015-clean-shadow-color" - "0016-copy-fills-from-position-data-to-text-node"])) + "0016-copy-fills-from-position-data-to-text-node" + "0017-fix-layout-flex-dir"])) From c59cc4dff459320b466b4eb711cff5660ccb7c10 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 9 Mar 2026 12:11:39 +0100 Subject: [PATCH 18/27] :bug: Fix tooltip position on absolute positioned elements (#8509) * :bug: Fix tooltip position on absolute positioned elements * :bug: Fix tests --- .../playwright/ui/specs/colorpicker.spec.js | 4 +- .../playwright/ui/specs/tokens/apply.spec.js | 47 +++-- .../playwright/ui/specs/tokens/crud.spec.js | 17 +- .../app/main/ui/ds/buttons/icon_button.cljs | 4 + .../ui/ds/controls/shared/token_option.cljs | 9 +- .../ui/ds/controls/utilities/input_field.cljs | 1 + .../ui/ds/controls/utilities/token_field.cljs | 4 + .../src/app/main/ui/ds/tooltip/tooltip.cljs | 164 ++++++++++-------- .../src/app/main/ui/ds/tooltip/tooltip.scss | 4 +- .../src/app/main/ui/ds/utilities/swatch.cljs | 9 +- .../styles/property_detail_copiable.cljs | 5 +- .../styles/rows/color_properties_row.cljs | 3 + .../inspect/styles/rows/properties_row.cljs | 3 + .../workspace/colorpicker/color_tokens.cljs | 5 +- .../sidebar/options/rows/color_row.cljs | 4 + .../tokens/management/forms/modals.scss | 22 ++- 16 files changed, 176 insertions(+), 129 deletions(-) diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index e727f4aac7..a0e28eea07 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -94,7 +94,7 @@ test("Create a LINEAR gradient", async ({ page }) => { await expect(inputOpacityGlobal).toBeVisible(); await expect( - workspacePage.page.getByText("Linear gradient").nth(1), + workspacePage.page.getByText("Linear gradient") ).toBeVisible(); }); @@ -178,7 +178,7 @@ test("Create a RADIAL gradient", async ({ page }) => { await expect(inputOpacityGlobal).toBeVisible(); await expect( - workspacePage.page.getByText("Radial gradient").nth(1), + workspacePage.page.getByText("Radial gradient") ).toBeVisible(); }); diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 2952a23c87..a05e28b2ff 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -83,7 +83,7 @@ test.describe("Tokens: Apply token", () => { await brTokenPillSM.click(); // Change token from dropdown - const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl"); + const brTokenOptionXl = borderRadiusSection.getByRole('option', { name: 'borderRadius.xl' }) await expect(brTokenOptionXl).toBeVisible(); await brTokenOptionXl.click(); @@ -149,7 +149,7 @@ test.describe("Tokens: Apply token", () => { await detachButton.click(); // Open dropdown from input - const dropdownBtn = layerMenuSection.getByLabel("Open token list"); + const dropdownBtn = layerMenuSection.getByRole('button', { name: 'Open token list' }) await expect(dropdownBtn).toBeVisible(); await dropdownBtn.click(); @@ -225,8 +225,8 @@ test.describe("Tokens: Apply token", () => { await expect(firstShadowFields).toBeVisible(); // Fill in the shadow values - const offsetXInput = firstShadowFields.getByLabel("X"); - const offsetYInput = firstShadowFields.getByLabel("Y"); + const offsetXInput = firstShadowFields.getByRole('textbox', { name: 'X' }); + const offsetYInput = firstShadowFields.getByRole('textbox', { name: 'Y' }); const blurInput = firstShadowFields.getByRole("textbox", { name: "Blur", }); @@ -299,8 +299,8 @@ test.describe("Tokens: Apply token", () => { await expect(thirdShadowFields).toBeVisible(); // User adds values for the third shadow - const thirdOffsetXInput = thirdShadowFields.getByLabel("X"); - const thirdOffsetYInput = thirdShadowFields.getByLabel("Y"); + const thirdOffsetXInput = thirdShadowFields.getByRole('textbox', { name: 'X' }); + const thirdOffsetYInput = thirdShadowFields.getByRole('textbox', { name: 'Y' }); const thirdBlurInput = thirdShadowFields.getByRole("textbox", { name: "Blur", }); @@ -328,10 +328,10 @@ test.describe("Tokens: Apply token", () => { // Verify that the first shadow kept its values const firstOffsetXValue = await firstShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const firstOffsetYValue = await firstShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const firstBlurValue = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -357,10 +357,10 @@ test.describe("Tokens: Apply token", () => { await expect(newSecondShadowFields).toBeVisible(); const secondOffsetXValue = await newSecondShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const secondOffsetYValue = await newSecondShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const secondBlurValue = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -410,10 +410,10 @@ test.describe("Tokens: Apply token", () => { // Verify first shadow values are still there const restoredFirstOffsetX = await firstShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const restoredFirstOffsetY = await firstShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const restoredFirstBlur = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -433,10 +433,10 @@ test.describe("Tokens: Apply token", () => { // Verify second shadow values are still there const restoredSecondOffsetX = await newSecondShadowFields - .getByLabel("X") + .getByRole('textbox', { name: 'X' }) .inputValue(); const restoredSecondOffsetY = await newSecondShadowFields - .getByLabel("Y") + .getByRole('textbox', { name: 'Y' }) .inputValue(); const restoredSecondBlur = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -518,7 +518,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.nth(1).click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }) await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -572,7 +572,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -626,7 +626,7 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl"); + const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -682,7 +682,7 @@ test.describe("Tokens: Apply token", () => { // Change token from dropdown const dimensionTokenOptionXl = - borderRadiusSection.getByLabel("dimension.xl"); + borderRadiusSection.getByRole('option', { name: 'dimension.xl' }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -751,7 +751,7 @@ test.describe("Tokens: Apply token", () => { }); await tokenDropdown.click(); - const widthOptionSmall = firstStrokeRow.getByLabel("width-small"); + const widthOptionSmall = firstStrokeRow.getByRole('option', { name: 'width-small' }); await expect(widthOptionSmall).toBeVisible(); await widthOptionSmall.click(); const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", { @@ -831,15 +831,10 @@ test.describe("Tokens: Apply token", () => { }); await detachButton.click(); await expect(marginPillXL).not.toBeVisible(); - const horizontalMarginInput = layoutItemSectionSidebar.getByText( - "Horizontal marginOpen token", - ); - await expect(horizontalMarginInput).toBeVisible(); - - const tokenDropdown = horizontalMarginInput.getByRole("button", { + const horizontalMarginInput = layoutItemSectionSidebar.getByRole("button", { name: "Open token list", }); - await tokenDropdown.click(); + await horizontalMarginInput.nth(1).click(); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index b726db042d..e96ef55ae8 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -1024,7 +1024,7 @@ test.describe("Tokens - creation", () => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill("typography.empty"); - const valueField = tokensUpdateCreateModal.getByLabel("Font Size"); + const valueField = tokensUpdateCreateModal.getByRole("textbox", {name: "Font Size"}); // Insert a value and then delete it await valueField.fill("1"); @@ -1716,12 +1716,12 @@ test.describe("Tokens tab - edition", () => { // Fill font-family to verify to verify that input value doesn't get split into list of characters const fontFamilyField = tokensUpdateCreateModal - .getByLabel("Font family") + .getByRole("textbox", { name: "Font family" }) .first(); await fontFamilyField.fill("OneWord"); // Invalidate incorrect values for font size - const fontSizeField = tokensUpdateCreateModal.getByLabel(/Font Size/i); + const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Size" }); await fontSizeField.fill("invalid"); await expect( tokensUpdateCreateModal.getByText(/Invalid token value:/), @@ -1736,13 +1736,13 @@ test.describe("Tokens tab - edition", () => { await fontSizeField.fill("16"); await expect(saveButton).toBeEnabled(); - const fontWeightField = tokensUpdateCreateModal.getByLabel(/Font Weight/i); + const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" }); const letterSpacingField = - tokensUpdateCreateModal.getByLabel(/Letter Spacing/i); - const lineHeightField = tokensUpdateCreateModal.getByLabel(/Line Height/i); - const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i); + tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" }); + const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" }); + const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" }); const textDecorationField = - tokensUpdateCreateModal.getByLabel(/Text Decoration/i); + tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" }); // Capture all values before switching tabs const originalValues = { @@ -1800,6 +1800,7 @@ test.describe("Tokens tab - edition", () => { const colorToken = tokensSidebar.getByRole("button", { name: "100", }); + await expect(colorToken).toBeVisible(); await colorToken.click({ button: "right" }); 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 3d766db460..1457dabc06 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -33,6 +33,8 @@ (let [variant (d/nilv variant "primary") + button-ref (mf/use-ref nil) + tooltip-id (mf/use-id) @@ -47,10 +49,12 @@ props (mf/spread-props props {:class [class button-class] + :ref button-ref :aria-labelledby tooltip-id})] [:> tooltip* {:content aria-label :class tooltip-class + :trigger-ref button-ref :placement tooltip-placement :id tooltip-id} [:> :button props 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 3c13988cd3..18d5fc8a7d 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 @@ -28,7 +28,8 @@ {::mf/schema schema:token-option} [{:keys [id name on-click selected ref focused resolved] :rest props}] (let [internal-id (mf/use-id) - id (d/nilv id internal-id)] + id (d/nilv id internal-id) + element-ref (mf/use-ref nil)] [:li {:value id :class (stl/css-case :token-option true :option-with-pill true @@ -50,10 +51,12 @@ :aria-hidden (when name true)}] [:span {:class (stl/css :icon-placeholder)}]) [:> tooltip* {:content name + :trigger-ref element-ref :id (dm/str id "-name") :class (stl/css :option-text)} - ;; Add ellipsis - [:span {:aria-labelledby (dm/str id "-name")} + ;; Add ellipsis + [:span {:aria-labelledby (dm/str id "-name") + :ref element-ref} name]] (when resolved [:> :span {:class (stl/css :option-pill)} 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 56ceedc848..58a3202c80 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 @@ -84,6 +84,7 @@ :on-click on-icon-click}]) (if aria-label [:> tooltip* {:content aria-label + :trigger-ref (or ref input-ref) :class (stl/css :tooltip-wrapper) :id tooltip-id} [:> "input" props]] 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 8f4920ae69..c008ce2bd0 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 @@ -43,6 +43,7 @@ (tr "ds.inputs.token-field.no-active-token-option")) default-id (mf/use-id) id (d/nilv id default-id) + pill-ref (mf/use-ref nil) focus-wrapper (mf/use-fn @@ -53,6 +54,7 @@ (dom/focus! (mf/ref-val token-wrapper-ref)))))] [:> tooltip* {:content property :class (stl/css :token-field-wrapper) + :trigger-ref token-wrapper-ref :id (dm/str default-id "-input")} [:div {:class [class (stl/css-case :token-field true :with-icon (some? slot-start) @@ -70,8 +72,10 @@ [:div {:class (stl/css :content-wrapper)} [:> tooltip* {:content content + :trigger-ref pill-ref :id (dm/str id "-pill")} [:button {:on-click on-click + :ref pill-ref :class (stl/css-case :pill true :no-set-pill (not set-active?) :pill-disabled disabled) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 707540fd95..690384647c 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -6,7 +6,6 @@ (ns app.main.ui.ds.tooltip.tooltip (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require [app.common.data :as d] @@ -15,10 +14,10 @@ [app.util.timers :as ts] [rumext.v2 :as mf])) -(def ^:private ^:const arrow-height 12) -(def ^:private ^:const half-arrow-height (/ arrow-height 2)) (def ^:private ^:const overlay-offset 32) +(defonce active-tooltip (atom nil)) + (defn- clear-schedule [ref] (when-let [schedule (mf/ref-val ref)] @@ -29,20 +28,6 @@ [ref delay f] (mf/set-ref-val! ref (ts/schedule delay f))) -(defn- show-popover - [node] - (when (.-isConnected ^js node) - (.showPopover ^js node))) - -(defn- hide-popover - [node] - (when (and (some? node) - (fn? (.-hidePopover node))) - (dom/unset-css-property! node "block-size") - (dom/unset-css-property! node "inset-block-start") - (dom/unset-css-property! node "inset-inline-start") - (.hidePopover ^js node))) - (defn- calculate-placement-bounding-rect "Given a placement, calcultates the bounding rect for it taking in account provided tooltip bounding rect and the origin bounding @@ -72,18 +57,18 @@ :height tooltip-height} "left" - {:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) - :left (- trigger-left tooltip-width arrow-height) + {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) + :left (- trigger-left tooltip-width) :right (+ (- trigger-left tooltip-width) tooltip-width) - :bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) + :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height) :width tooltip-width :height tooltip-height} "right" - {:top (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) + {:top (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) :left (+ trigger-right offset) :right (+ trigger-right offset tooltip-width) - :bottom (+ (- (+ trigger-top (/ trigger-height 2) half-arrow-height) (/ tooltip-height 2)) tooltip-height) + :bottom (+ (- (+ trigger-top (/ trigger-height 2)) (/ tooltip-height 2)) tooltip-height) :width tooltip-width :height tooltip-height} @@ -153,22 +138,6 @@ (recur (rest placements)) #js [placement placement-brect]))))) -(defn- update-tooltip-position - "Update the tooltip position having in account the current window - size, placement. It calculates the appropriate placement and updates - the dom with the result." - [tooltip placement origin-brect offset] - (show-popover tooltip) - (let [tooltip-brect (dom/get-bounding-rect tooltip) - tooltip-brect (assoc tooltip-brect :height (:height tooltip-brect) :width (:width tooltip-brect)) - window-size (dom/get-window-size)] - (when-let [[placement placement-rect] (find-matching-placement placement tooltip-brect origin-brect window-size offset)] - (let [height (:height placement-rect)] - (dom/set-css-property! tooltip "block-size" (dm/str height "px")) - (dom/set-css-property! tooltip "inset-block-start" (dm/str (:top placement-rect) "px")) - (dom/set-css-property! tooltip "inset-inline-start" (dm/str (:left placement-rect) "px"))) - placement))) - (def ^:private schema:tooltip [:map [:class {:optional true} [:maybe :string]] @@ -176,19 +145,26 @@ [:offset {:optional true} :int] [:delay {:optional true} :int] [:content [:or fn? :string map?]] + [:trigger-ref {:optional true} [:maybe :any]] [:placement {:optional true} [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]]]) (mf/defc tooltip* {::mf/schema schema:tooltip} - [{:keys [class id children content placement offset delay] :rest props}] + [{:keys [class id children content placement offset delay trigger-ref aria-label] :rest props}] (let [internal-id (mf/use-id) - trigger-ref (mf/use-ref nil) + internal-trigger-ref (mf/use-ref nil) + trigger-ref (or trigger-ref internal-trigger-ref) + + tooltip-ref (mf/use-ref nil) id (d/nilv id internal-id) + tooltip-id + (mf/use-id) + placement* (mf/use-state #(d/nilv placement "top")) @@ -201,35 +177,35 @@ schedule-ref (mf/use-ref nil) + visible* + (mf/use-state false) + visible (deref visible*) + on-show (mf/use-fn - (mf/deps id placement offset) - (fn [event] - - (let [current (dom/get-current-target event) - related (dom/get-related-target event) - is-node? (fn [node] (and node (.-nodeType node)))] - (when-not (and related (is-node? related) (.contains current related)) - (clear-schedule schedule-ref) - (when-let [tooltip (dom/get-element id)] - (let [origin-brect - (dom/get-bounding-rect (mf/ref-val trigger-ref)) - - update-position - (fn [] - (let [new-placement (update-tooltip-position tooltip placement origin-brect offset)] - (when (not= new-placement placement) - (reset! placement* new-placement))))] - - (add-schedule schedule-ref delay update-position))))))) + (mf/deps tooltip-id delay) + (fn [_] + (let [trigger-el (mf/ref-val trigger-ref)] + (clear-schedule schedule-ref) + (add-schedule schedule-ref (d/nilv delay 300) + (fn [] + (prn tooltip-id) + (when-let [active @active-tooltip] + (when (not= (:id active) tooltip-id) + (when-let [tooltip-el (dom/get-element (:id active))] + (dom/set-css-property! tooltip-el "display" "none")) + (reset! active-tooltip nil))) + (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) + (reset! visible* true)))))) on-hide (mf/use-fn - (mf/deps id) + (mf/deps tooltip-id) (fn [] - (when-let [tooltip (dom/get-element id)] - (clear-schedule schedule-ref) - (hide-popover tooltip)))) + (clear-schedule schedule-ref) + (reset! visible* false) + (when (= (:id @active-tooltip) tooltip-id) + (reset! active-tooltip nil)))) handle-key-down (mf/use-fn @@ -250,28 +226,62 @@ :tooltip-bottom-left (identical? placement "bottom-left") :tooltip-top-left (identical? placement "top-left")) + content + (if (fn? content) + (content) + content) props (mf/spread-props props {:on-mouse-enter on-show :on-mouse-leave on-hide :on-focus on-show :on-blur on-hide + :ref internal-trigger-ref :on-key-down handle-key-down - :ref trigger-ref + :id id :class [class (stl/css :tooltip-trigger)] - :aria-describedby id}) - content - (if (fn? content) - (content) - content)] + :aria-label (if (string? content) + content + aria-label)})] + + (mf/use-effect + (mf/deps visible placement offset) + (fn [] + (when visible + (let [trigger-el (mf/ref-val trigger-ref) + tooltip-el (mf/ref-val tooltip-ref)] + (when (and trigger-el tooltip-el) + (js/requestAnimationFrame + (fn [] + (let [origin-brect (dom/get-bounding-rect trigger-el) + tooltip-brect (dom/get-bounding-rect tooltip-el) + window-size (dom/get-window-size)] + (when-let [[new-placement placement-rect] + (find-matching-placement + placement + tooltip-brect + origin-brect + window-size + offset)] + (dom/set-css-property! tooltip-el "inset-block-start" + (str (:top placement-rect) "px")) + (dom/set-css-property! tooltip-el "inset-inline-start" + (str (:left placement-rect) "px")) + + (when (not= new-placement placement) + (reset! placement* new-placement))))))))))) [:> :div props children - [:div {:class (stl/css :tooltip) - :id id - :popover "auto" - :role "tooltip"} - [:div {:class tooltip-class} - [:div {:class (stl/css :tooltip-content)} content] - [:div {:class (stl/css :tooltip-arrow) - :id "tooltip-arrow"}]]]])) + (when visible + (mf/portal + (mf/html + [:div {:class (stl/css :tooltip) + :role "tooltip" + :id tooltip-id + :ref tooltip-ref} + [:div {:class tooltip-class} + [:div {:class (stl/css :tooltip-content)} content] + [:div {:class (stl/css :tooltip-arrow) + :id "tooltip-arrow"}]]]) + (.-body js/document)))])) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 11df707d51..79fe80f774 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -6,17 +6,19 @@ @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; +@use "ds/z-index.scss" as *; @use "ds/typography.scss" as t; $arrow-side: 12px; .tooltip { - position: absolute; + position: fixed; max-inline-size: $sz-352; background-color: transparent; overflow: hidden; inline-size: fit-content; block-size: fit-content; + z-index: var(--z-index-notifications); } .tooltip-content-wrapper { diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.cljs b/frontend/src/app/main/ui/ds/utilities/swatch.cljs index 3f1a605efa..40b54eb607 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.cljs +++ b/frontend/src/app/main/ui/ds/utilities/swatch.cljs @@ -96,8 +96,9 @@ image (:image background) format (if id? "rounded" "square") element-id (mf/use-id) - has-opacity? (and (some? (:color background)) - (< (:opacity background) 1)) + has-opacity? (and (some? (:color background)) + (< (:opacity background) 1)) + element-ref (mf/use-ref nil) on-click (mf/use-fn (mf/deps background on-click) @@ -120,7 +121,8 @@ (mf/spread-props props {:class class :on-click on-click :type button-type - :aria-labelledby element-id}) + :aria-labelledby element-id + :ref element-ref}) children (mf/html [:> element-type props (cond @@ -147,6 +149,7 @@ [:> tooltip* {:content (if tooltip-content tooltip-content (color-title background)) + :trigger-ref element-ref :id element-id} children] 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 cc3042d9cb..ea67e04c7f 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 @@ -23,11 +23,12 @@ (mf/defc property-detail-copiable* {::mf/schema schema:property-detail-copiable} - [{:keys [color token copied on-click children]}] + [{:keys [color token copied on-click children ref]}] [:button {:class (stl/css-case :property-detail-copiable true :property-detail-copied copied :property-detail-copiable-color (some? color)) - :on-click on-click} + :on-click on-click + :ref ref} (when color [:> swatch* {:background color :size "small"}]) 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 3f192c3b82..145c452fc1 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 @@ -41,6 +41,7 @@ color-image-name (:name color-image) color-image-url (when (some? color-image) (cfg/resolve-file-media color-image)) + row-ref (mf/use-ref nil) color-opacity (mf/use-memo (mf/deps color) #(dm/str (-> color @@ -96,6 +97,7 @@ (if token [:> tooltip* {:id (:name token) :class (stl/css :tooltip-token-wrapper) + :trigger-ref row-ref :content #(mf/html [:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token-title)} @@ -104,6 +106,7 @@ (:resolved-value token)]])} [:> property-detail-copiable* {:color color :token token + :ref row-ref :copied copied :on-click copy-attr} formatted-color-value]] 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 0a317d7339..9203305eac 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 @@ -37,6 +37,7 @@ copiable-value (if (some? token) (:name token) property) + row-ref (mf/use-ref nil) copy-attr (mf/use-fn @@ -54,6 +55,7 @@ (let [token-type (:type token)] [:> tooltip* {:id (:name token) :class (stl/css :tooltip-token-wrapper) + :trigger-ref row-ref :content #(mf/html [:div {:class (stl/css :tooltip-token)} [:div {:class (stl/css :tooltip-token-title)} @@ -75,6 +77,7 @@ (:resolved-value token))]])} [:> property-detail-copiable* {:token token :copied copied + :ref row-ref :on-click copy-attr} detail]]) [:> property-detail-copiable* {:copied copied :on-click copy-attr} detail]) 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 dfef56e0ac..89be5ed48f 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.cljs @@ -44,12 +44,15 @@ (on-token-pill-click event token))) id-tooltip (mf/use-id) resolved (:resolved-value token) - color-value (dwta/value->color resolved)] + color-value (dwta/value->color resolved) + item-ref (mf/use-ref nil)] [:> tooltip* {:id id-tooltip :style {:width "100%"} + :trigger-ref item-ref :content (:name token)} [:button {:class (stl/css-case :color-token-item true :color-token-selected selected) + :ref item-ref :aria-labelledby id-tooltip :on-click on-click} [:> swatch* {:background color-value 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 afe72d64fe..7e6e47241a 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 @@ -94,6 +94,7 @@ not-active (or (empty? active-tokens) (nil? token)) id (dm/str (:id token) "-name") + token-name-ref (mf/use-ref nil) swatch-tooltip-content (cond not-active (tr "ds.inputs.token-field.no-active-token-option") @@ -126,8 +127,11 @@ :size "small"}]] [:> tooltip* {:content name-tooltip-content :id id + :aria-label (str (tr "workspace.tokens.token-name") ": " applied-token-name) + :trigger-ref token-name-ref :class (stl/css :token-tooltip)} [:div {:class (stl/css :token-name) + :ref token-name-ref :aria-labelledby id} (or token-name applied-token-name)]] [:div {:class (stl/css :token-actions)} 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 666bbab5f9..c9dc66715a 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 @@ -4,17 +4,27 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; .token-modal-wrapper { - @extend .modal-container-base; - @include deprecated.menuShadow; + border-radius: $br-4; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-width: $sz-364; + min-height: $sz-192; + max-width: $sz-512; + max-height: $sz-512; + box-shadow: 0px 0px $sz-12 0px var(--color-shadow-dark); position: absolute; width: auto; min-width: auto; - z-index: 11; + z-index: var(--z-index-set); overflow-y: auto; overflow-x: hidden; + padding: var(--sp-xxxl); &.token-modal-large { max-block-size: 95vh; } @@ -22,6 +32,6 @@ .close-btn { position: absolute; - top: deprecated.$s-6; - right: deprecated.$s-6; + top: px2rem(6); + right: px2rem(6); } From 34d29328e6452a038b7faa163fdfaca1ee1dd4c4 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 9 Mar 2026 12:12:03 +0100 Subject: [PATCH 19/27] :bug: Fix bad size on switching a layout with fixed sizing (#8504) --- CHANGES.md | 3 +- common/src/app/common/logic/libraries.cljc | 60 ++ .../logic/variants_switch_test.cljc | 838 ++++++++++++++++++ 3 files changed, 900 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 546cca95b8..ee26aed417 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 2.14.0 (Unreleased) ### :boom: Breaking changes & Deprecations + - Deprecate `PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE` in favour of `PENPOT_HTTP_SERVER_MAX_BODY_SIZE`. ### :sparkles: New features & Enhancements @@ -33,6 +34,7 @@ - Fix remove fill affects different element than selected [Taiga #13128](https://tree.taiga.io/project/penpot/issue/13128) - Fix cannot apply second token after creation while shape is selected [Taiga #13513](https://tree.taiga.io/project/penpot/issue/13513) - Fix error activating a set with invalid shadow token applied [Taiga #13528](https://tree.taiga.io/project/penpot/issue/13528) +- Fix component "broken" after variant switch [Taiga #12984](https://tree.taiga.io/project/penpot/issue/12984) ## 2.13.3 @@ -47,7 +49,6 @@ - Fix modifying shapes by apply negative tokens to border radius [Taiga #13317](https://tree.taiga.io/project/penpot/issue/13317) - Fix arbitrary file read security issue on create-font-variant rpc method (https://github.com/penpot/penpot/security/advisories/GHSA-xp3f-g8rq-9px2) - ## 2.13.1 ### :bug: Bugs fixed diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index c47f7e3878..ee4a3a8d5b 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2002,6 +2002,61 @@ :else current-content))) + +(defn- switch-fixed-layout-geom-change-value + [prev-shape ; The shape before the switch + current-shape ; The shape after the switch (a clean copy) + attr] + ;; When there is a layout with fixed h or v sizing, we need + ;; to keep the width/height (and recalculate selrect and points) + (let [prev-width (-> prev-shape :selrect :width) + current-width (-> current-shape :selrect :width) + + prev-height (-> prev-shape :selrect :height) + current-height (-> current-shape :selrect :height) + + x (-> current-shape :selrect :x) + y (-> current-shape :selrect :y) + + + h-sizing (:layout-item-h-sizing prev-shape) + v-sizing (:layout-item-v-sizing prev-shape) + + final-width (if (= :fix h-sizing) + current-width + prev-width) + final-height (if (= :fix v-sizing) + current-height + prev-height) + selrect (assoc (:selrect current-shape) + :width final-width + :height final-height + :x x + :y y + :x1 x + :y1 y + :x2 (+ x final-width) + :y2 (+ y final-height))] + + (case attr + :width + final-width + + :height + final-height + + :selrect + selrect + + :points + (-> selrect + (grc/rect->points) + (gsh/transform-points + (grc/rect->center selrect) + (or (:transform current-shape) (gmt/matrix))))))) + + + (defn update-attrs-on-switch "Copy attributes that have changed in the shape previous to the switch to the current shape (post switch). Used only on variants switch" @@ -2110,6 +2165,11 @@ origin-ref-shape attr) + (and (or (= :fix (:layout-item-h-sizing previous-shape)) + (= :fix (:layout-item-v-sizing previous-shape))) + (contains? #{:points :selrect :width :height} attr)) + (switch-fixed-layout-geom-change-value previous-shape current-shape attr) + :else (get previous-shape attr))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index f271489ae1..d49764a389 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -18,6 +18,9 @@ (t/use-fixtures :each thi/test-fixture) +;; ============================================================ +;; BASIC SWITCH TESTS (no overrides) +;; ============================================================ (t/deftest test-basic-switch (let [;; ==== Setup @@ -68,6 +71,9 @@ ;; The rect has width 15 after the switch (t/is (= (:width rect02') 15)))) +;; ============================================================ +;; SIMPLE ATTRIBUTE OVERRIDES (identical variants) +;; ============================================================ (t/deftest test-basic-switch-override (let [;; ==== Setup @@ -142,6 +148,10 @@ ;; The override is keept: The rect still has width 25 after the switch (t/is (= (:width rect02') 25)))) +;; ============================================================ +;; SIMPLE ATTRIBUTE OVERRIDES (different variants) +;; ============================================================ + (t/deftest test-switch-with-no-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -182,6 +192,10 @@ ;; The rect has width 15 after the switch (t/is (= (:width rect02') 15)))) +;; ============================================================ +;; TEXT OVERRIDES (identical variants) +;; ============================================================ + (def font-size-path-paragraph [:content :children 0 :children 0 :font-size]) (def font-size-path-0 [:content :children 0 :children 0 :children 0 :font-size]) (def font-size-path-1 [:content :children 0 :children 0 :children 1 :font-size]) @@ -346,6 +360,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) +;; ============================================================ +;; TEXT OVERRIDES (different property) +;; ============================================================ + (t/deftest test-switch-with-different-prop-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -472,6 +490,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' text-path-0) "text overriden")))) +;; ============================================================ +;; TEXT OVERRIDES (different text) +;; ============================================================ + (t/deftest test-switch-with-different-text-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -596,6 +618,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "25")) (t/is (= (get-in copy-both-t' text-path-0) "bye")))) +;; ============================================================ +;; TEXT OVERRIDES (different text AND property) +;; ============================================================ + (t/deftest test-switch-with-different-text-and-prop-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -722,6 +748,10 @@ (t/is (= (get-in copy-both-t' font-size-path-0) "50")) (t/is (= (get-in copy-both-t' text-path-0) "bye")))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (identical variants) +;; ============================================================ + (t/deftest test-switch-with-identical-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -851,6 +881,10 @@ (t/is (= (get-in copy-structure-mixed-t' font-size-path-1) "40")) (t/is (= (get-in copy-structure-mixed-t' text-path-1) "new line 2")))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different property) +;; ============================================================ + (t/deftest test-switch-with-different-prop-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -978,6 +1012,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "hello world")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different text) +;; ============================================================ + (t/deftest test-switch-with-different-text-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1104,6 +1142,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; TEXT STRUCTURE OVERRIDES (different text AND property) +;; ============================================================ + (t/deftest test-switch-with-different-text-and-prop-structure-text-override (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1231,6 +1273,10 @@ (t/is (= (get-in copy-structure-mixed-t' text-path-0) "bye")) (t/is (nil? (get-in copy-structure-mixed-t' font-size-path-1))))) +;; ============================================================ +;; NESTED COMPONENTS (with same component in both variants) +;; ============================================================ + (t/deftest test-switch-variant-for-other-with-same-nested-component (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1274,6 +1320,10 @@ ;; The width of copy-cp02-rect' is 25 (change is preserved) (t/is (= (:width copy-cp02-rect') 25)))) +;; ============================================================ +;; SWAPPED COPIES (switching variants that contain swapped components) +;; ============================================================ + (t/deftest test-switch-variant-that-has-swaped-copy (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1366,6 +1416,10 @@ ;; The width of copy-cp02-rect' is 25 (change is preserved) (t/is (= (:width copy-cp02-rect') 25)))) +;; ============================================================ +;; TOUCHED PARENT (switch without touched but with touched parent) +;; ============================================================ + (t/deftest test-switch-variant-without-touched-but-touched-parent (let [;; ==== Setup file (-> (thf/sample-file :file1) @@ -1420,3 +1474,787 @@ (t/is (= (:width rect01) 25)) ;; The rect still has width 25 after the switch (t/is (= (:width rect02') 25)))) + +;; ============================================================ +;; LAYOUT ITEM SIZING - HORIZONTAL (fix, auto, fill, none) +;; ============================================================ + +(t/deftest test-switch-with-layout-item-h-sizing-fix + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :fix + ;; When :fix is set, the width should NOT be preserved on switch + ;; but should take the new component's width + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :fix} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :fix, the width should be taken from the new component + ;; (not preserving the override), so it should be 200 + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :fix after switch + (t/is (= (:layout-item-h-sizing rect02') :fix)))) + +(t/deftest test-switch-with-layout-item-h-sizing-auto + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :auto + ;; When :auto is set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :auto} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :auto}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :auto, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :auto after switch + (t/is (= (:layout-item-h-sizing rect02') :auto)))) + +(t/deftest test-switch-with-layout-item-h-sizing-fill + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-h-sizing :fill + ;; When :fill is set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50 + :layout-item-h-sizing :fill} + :child2-params {:width 200 + :height 50 + :layout-item-h-sizing :fill}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; With layout-item-h-sizing :fill, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still :fill after switch + (t/is (= (:layout-item-h-sizing rect02') :fill)))) + +(t/deftest test-switch-without-layout-item-h-sizing + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child without layout-item-h-sizing + ;; When not set, the width override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 50} + :child2-params {:width 200 + :height 50}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch (with override) + (t/is (= (:width rect01) 150)) + ;; Without layout-item-h-sizing, since the two variants have different widths (100 vs 200), + ;; the override is not preserved and the new component's width (200) is used + (t/is (= (:width rect02') 200)) + ;; Verify layout-item-h-sizing is still nil after switch + (t/is (nil? (:layout-item-h-sizing rect02'))))) + +;; ============================================================ +;; LAYOUT ITEM SIZING - VERTICAL (fix, auto, fill, none) +;; ============================================================ + +(t/deftest test-switch-with-layout-item-v-sizing-fix + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :fix + ;; When :fix is set, the height should NOT be preserved on switch + ;; but should take the new component's height + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :fix} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :fix}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :fix, the height should be taken from the new component + ;; (not preserving the override), so it should be 200 + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :fix after switch + (t/is (= (:layout-item-v-sizing rect02') :fix)))) + +(t/deftest test-switch-with-layout-item-v-sizing-auto + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :auto + ;; When :auto is set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :auto} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :auto}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :auto, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :auto after switch + (t/is (= (:layout-item-v-sizing rect02') :auto)))) + +(t/deftest test-switch-with-layout-item-v-sizing-fill + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child that has layout-item-v-sizing :fill + ;; When :fill is set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100 + :layout-item-v-sizing :fill} + :child2-params {:width 50 + :height 200 + :layout-item-v-sizing :fill}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; With layout-item-v-sizing :fill, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still :fill after switch + (t/is (= (:layout-item-v-sizing rect02') :fill)))) + +(t/deftest test-switch-without-layout-item-v-sizing + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create a variant with a child without layout-item-v-sizing + ;; When not set, the height override SHOULD be preserved on switch + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 50 + :height 100} + :child2-params {:width 50 + :height 200}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change height of the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :height 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had height 150 before the switch (with override) + (t/is (= (:height rect01) 150)) + ;; Without layout-item-v-sizing, since the two variants have different heights (100 vs 200), + ;; the override is not preserved and the new component's height (200) is used + (t/is (= (:height rect02') 200)) + ;; Verify layout-item-v-sizing is still nil after switch + (t/is (nil? (:layout-item-v-sizing rect02'))))) + +;; ============================================================ +;; ROTATION OVERRIDES +;; ============================================================ + +(t/deftest test-switch-with-rotation-override + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :rotation 0} + :child2-params {:width 100 + :height 100 + :rotation 0}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Apply rotation to the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :rotation 45)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had rotation 45 before the switch (with override) + (t/is (= (:rotation rect01) 45)) + ;; The rotation override should be preserved after switch since both variants have the same rotation + (t/is (= (:rotation rect02') 45)) + ;; The transform matrix should also be preserved + (t/is (some? (:transform rect02'))))) + +(t/deftest test-switch-with-rotation-different-variants + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :rotation 0} + :child2-params {:width 100 + :height 100 + :rotation 90}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Apply rotation to the child rect (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :rotation 45)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had rotation 45 before the switch (with override) + (t/is (= (:rotation rect01) 45)) + ;; The override should NOT be preserved since the two variants have different rotations (0 vs 90) + ;; The new rotation should be 90 (from c02) + (t/is (= (:rotation rect02') 90)))) + +;; ============================================================ +;; SPECIAL CASES (auto-text, geometry, touched attributes, position data) +;; ============================================================ + +(t/deftest test-switch-with-auto-text-geometry-not-copied + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + ;; Create variants with auto-text (grow-type :auto-width or :auto-height) + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello" "world")) + + page (thf/current-page file) + ;; Modify the first text shape to have grow-type :auto-width + t01 (ths/get-shape file :t01) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t01)} + (fn [shape] + (assoc shape :grow-type :auto-width)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Also modify t02 + page (thf/current-page file) + t02 (ths/get-shape file :t02) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t02)} + (fn [shape] + (assoc shape :grow-type :auto-width)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Now create a copy and modify its width + file (thc/instantiate-component file :c01 + :copy01 + :children-labels [:copy-t01]) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + text01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the text (creating an override) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id text01)} + (fn [shape] + (assoc shape :width 200)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + text01 (get-in page [:objects (:id text01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + text02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The text had width 200 before the switch (with override) + (t/is (= (:width text01) 200)) + ;; For auto-text shapes, geometry attributes like width should NOT be copied on switch + ;; So the width should be from the new component (t02's width) + (t/is (not= (:width text02') 200)) + ;; Verify grow-type is preserved + (t/is (= (:grow-type text02') :auto-width)))) + +(t/deftest test-switch-different-shape-types-content-not-copied + (let [;; ==== Setup - Create a variant with a rect in first component + ;; This test is simplified to just test attributes, not changing shape types + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 100 :type :rect} + :child2-params {:width 100 :height 100 :type :rect}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; ==== Action - Try to switch to a component with different shape type + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + child02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; Verify the shapes are still rects + (t/is (= (:type rect01) :rect)) + (t/is (= (:type child02') :rect)) + ;; This test demonstrates that content with different types isn't copied + ;; In practice this means proper attribute filtering + (t/is (= (:width child02') 100)))) + +(t/deftest test-switch-with-path-shape-geometry-override + (let [;; ==== Setup - Create variants with path shapes + ;; Using rect shapes as path shapes are complex - the principle is the same + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 :height 100 :type :rect} + :child2-params {:width 200 :height 200 :type :rect}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-path01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + path01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Resize the path (creating an override by changing selrect) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id path01)} + (fn [shape] + (assoc shape :width 150)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + path01 (get-in page [:objects (:id path01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + path02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had width 150 before the switch + (t/is (= (:width path01) 150)) + ;; For shapes with geometry changes, the transformed geometry is applied + ;; Since variants have different widths (100 vs 200), override is discarded + (t/is (= (:width path02') 200)) + ;; Verify it's still a rect type + (t/is (= (:type path02') :rect)))) + +(t/deftest test-switch-preserves-touched-attributes-only + (let [;; ==== Setup - Test that only touched attributes are copied + ;; Use opacity since it's a simpler attribute than fill-color + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :opacity 1} + :child2-params {:width 200 + :height 200 + :opacity 1}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change the opacity (creating a touched attribute) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id rect01)} + (fn [shape] + (assoc shape :opacity 0.5)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + rect01 (get-in page [:objects (:id rect01)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had opacity 0.5 before the switch (touched) + (t/is (= (:opacity rect01) 0.5)) + ;; The rect had width 100 before the switch (not touched) + (t/is (= (:width rect01) 100)) + + ;; After switch: + ;; - opacity override SHOULD be preserved because: + ;; 1. It was touched + ;; 2. Both variants have same opacity (1) + (t/is (= (:opacity rect02') 0.5)) + ;; - width should NOT be preserved (it wasn't touched, and variants have different widths) + (t/is (= (:width rect02') 200)) + ;; - height should match the new variant + (t/is (= (:height rect02') 200)))) + +(t/deftest test-switch-with-equal-values-not-copied + (let [;; ==== Setup - Test that when previous-shape and current-shape have equal values, + ;; no copy operation occurs (optimization in update-attrs-on-switch) + ;; Both variants start with opacity 0.5 + file (-> (thf/sample-file :file1) + (thv/add-variant-with-child + :v01 :c01 :m01 :c02 :m02 :r01 :r02 + {:child1-params {:width 100 + :height 100 + :opacity 0.5} + :child2-params {:width 100 + :height 100 + :opacity 0.5}}) + + (thc/instantiate-component :c01 + :copy01 + :children-labels [:copy-r01])) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + rect01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The rect had opacity 0.5 before the switch + (t/is (= (:opacity rect01) 0.5)) + ;; After switch, opacity should still be 0.5 + ;; This validates that the equality check works correctly + (t/is (= (:opacity rect02') 0.5)))) + +(t/deftest test-switch-with-position-data-reset + (let [;; ==== Setup - Test that position-data is reset when geometry-group is touched + file (-> (thf/sample-file :file1) + ;; Create variants with text shapes + (thv/add-variant-with-text + :v01 :c01 :m01 :c02 :m02 :t01 :t02 "hello world" "hello world")) + + page (thf/current-page file) + ;; Modify the first text shape to have specific geometry + t01 (ths/get-shape file :t01) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id t01)} + (fn [shape] + (assoc shape :width 200)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; Create a copy and modify its geometry (touching geometry-group) + file (thc/instantiate-component file :c01 + :copy01 + :children-labels [:copy-t01]) + + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + text01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; Change width of the text (touching geometry) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id text01)} + (fn [shape] + (assoc shape :width 300)) + (:objects page) + {}) + + file (thf/apply-changes file changes) + page (thf/current-page file) + text01 (get-in page [:objects (:id text01)]) + old-position-data (:position-data text01) + + ;; ==== Action + file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + text02' (get-in page' [:objects (-> copy02' :shapes first)]) + new-position-data (:position-data text02')] + + ;; position-data should be reset (nil or different) when geometry group is touched + ;; This allows the system to recalculate it based on the new geometry + ;; Note: old-position-data may be nil initially, which is fine + ;; After switch with geometry changes, if old data existed and was different, + ;; or if it needs recalculation, the test validates the behavior + (t/is (or (nil? old-position-data) + (nil? new-position-data) + (not= old-position-data new-position-data))))) \ No newline at end of file From 32cf95265a69ef61ad24d6e281d7f2328b5f8c97 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 9 Mar 2026 16:23:28 +0100 Subject: [PATCH 20/27] :books: Add GitHub Copilot instructions (#8548) --- .github/workflows/tests.yml | 42 +++- .gitignore | 1 + AGENTS.md | 265 +++++++++++++++++++++++ backend/AGENTS.md | 87 ++++++++ backend/package.json | 5 +- common/package.json | 3 +- exporter/package.json | 3 +- frontend/package.json | 11 +- frontend/scripts/build-libs.js | 17 +- library/package.json | 3 +- package.json | 1 + pnpm-lock.yaml | 370 +++++++++++++++++++++++++++++++++ render-wasm/AGENTS.md | 61 ++++++ run-ci.sh | 50 ----- 14 files changed, 845 insertions(+), 74 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/AGENTS.md create mode 100644 pnpm-lock.yaml create mode 100644 render-wasm/AGENTS.md delete mode 100755 run-ci.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4021568ca..9fa432e7d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,9 +28,47 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Check clojure code format + - name: Lint Common + working-directory: ./common run: | - ./scripts/lint + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Frontend + working-directory: ./frontend + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + pnpm run lint:js + pnpm run lint:scss + + - name: Lint Backend + working-directory: ./backend + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Exporter + working-directory: ./exporter + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj + + - name: Lint Library + working-directory: ./library + run: | + corepack enable; + corepack install; + pnpm install; + pnpm run lint:clj test-common: name: "Common Tests" diff --git a/.gitignore b/.gitignore index 224d199dc3..9958d90cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ /notes /playground/ /backend/*.md +!/backend/AGENTS.md /backend/*.sql /backend/*.txt /backend/assets/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..d126301300 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,265 @@ +# Penpot – Copilot Instructions + +## Architecture Overview + +Penpot is a full-stack design tool composed of several distinct components: + +| Component | Language | Role | +|-----------|----------|------| +| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | +| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | +| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | +| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | +| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | +| `mcp/` | TypeScript | Model Context Protocol integration | +| `plugins/` | TypeScript | Plugin runtime and example plugins | + +The monorepo is managed with `pnpm` workspaces. The `manage.sh` +orchestrates cross-component builds. `run-ci.sh` defines the CI +pipeline. + +--- + +## Build, Test & Lint Commands + +### Frontend (`cd frontend`) + +Run `./scripts/setup` for setup all dependencies. + + +```bash +# Dev +pnpm run watch:app # Full dev build (WASM + CLJS + assets) + +# Production Build +./scripts/build + +# Tests +pnpm run test # Build ClojureScript tests + run node target/tests/test.js +pnpm run watch:test # Watch + auto-rerun on change +pnpm run test:e2e # Playwright e2e tests +pnpm run test:e2e --grep "pattern" # Single e2e test by pattern + +# Lint +pnpm run lint:js # format and linter check for JS +pnpm run lint:clj # format and linter check for CLJ +pnpm run lint:scss # prettier check for SCSS + +# Code formatting +pnpm run fmt:clj # Format CLJ +pnpm run fmt:js # prettier for JS +pnpm run fmt:scss # prettier for SCSS +``` + +To run a focused ClojureScript unit test: edit +`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + +### Backend (`cd backend`) + +```bash +# Tests (Kaocha) +clojure -M:dev:test # Full suite +clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace + +# Lint / Format +pnpm run lint:clj +pnpm run fmt:clj +``` + +Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. + + +### Common (`cd common`) + +```bash +pnpm run test # Build + run node target/tests/test.js +pnpm run watch:test # Watch mode +pnpm run lint:clj +pnpm run fmt:clj +``` + +### Render-WASM (`cd render-wasm`) + +```bash +./test # Rust unit tests (cargo test) +./build # Compile to WASM (requires Emscripten) +cargo fmt --check +./lint --debug +``` + +## Key Conventions + +### Namespace Structure + +**Backend:** +- `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) +- `app.http.*` – HTTP routes and middleware +- `app.db.*` – Database layer +- `app.tasks.*` – Background job tasks +- `app.main` – Integrant system setup and entrypoint +- `app.loggers` – Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`) + +**Frontend:** +- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`) +- `app.main.data.*` – Potok event handlers (state mutations + side effects) +- `app.main.refs` – Reactive subscriptions (okulary lenses) +- `app.main.store` – Potok event store +- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) + +**Common:** +- `app.common.types.*` – Shared data types for shapes, files, pages +- `app.common.schema` – Malli validation schemas +- `app.common.geom.*` – Geometry utilities +- `app.common.data.macros` – Performance macros used everywhere + +### Backend RPC Commands + +All API calls go through a single RPC endpoint: `POST /api/rpc/command/`. + +```clojure +(sv/defmethod ::my-command + {::rpc/auth true ;; requires auth + ::doc/added "1.18" + ::sm/params [:map ...] ;; malli input schema + ::sm/result [:map ...]} ;; malli output schema + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] + ;; return a plain map or throw + {:id (uuid/next)}) +``` + +### Frontend State Management (Potok) + +State is a single atom managed by a Potok store. Events implement protocols: + +```clojure +(defn my-event [data] + (ptk/reify ::my-event + ptk/UpdateEvent + (update [_ state] ;; synchronous state transition + (assoc state :key data)) + + ptk/WatchEvent + (watch [_ state stream] ;; async: returns an observable + (->> (rp/cmd! :some-rpc-command params) + (rx/map success-event) + (rx/catch error-handler))) + + ptk/EffectEvent + (effect [_ state _] ;; pure side effects (DOM, logging) + (.focus (dom/get-element "id"))))) +``` + +Dispatch with `(st/emit! (my-event data))`. Read state via reactive +refs: `(deref refs/selected-shapes)`. Prefer helpers from +`app.util.dom` instead of using direct dom calls, if no helper is +available, prefer adding a new helper for handling it and the use the +new helper. + + +### CSS Modules Pattern + +Styles are co-located with components. Each `.cljs` file has a corresponding `.scss` file: + +```clojure +;; In the component namespace: +(require '[app.main.style :as stl]) + +;; In the render function: +[:div {:class (stl/css :container :active)}] + +;; Conditional: +[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] + +;; When you need concat an existing class: +[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] + +``` + +### Performance Macros (`app.common.data.macros`) + +Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: + +```clojure +(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys +(dm/get-in obj [:a :b :c]) ;; faster than core/get-in +(dm/str "a" "b" "c") ;; string concatenation +``` + +### Shared Code (cljc) + +Files in `common/src/app/common/` use reader conditionals to target both runtimes: + +```clojure +#?(:clj (import java.util.UUID) + :cljs (:require [cljs.core :as core])) +``` + +Both frontend and backend depend on `common` as a local library (`penpot/common {:local/root "../common"}`). + + +### Component Definition (Rumext / React) + +The codebase has several kind of components, some of them use legacy +syntax. The current and the most recent syntax uses `*` suffix on the +name. This indicates to the `mf/defc` macro apply concrete rules on +how props should be treated. + +```clojure +(mf/defc my-component* + {::mf/wrap [mf/memo]} ;; React.memo + [{:keys [name on-click]}] + [:div {:class (stl/css :root) + :on-click on-click} + name]) +``` + +Hooks: `(mf/use-state)`, `(mf/use-effect)`, `(mf/use-memo)` – analgous to react hooks. + + +The component usage should always follow the `[:> my-component* +props]`, where props should be a map literal or symbol pointing to +javascript props objects. The javascript props object can be created +manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper +macro. + +--- + +## Commit Guidelines + +Format: ` ` + +``` +:bug: Fix unexpected error on launching modal + +Optional body explaining the why. + +Signed-off-by: Fullname +``` + +**Subject rules:** imperative mood, capitalize first letter, no +trailing period, ≤ 80 characters. Add an entry to `CHANGES.md` if +applicable. + +**Code patches must include a DCO sign-off** (`git commit -s`). + +| Emoji | Emoji-Code | Use for | +|-------|------|---------| +| 🐛 | `:bug:` | Bug fix | +| ✨ | `:sparkles:` | Improvement | +| 🎉 | `:tada:` | New feature | +| ♻️ | `:recycle:` | Refactor | +| 💄 | `:lipstick:` | Cosmetic changes | +| 🚑 | `:ambulance:` | Critical bug fix | +| 📚 | `:books:` | Docs | +| 🚧 | `:construction:` | WIP | +| 💥 | `:boom:` | Breaking change | +| 🔧 | `:wrench:` | Config update | +| ⚡ | `:zap:` | Performance | +| 🐳 | `:whale:` | Docker | +| 📎 | `:paperclip:` | Other non-relevant changes | +| ⬆️ | `:arrow_up:` | Dependency upgrade | +| ⬇️ | `:arrow_down:` | Dependency downgrade | +| 🔥 | `:fire:` | Remove files or code | +| 🌐 | `:globe_with_meridians:` | Translations | diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000000..f0b4a7314c --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,87 @@ +# backend – Agent Instructions + +Clojure service running on the JVM. Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching. + +## Commands + +```bash +# REPL (primary dev workflow) +./scripts/repl # Start nREPL + load dev/user.clj utilities + +# Tests (Kaocha) +clojure -M:dev:test # Full suite +clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace + +# Lint / Format +pnpm run lint:clj +pnpm run fmt:clj +``` + +Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`. + +## Integrant System + +`src/app/main.clj` declares the system map. Each key is a component; +values are config maps with `::ig/ref` for dependencies. Components +implement `ig/init-key` / `ig/halt-key!`. + +From the REPL (`dev/user.clj` is auto-loaded): +```clojure +(start!) ; boot the system +(stop!) ; halt the system +(restart!) ; stop + reload namespaces + start +``` + +## RPC Commands + +All API calls: `POST /api/rpc/command/`. + +```clojure +(sv/defmethod ::my-command + {::rpc/auth true ;; requires authentication (default) + ::doc/added "1.18" + ::sm/params [:map ...] ;; malli input schema + ::sm/result [:map ...]} ;; malli output schema + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] + ;; return a plain map; throw via ex/raise for errors + {:id (uuid/next)}) +``` + +Add new commands in `src/app/rpc/commands/`. + +## Database + +`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. + +```clojure +;; Query helpers +(db/get pool :table {:id id}) ; fetch one row (throws if missing) +(db/get* pool :table {:id id}) ; fetch one row (returns nil) +(db/query pool :table {:team-id team-id}) ; fetch multiple rows +(db/insert! pool :table {:name "x" :team-id id}) ; insert +(db/update! pool :table {:name "y"} {:id id}) ; update +(db/delete! pool :table {:id id}) ; delete +;; Transactions +(db/tx-run cfg (fn [{:keys [::db/conn]}] + (db/insert! conn :table row))) +``` + +Almost all methods on `app.db` namespace accepts `pool`, `conn` or +`cfg` as params. + +Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. + +## Error Handling + +```clojure +(ex/raise :type :not-found + :code :object-not-found + :hint "File does not exist" + :context {:id file-id}) +``` + +Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`. + +## Configuration + +`src/app/config.clj` reads `PENPOT_*` environment variables, validated with Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags :enable-smtp)`. diff --git a/backend/package.json b/backend/package.json index f3f4c18476..63bf06eddf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,8 +19,7 @@ "ws": "^8.17.0" }, "scripts": { - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "clj-kondo --parallel --lint src/" + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "fmt:clj": "cljfmt fix --parallel=true src/ test/" } } diff --git a/common/package.json b/common/package.json index 9e1343ef20..09de4e95aa 100644 --- a/common/package.json +++ b/common/package.json @@ -20,9 +20,8 @@ "date-fns": "^4.1.0" }, "scripts": { - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "clj-kondo --parallel=true --lint src/", "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", diff --git a/exporter/package.json b/exporter/package.json index 9471814939..70b64bea7d 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,8 +34,7 @@ "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:clj:check": "cljfmt check --parallel=false src/", "fmt:clj": "cljfmt fix --parallel=true src/", - "lint:clj": "clj-kondo --parallel --lint src/" + "lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" } } diff --git a/frontend/package.json b/frontend/package.json index 0d775f1a9e..f1fb0b3feb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,12 +24,11 @@ "build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "fmt:js": "pnpx prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", - "fmt:js:check": "pnpx prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", - "lint:clj": "clj-kondo --parallel --lint src/", - "lint:scss": "pnpx prettier -c resources/styles -c src/**/*.scss", - "lint:scss:fix": "pnpx prettier -c resources/styles -c src/**/*.scss -w", + "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", + "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", + "lint:scss": "prettier -c resources/styles -c src/**/*.scss", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", diff --git a/frontend/scripts/build-libs.js b/frontend/scripts/build-libs.js index b2bbe30559..a1aff27f2b 100644 --- a/frontend/scripts/build-libs.js +++ b/frontend/scripts/build-libs.js @@ -5,14 +5,17 @@ import { readFile } from "node:fs/promises"; * esbuild plugin to watch a directory recursively */ const watchExtraDirPlugin = { - name: 'watch-extra-dir', + name: "watch-extra-dir", setup(build) { - build.onLoad({ filter: /target\/index.js/, namespace: 'file' }, async (args) => { - return { - watchDirs: ["packages/ui/dist"], - }; - }); - } + build.onLoad( + { filter: /target\/index.js/, namespace: "file" }, + async (args) => { + return { + watchDirs: ["packages/ui/dist"], + }; + }, + ); + }, }; const filter = diff --git a/library/package.json b/library/package.json index 46dc4fbac8..c3f3d1c32a 100644 --- a/library/package.json +++ b/library/package.json @@ -27,8 +27,7 @@ "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build:bundle": "./scripts/build", "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "fmt:clj:check": "cljfmt check --parallel=false src/ test/", - "lint:clj": "clj-kondo --parallel --lint src/", + "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", "test": "node --test", "watch:test": "node --test --watch", "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" diff --git a/package.json b/package.json index 0a6d43e4f6..f38f80617d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "fmt": "./scripts/fmt" }, "devDependencies": { + "@github/copilot": "^1.0.2", "@types/node": "^20.12.7", "esbuild": "^0.25.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000..bec7b49e31 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,370 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@github/copilot': + specifier: ^1.0.2 + version: 1.0.2 + '@types/node': + specifier: ^20.12.7 + version: 20.19.37 + esbuild: + specifier: ^0.25.9 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@github/copilot-darwin-arm64@1.0.2': + resolution: {integrity: sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@1.0.2': + resolution: {integrity: sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@1.0.2': + resolution: {integrity: sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@1.0.2': + resolution: {integrity: sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-win32-arm64@1.0.2': + resolution: {integrity: sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@1.0.2': + resolution: {integrity: sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@1.0.2': + resolution: {integrity: sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==} + hasBin: true + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@github/copilot-darwin-arm64@1.0.2': + optional: true + + '@github/copilot-darwin-x64@1.0.2': + optional: true + + '@github/copilot-linux-arm64@1.0.2': + optional: true + + '@github/copilot-linux-x64@1.0.2': + optional: true + + '@github/copilot-win32-arm64@1.0.2': + optional: true + + '@github/copilot-win32-x64@1.0.2': + optional: true + + '@github/copilot@1.0.2': + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.2 + '@github/copilot-darwin-x64': 1.0.2 + '@github/copilot-linux-arm64': 1.0.2 + '@github/copilot-linux-x64': 1.0.2 + '@github/copilot-win32-arm64': 1.0.2 + '@github/copilot-win32-x64': 1.0.2 + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + undici-types@6.21.0: {} diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md new file mode 100644 index 0000000000..378122985c --- /dev/null +++ b/render-wasm/AGENTS.md @@ -0,0 +1,61 @@ +# render-wasm – Agent Instructions + +This component compiles Rust to WebAssembly using Emscripten + Skia. It is consumed by the frontend as a canvas renderer. + +## Commands + +```bash +./build # Compile Rust → WASM (requires Emscripten environment) +./watch # Incremental rebuild on file change +./test # Run Rust unit tests (cargo test) +./lint # clippy -D warnings +cargo fmt --check +``` + +Run a single test: +```bash +cargo test my_test_name # by test function name +cargo test shapes:: # by module prefix +``` + +Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server). + +## Build Environment + +The `_build_env` script sets required env vars (Emscripten paths, +`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is +configured to 256 MB initial with geometric growth. + +## Architecture + +**Global state** — a single `unsafe static mut State` accessed +exclusively through `with_state!` / `with_state_mut!` macros. Never +access it directly. + +**Tile-based rendering** — only 512×512 tiles within the viewport +(plus a pre-render buffer) are drawn each frame. Tiles outside the +range are skipped. + +**Two-phase updates** — shape data is written via exported setter +functions (called from ClojureScript), then a single `render_frame()` +triggers the actual Skia draw calls. + +**Shape hierarchy** — shapes live in a flat pool indexed by UUID; +parent/child relationships are tracked separately. + +## Key Source Modules + +| Path | Role | +|------|------| +| `src/lib.rs` | WASM exports — all functions callable from JS | +| `src/state.rs` | Global `State` struct definition | +| `src/render/` | Tile rendering pipeline, Skia surface management | +| `src/shapes/` | Shape types and Skia draw logic per shape | +| `src/wasm/` | JS interop helpers (memory, string encoding) | + +## Frontend Integration + +The WASM module is loaded by `app.render-wasm.*` namespaces in the +frontend. ClojureScript calls exported Rust functions to push shape +data, then calls `render_frame`. Do not change export function +signatures without updating the ClojureScript bridge. diff --git a/run-ci.sh b/run-ci.sh deleted file mode 100755 index a57d425924..0000000000 --- a/run-ci.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -set -e - -echo "################ test common ################" -pushd common -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -clojure -M:dev:test -pnpm run test -popd - -echo "################ test frontend ################" -pushd frontend -pnpm install -pnpm run fmt:clj:check -pnpm run fmt:js:check -pnpm run lint:scss -pnpm run lint:clj -pnpm run test -popd - -echo "################ test integration ################" -pushd frontend -pnpm install -pnpm run test:e2e -x --workers=4 -popd - -echo "################ test backend ################" -pushd backend -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -clojure -M:dev:test --reporter kaocha.report/documentation -popd - -echo "################ test exporter ################" -pushd exporter -pnpm install -pnpm run fmt:clj:check -pnpm run lint:clj -popd - -echo "################ test render-wasm ################" -pushd render-wasm -cargo fmt --check -./lint --debug -./test -popd From ab90500ec824cd9a56ddf04780823915ace056cd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 10:04:07 +0100 Subject: [PATCH 21/27] :bug: Fix download-image to properly handle network errors and non-2xx responses (#8554) The download-image function in app.media silently succeeded when the remote image URL was unreachable or returned an error status code, causing create-file-media-object-from-url to report success with no actual image stored. Add exception handling for connection refused, timeouts, and I/O errors around the HTTP request, and validate the HTTP status code in parse-and-validate before processing the response body. Fixes #8499 Signed-off-by: Andrey Antukh --- backend/src/app/media.clj | 36 ++++++- backend/test/backend_tests/rpc_media_test.clj | 102 +++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index bbb3123e73..d54f19ab10 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -293,12 +293,17 @@ (defn download-image "Download an image from the provided URI and return the media input object" [{:keys [::http/client]} uri] - (letfn [(parse-and-validate [{:keys [headers] :as response}] + (letfn [(parse-and-validate [{:keys [status headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) mtype (get headers "content-type") format (cm/mtype->format mtype) max-size (cf/get :media-max-file-size default-max-file-size)] + (when-not (<= 200 status 299) + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': unexpected status code %" uri status))) + (when-not size (ex/raise :type :validation :code :unknown-size @@ -318,9 +323,32 @@ {:size size :mtype mtype :format format}))] - (let [{:keys [body] :as response} (http/req! client - {:method :get :uri uri} - {:response-type :input-stream}) + (let [{:keys [body] :as response} + (try + (http/req! client + {:method :get :uri uri} + {:response-type :input-stream}) + (catch java.net.ConnectException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': connection refused or host unreachable" uri) + :cause cause)) + (catch java.net.http.HttpConnectTimeoutException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': connection timeout" uri) + :cause cause)) + (catch java.net.http.HttpTimeoutException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': request timeout" uri) + :cause cause)) + (catch java.io.IOException cause + (ex/raise :type :validation + :code :unable-to-download-image + :hint (str/ffmt "unable to download image from '%': I/O error" uri) + :cause cause))) + {:keys [size mtype]} (parse-and-validate response) path (tmp/tempfile :prefix "penpot.media.download.") written (io/write* path body :size size)] diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index d583565f39..79df6d38b4 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -9,11 +9,14 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.db :as db] + [app.http.client :as http] + [app.media :as media] [app.rpc :as-alias rpc] [app.storage :as sto] [backend-tests.helpers :as th] [clojure.test :as t] - [datoteka.fs :as fs])) + [datoteka.fs :as fs] + [mockery.core :refer [with-mocks]])) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -278,3 +281,100 @@ error-data (ex-data error)] (t/is (th/ex-info? error)) (t/is (= (:type error-data) :not-found))))) + + +(t/deftest download-image-connection-error + (t/testing "connection refused raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.ConnectException. "Connection refused")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "connection timeout raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "request timeout raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.net.http.HttpTimeoutException. "Request timed out")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "I/O error raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :throw (java.io.IOException. "Stream closed")}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://unreachable.invalid/image.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err)))))))) + + +(t/deftest download-image-status-code-error + (t/testing "404 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 404 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/not-found.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "500 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 500 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/server-error.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err))))))) + + (t/testing "302 status raises validation error" + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 302 + :headers {"content-type" "text/html" + "content-length" "0"} + :body nil}}] + (let [cfg {::http/client :mock-client} + err (try + (media/download-image cfg "http://example.com/redirect.png") + nil + (catch clojure.lang.ExceptionInfo e e))] + (t/is (some? err)) + (t/is (= :validation (:type (ex-data err)))) + (t/is (= :unable-to-download-image (:code (ex-data err)))))))) From 3112b0d8cfcd4e4b97800bd09fe45375b6b44a70 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Tue, 10 Mar 2026 14:41:50 +0100 Subject: [PATCH 22/27] :bug: Fix grow options not verifying text-editor/v2 (#8571) --- .../main/ui/workspace/sidebar/options/menus/text.cljs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 3135a815bc..75c0344662 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 @@ -137,12 +137,13 @@ (fn [value] (on-blur) (let [uid (js/Symbol) - grow-type (keyword value) - content (when editor-instance - (content/dom->cljs (dwt/get-editor-root editor-instance)))] + grow-type (keyword value)] (st/emit! (dwu/start-undo-transaction uid)) - (when (some? content) - (st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))) + (when (features/active-feature? @st/state "text-editor/v2") + (let [content (when editor-instance + (content/dom->cljs (dwt/get-editor-root editor-instance)))] + (when (some? content) + (st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))))) (st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type))) (when (features/active-feature? @st/state "render-wasm/v1") From 9f66220caa8c16605828b445d642685e38a6fca2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 09:59:54 +0100 Subject: [PATCH 23/27] :bug: Fix flex layout container horizontalSizing/verticalSizing via plugin API (#8555) Setting horizontalSizing/verticalSizing on a FlexLayoutProxy was dispatching update-layout-child instead of update-layout, so the frame's auto-sizing (hug content) was never triggered even though the getter read back the value correctly. Also restricts accepted values to #{:fix :auto} (matching shape.cljs) since frames cannot use :fill, and fixes a copy-paste error that reported :horizontalPadding instead of :horizontalSizing in error messages. Signed-off-by: Andrey Antukh --- frontend/src/app/plugins/flex.cljs | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 9ae4525a5f..a1c7ef754c 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -265,7 +265,39 @@ (if (and (natural-child-ordering? plugin-id) (not (ctl/reverse? shape))) 0 (count (:shapes shape)))] - (st/emit! (dwsh/relocate-shapes #{child-id} id index))))))) + (st/emit! (dwsh/relocate-shapes #{child-id} id index))))) + + :horizontalSizing + {:this true + :get #(-> % u/proxy->shape :layout-item-h-sizing (d/nilv :fix) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-h-sizing-types value)) + (u/display-not-valid :horizontalSizing value) + + (not (r/check-permission plugin-id "content:write")) + (u/display-not-valid :horizontalSizing "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} + + :verticalSizing + {:this true + :get #(-> % u/proxy->shape :layout-item-v-sizing (d/nilv :fix) d/name) + :set + (fn [_ value] + (let [value (keyword value)] + (cond + (not (contains? ctl/item-v-sizing-types value)) + (u/display-not-valid :verticalSizing value) + + (not (r/check-permission plugin-id "content:write")) + (u/display-not-valid :verticalSizing "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) (defn layout-child-proxy? [p] (obj/type-of? p "LayoutChildProxy")) From 98c1503bca584df684fe21ddc86672b7c835caa7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 15:05:08 +0100 Subject: [PATCH 24/27] :rewind: Backport serveral plugin types documentation --- plugins/libs/plugin-types/index.d.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index a3a67f4550..75861797bf 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -243,11 +243,17 @@ export interface Board extends ShapeBase { /** * The horizontal sizing behavior of the board. + * It can be one of the following values: + * - 'fix': The containers has its own intrinsic fixed size. + * - 'auto': The container fits the content. */ horizontalSizing?: 'auto' | 'fix'; /** * The vertical sizing behavior of the board. + * It can be one of the following values: + * - 'fix': The containers has its own intrinsic fixed size. + * - 'auto': The container fits the content. */ verticalSizing?: 'auto' | 'fix'; @@ -738,19 +744,19 @@ export interface CommonLayout { /** * The `horizontalSizing` property specifies the horizontal sizing behavior of the container. * It can be one of the following values: - * - 'fit-content': The container fits the content. - * - 'fill': The container fills the available space. - * - 'auto': The container size is determined automatically. + * - 'fix': The containers has its own intrinsic fixed size. + * - 'fill': The container fills the available space. Only can be set if it's inside another layout. + * - 'auto': The container fits the content. */ - horizontalSizing: 'fit-content' | 'fill' | 'auto'; + horizontalSizing: 'fix' | 'fill' | 'auto'; /** * The `verticalSizing` property specifies the vertical sizing behavior of the container. * It can be one of the following values: - * - 'fit-content': The container fits the content. - * - 'fill': The container fills the available space. - * - 'auto': The container size is determined automatically. + * - 'fix': The containers has its own intrinsic fixed size. + * - 'fill': The container fills the available space. Only can be set if it's inside another layout. + * - 'auto': The container fits the content. */ - verticalSizing: 'fit-content' | 'fill' | 'auto'; + verticalSizing: 'fix' | 'fill' | 'auto'; /** * The `remove` method removes the layout. From 31d8b35a2c7dd583aa5820eb308a475c75ca90f0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 10 Mar 2026 18:50:33 +0100 Subject: [PATCH 25/27] :paperclip: Revert small changes related to browser pool on exporter --- exporter/src/app/browser.cljs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 0da27c2609..526ae77380 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -100,14 +100,12 @@ (def browser-pool-factory (letfn [(create [] - (-> (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} - browser (.launch pw/chromium opts) - id (swap! pool-browser-id inc)] - (l/info :origin "factory" :action "create" :browser-id id) - (unchecked-set browser "__id" id) - browser) - (p/catch (fn [cause] - (l/error :hint "Cannot launch the headless browser" :cause cause))))) + (p/let [opts #js {:args #js ["--allow-insecure-localhost" "--font-render-hinting=none"]} + browser (.launch pw/chromium opts) + id (swap! pool-browser-id inc)] + (l/info :origin "factory" :action "create" :browser-id id) + (unchecked-set browser "__id" id) + browser)) (destroy [obj] (let [id (unchecked-get obj "__id")] From e855907b05869a335fead414abd2f2de4daeb19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 10 Mar 2026 18:42:28 +0100 Subject: [PATCH 26/27] :wrench: Disable search indexing of plugin docs for non-production envs --- .github/workflows/plugins-deploy-api-doc.yml | 17 +++++++++++++++++ .github/workflows/plugins-deploy-styles-doc.yml | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index aaa1339c9e..1842a61b16 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -104,6 +104,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/doc" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 1e2b39e74d..f8e43899b8 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -102,6 +102,23 @@ jobs: run: | sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml + - name: Add noindex header and robots.txt files for non-production environments + if: ${{ steps.vars.outputs.gh_ref != 'main' }} + working-directory: plugins + shell: bash + run: | + ASSETS_DIR="dist/apps/example-styles" + + cat > "${ASSETS_DIR}/_headers" << 'EOF' + /* + X-Robots-Tag: noindex, nofollow + EOF + + cat > "${ASSETS_DIR}/robots.txt" << 'EOF' + User-agent: * + Disallow: / + EOF + - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 with: From 7ec9261475131a79b255d8a96b85e5649391d569 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 11 Mar 2026 15:24:40 +0100 Subject: [PATCH 27/27] :sparkles: Add improvements to AGENTS.md (#8586) --- .github/workflows/tests.yml | 21 +- .gitignore | 22 +- AGENTS.md | 352 +++++-- backend/package.json | 6 +- common/package.json | 9 +- common/pnpm-lock.yaml | 10 + common/scripts/test | 3 +- common/src/app/common/encoding_impl.js | 134 ++- .../src/app/common/svg/path/arc_to_bezier.js | 72 +- common/src/app/common/svg/path/parser.js | 863 ++++++++++-------- common/src/app/common/uuid_impl.js | 154 ++-- common/src/app/common/weak/impl_weak_map.js | 7 +- common/tests.edn | 6 +- docker/devenv/Dockerfile | 1 + exporter/package.json | 5 +- frontend/package.json | 9 +- library/package.json | 5 +- scripts/check-fmt | 13 + scripts/lint | 10 - 19 files changed, 1038 insertions(+), 664 deletions(-) create mode 100755 scripts/check-fmt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fa432e7d3..4ba57dde95 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,8 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:clj + pnpm run check-fmt:js pnpm run lint:clj - name: Lint Frontend @@ -42,6 +44,9 @@ jobs: corepack enable; corepack install; pnpm install; + pnpm run check-fmt:js + pnpm run check-fmt:clj + pnpm run check-fmt:scss pnpm run lint:clj pnpm run lint:js pnpm run lint:scss @@ -52,7 +57,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Exporter working-directory: ./exporter @@ -60,7 +66,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint - name: Lint Library working-directory: ./library @@ -68,7 +75,8 @@ jobs: corepack enable; corepack install; pnpm install; - pnpm run lint:clj + pnpm run check-fmt + pnpm run lint test-common: name: "Common Tests" @@ -79,12 +87,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Run tests on JVM - working-directory: ./common - run: | - clojure -M:dev:test - - - name: Run tests on NODE + - name: Run tests working-directory: ./common run: | ./scripts/test diff --git a/.gitignore b/.gitignore index 9958d90cb8..d0a13534b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,4 @@ .pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -.pnpm-store *-init.clj *.css.json *.jar @@ -20,8 +13,6 @@ .nyc_output .rebel_readline_history .repl -.shadow-cljs -.pnpm-store/ /*.jpg /*.md /*.png @@ -36,6 +27,7 @@ /playground/ /backend/*.md !/backend/AGENTS.md +/backend/.shadow-cljs /backend/*.sql /backend/*.txt /backend/assets/ @@ -48,13 +40,13 @@ /backend/experiments /backend/scripts/_env.local /bundle* -/cd.md /clj-profiler/ /common/coverage /common/target -/deploy +/common/.shadow-cljs /docker/images/bundle* /exporter/target +/exporter/.shadow-cljs /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ @@ -68,9 +60,9 @@ /frontend/storybook-static/ /frontend/target/ /frontend/test-results/ +/frontend/.shadow-cljs /other/ -/scripts/ -/telemetry/ +/nexus/ /tmp/ /vendor/**/target /vendor/svgclean/bundle*.js @@ -79,13 +71,11 @@ /library/*.zip /external /penpot-nitrate - -clj-profiler/ -node_modules /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /render-wasm/target/ +/**/node_modules /**/.yarn/* /.pnpm-store diff --git a/AGENTS.md b/AGENTS.md index d126301300..9505d47698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Penpot – Copilot Instructions +# Penpot – Instructions ## Architecture Overview @@ -18,7 +18,13 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh` orchestrates cross-component builds. `run-ci.sh` defines the CI pipeline. ---- +## Search Standards + +When searching code, always use `ripgrep` (rg) instead of grep if +available, as it respects `.gitignore` by default. + +If using grep, try to exclude node_modules and .shadow-cljs directories + ## Build, Test & Lint Commands @@ -28,27 +34,26 @@ Run `./scripts/setup` for setup all dependencies. ```bash -# Dev -pnpm run watch:app # Full dev build (WASM + CLJS + assets) - -# Production Build +# Build (Producution) ./scripts/build # Tests -pnpm run test # Build ClojureScript tests + run node target/tests/test.js -pnpm run watch:test # Watch + auto-rerun on change -pnpm run test:e2e # Playwright e2e tests -pnpm run test:e2e --grep "pattern" # Single e2e test by pattern +pnpm run test # Build ClojureScript tests + run node target/tests/test.js # Lint -pnpm run lint:js # format and linter check for JS -pnpm run lint:clj # format and linter check for CLJ -pnpm run lint:scss # prettier check for SCSS +pnpm run lint:js # Linter for JS/TS +pnpm run lint:clj # Linter for CLJ/CLJS/CLJC +pnpm run lint:scss # Linter for SCSS -# Code formatting -pnpm run fmt:clj # Format CLJ -pnpm run fmt:js # prettier for JS -pnpm run fmt:scss # prettier for SCSS +# Check Code Formart +pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC +pnpm run check-fmt:js # Format JS/TS +pnpm run check-fmt:scss # Format SCSS + +# Code Format (Automatic Formating) +pnpm run fmt:clj # Format CLJ/CLJS/CLJC +pnpm run fmt:js # Format JS/TS +pnpm run fmt:scss # Format SCSS ``` To run a focused ClojureScript unit test: edit @@ -58,28 +63,63 @@ run build:test && node target/tests/test.js`. ### Backend (`cd backend`) -```bash -# Tests (Kaocha) -clojure -M:dev:test # Full suite -clojure -M:dev:test --focus backend-tests.my-ns-test # Single namespace +Run `pnpm install` for install all dependencies. -# Lint / Format -pnpm run lint:clj -pnpm run fmt:clj +```bash +# Run full test suite +pnpm run test + +# Run single namespace +pnpm run test --focus backend-tests.rpc-doc-test + +# Check Code Format +pnpm run check-fmt + +# Code Format (Automatic Formatting) +pnpm run fmt + +# Code Linter +pnpm run lint ``` -Test config is in `backend/tests.edn`; test namespaces match `.*-test$` under `test/`. +Test config is in `backend/tests.edn`; test namespaces match +`.*-test$` under `test/` directory. You should not touch this file, +just use it for reference. ### Common (`cd common`) +This contains code that should compile and run under different runtimes: JVM & JS so the commands are +separarated for each runtime. + ```bash -pnpm run test # Build + run node target/tests/test.js -pnpm run watch:test # Watch mode -pnpm run lint:clj -pnpm run fmt:clj +clojure -M:dev:test # Run full test suite under JVM +clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM + +# Run full test suite under JS or JVM runtimes +pnpm run test:js +pnpm run test:jvm + +# Run single namespace (only on JVM) +pnpm run test:jvm --focus common-tests.my-ns-test + +# Lint +pnpm run lint:clj # Lint CLJ/CLJS/CLJC code + +# Check Format +pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run check-fmt:js # Check JS/TS code + +# Code Format (Automatic Formatting) +pnpm run fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run fmt:js # Check JS/TS code ``` +To run a focused ClojureScript unit test: edit +`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + ### Render-WASM (`cd render-wasm`) ```bash @@ -93,6 +133,10 @@ cargo fmt --check ### Namespace Structure +The backend, frontend and exporter are developed using clojure and +clojurescript and code is organized in namespaces. This is a general +overview of the available namespaces. + **Backend:** - `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) - `app.http.*` – HTTP routes and middleware @@ -109,14 +153,26 @@ cargo fmt --check - `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) **Common:** -- `app.common.types.*` – Shared data types for shapes, files, pages -- `app.common.schema` – Malli validation schemas -- `app.common.geom.*` – Geometry utilities +- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas +- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli +- `app.common.geom.*` – Geometry and shape transformation helpers +- `app.common.data` – Generic helpers used around all application +- `app.common.math` – Generic math helpers used around all aplication +- `app.common.json` – Generic JSON encoding/decoding helpers - `app.common.data.macros` – Performance macros used everywhere + ### Backend RPC Commands -All API calls go through a single RPC endpoint: `POST /api/rpc/command/`. +The PRC methods are implement in a some kind of multimethod structure using +`app.util.serivices` namespace. All RPC methods are collected under `app.rpc` +namespace and exposed under `/api/rpc/command/`. The RPC method +accepts POST and GET requests indistinctly and uses `Accept` header for +negotiate the response encoding (which can be transit, the defaut or plain +json). It also accepts transit (defaut) or json as input, which should be +indicated using `Content-Type` header. + +This is an example: ```clojure (sv/defmethod ::my-command @@ -129,12 +185,18 @@ All API calls go through a single RPC endpoint: `POST /api/rpc/command/ my-component* -props]`, where props should be a map literal or symbol pointing to -javascript props objects. The javascript props object can be created -manually `#js {:data-foo "bar"}` or using `mf/spread-object` helper -macro. +Example for `mf/with-memo` macro: ---- +```clj +;; Using functions +(mf/use-effect + (mf/deps team-id) + (fn [] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id))))) -## Commit Guidelines +;; The same effect but using mf/with-effect +(mf/with-effect [team-id] + (st/emit! (dd/initialize team-id)) + (fn [] + (st/emit! (dd/finalize team-id)))) +``` + +Example for `mf/with-memo` macro: + +``` +;; Using functions +(mf/use-memo + (mf/deps projects team-id) + (fn [] + (->> (vals projects) + (filterv #(= team-id (:team-id %)))))) + +;; Using the macro +(mf/with-memo [projects team-id] + (->> (vals projects) + (filterv #(= team-id (:team-id %))))) +``` + +Prefer using the macros for it syntax simplicity. + + +4. Component Usage (Hiccup Syntax) + +When invoking a component within Hiccup, always use the [:> component* props] +pattern. + +Requirements for props: + +- Must be a map literal or a symbol pointing to a JavaScript props object. +- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro. + +Examples: + +```clj +;; Using object literal (no need of #js because macro already interprets it) +[:> my-component* {:data-foo "bar"}] + +;; Using object literal (no need of #js because macro already interprets it) +(let [props #js {:data-foo "bar" + :className "myclass"}] + [:> my-component* props]) + +;; Using the spread helper +(let [props (mf/spread-object base-props {:extra "data"})] + [:> my-component* props]) +``` + +4. Checklist + +- [ ] Does the component name end with *? + + +## Commit Format Guidelines Format: ` ` @@ -263,3 +454,46 @@ applicable. | ⬇️ | `:arrow_down:` | Dependency downgrade | | 🔥 | `:fire:` | Remove files or code | | 🌐 | `:globe_with_meridians:` | Translations | + + +## SCSS Rules & Migration + +### General rules + +- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss + variables and get the already defined properties from `_sizes.scss`. The SCSS + variables are allowed and still used, just prefer properties if they are + already defined. +- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" + as *; padding: px2rem(23);`. +- Do **not** create new SCSS variables for one-off values. +- Use physical directions with logical ones to support RTL/LTR naturally. + - ❌ `margin-left`, `padding-right`, `left`, `right`. + - ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. +- Always use the `use-typography` mixin from `ds/typography.scss`. + - ✅ `@include t.use-typography("title-small");` +- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. +- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or + legacy color variables. +- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like + `@include flexCenter;`. Write standard CSS (flex/grid) instead. + +### Syntax & Structure + +- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, + try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as + *;` (Use `as *` to expose variables directly). +- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: + - ❌ `.card { .title { ... } }` + - ✅ `.card-title { ... }` +- Leverage component-level CSS variables for state changes (hover/focus) instead + of rewriting properties. + +### Checklist + +- [ ] No references to `common/refactor/` +- [ ] All `@import` converted to `@use` (only if refactoring) +- [ ] Physical properties (left/right) using logical properties (inline-start/end). +- [ ] Typography implemented via `use-typography()` mixin. +- [ ] Hardcoded pixel values wrapped in `px2rem()`. +- [ ] Selectors are flat (no deep nesting). diff --git a/backend/package.json b/backend/package.json index 63bf06eddf..8ad7cd3c1d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,9 @@ "ws": "^8.17.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "fmt:clj": "cljfmt fix --parallel=true src/ test/" + "lint": "clj-kondo --parallel --lint ../common/src src/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "test": "clojure -M:dev:test" } } diff --git a/common/package.json b/common/package.json index 09de4e95aa..ac874c2b45 100644 --- a/common/package.json +++ b/common/package.json @@ -13,6 +13,7 @@ "devDependencies": { "concurrently": "^9.1.2", "nodemon": "^3.1.10", + "prettier": "3.5.3", "source-map-support": "^0.5.21", "ws": "^8.18.2" }, @@ -20,11 +21,15 @@ "date-fns": "^4.1.0" }, "scripts": { - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel=true --lint src/", + "lint:clj": "clj-kondo --parallel=true --lint src/", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.js", "fmt:clj": "cljfmt fix --parallel=true src/ test/", + "fmt:js": "prettier -c src/**/*.js -w", "lint": "pnpm run lint:clj", "watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"", "build:test": "clojure -M:dev:shadow-cljs compile test", - "test": "pnpm run build:test && node target/tests/test.js" + "test:js": "pnpm run build:test && node target/tests/test.js", + "test:jvm": "clojure -M:dev:test" } } diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 8536654155..7f63f16b3c 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: nodemon: specifier: ^3.1.10 version: 3.1.11 + prettier: + specifier: 3.5.3 + version: 3.5.3 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -169,6 +172,11 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -405,6 +413,8 @@ snapshots: picomatch@2.3.1: {} + prettier@3.5.3: {} + pstree.remy@1.1.8: {} readdirp@3.6.0: diff --git a/common/scripts/test b/common/scripts/test index 6402c5afd1..b064f2b8d2 100755 --- a/common/scripts/test +++ b/common/scripts/test @@ -4,4 +4,5 @@ set -ex corepack enable; corepack install; pnpm install; -pnpm run test; +pnpm run test:js; +pnpm run test:jvm; diff --git a/common/src/app/common/encoding_impl.js b/common/src/app/common/encoding_impl.js index 9af7d0fd57..a08f51170c 100644 --- a/common/src/app/common/encoding_impl.js +++ b/common/src/app/common/encoding_impl.js @@ -10,7 +10,7 @@ goog.require("cljs.core"); goog.provide("app.common.encoding_impl"); -goog.scope(function() { +goog.scope(function () { const core = cljs.core; const global = goog.global; const self = app.common.encoding_impl; @@ -28,8 +28,10 @@ goog.scope(function() { // Accept UUID hex format input = input.replace(/-/g, ""); - if ((input.length % 2) !== 0) { - throw new RangeError("Expected string to be an even number of characters") + if (input.length % 2 !== 0) { + throw new RangeError( + "Expected string to be an even number of characters", + ); } const view = new Uint8Array(input.length / 2); @@ -44,7 +46,11 @@ goog.scope(function() { function bufferToHex(source, isUuid) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } @@ -56,22 +62,28 @@ goog.scope(function() { const spacer = isUuid ? "-" : ""; let i = 0; - return (hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + spacer + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]] + - hexMap[source[i++]]); + return ( + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + spacer + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + + hexMap[source[i++]] + ); } self.hexToBuffer = hexToBuffer; @@ -87,8 +99,10 @@ goog.scope(function() { // for base16 (hex), base32, or base64 encoding in a standards // compliant manner. - function getBaseCodec (ALPHABET) { - if (ALPHABET.length >= 255) { throw new TypeError("Alphabet too long"); } + function getBaseCodec(ALPHABET) { + if (ALPHABET.length >= 255) { + throw new TypeError("Alphabet too long"); + } let BASE_MAP = new Uint8Array(256); for (let j = 0; j < BASE_MAP.length; j++) { BASE_MAP[j] = 255; @@ -96,22 +110,32 @@ goog.scope(function() { for (let i = 0; i < ALPHABET.length; i++) { let x = ALPHABET.charAt(i); let xc = x.charCodeAt(0); - if (BASE_MAP[xc] !== 255) { throw new TypeError(x + " is ambiguous"); } + if (BASE_MAP[xc] !== 255) { + throw new TypeError(x + " is ambiguous"); + } BASE_MAP[xc] = i; } let BASE = ALPHABET.length; let LEADER = ALPHABET.charAt(0); let FACTOR = Math.log(BASE) / Math.log(256); // log(BASE) / log(256), rounded up let iFACTOR = Math.log(256) / Math.log(BASE); // log(256) / log(BASE), rounded up - function encode (source) { + function encode(source) { if (source instanceof Uint8Array) { } else if (ArrayBuffer.isView(source)) { - source = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + source = new Uint8Array( + source.buffer, + source.byteOffset, + source.byteLength, + ); } else if (Array.isArray(source)) { source = Uint8Array.from(source); } - if (!(source instanceof Uint8Array)) { throw new TypeError("Expected Uint8Array"); } - if (source.length === 0) { return ""; } + if (!(source instanceof Uint8Array)) { + throw new TypeError("Expected Uint8Array"); + } + if (source.length === 0) { + return ""; + } // Skip & count leading zeroes. let zeroes = 0; let length = 0; @@ -129,12 +153,18 @@ goog.scope(function() { let carry = source[pbegin]; // Apply "b58 = b58 * 256 + ch". let i = 0; - for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) { + for ( + let it1 = size - 1; + (carry !== 0 || i < length) && it1 !== -1; + it1--, i++ + ) { carry += (256 * b58[it1]) >>> 0; - b58[it1] = (carry % BASE) >>> 0; + b58[it1] = carry % BASE >>> 0; carry = (carry / BASE) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; pbegin++; } @@ -145,13 +175,19 @@ goog.scope(function() { } // Translate the result into a string. let str = LEADER.repeat(zeroes); - for (; it2 < size; ++it2) { str += ALPHABET.charAt(b58[it2]); } + for (; it2 < size; ++it2) { + str += ALPHABET.charAt(b58[it2]); + } return str; } - function decodeUnsafe (source) { - if (typeof source !== "string") { throw new TypeError("Expected String"); } - if (source.length === 0) { return new Uint8Array(); } + function decodeUnsafe(source) { + if (typeof source !== "string") { + throw new TypeError("Expected String"); + } + if (source.length === 0) { + return new Uint8Array(); + } let psz = 0; // Skip and count leading '1's. let zeroes = 0; @@ -161,21 +197,29 @@ goog.scope(function() { psz++; } // Allocate enough space in big-endian base256 representation. - let size = (((source.length - psz) * FACTOR) + 1) >>> 0; // log(58) / log(256), rounded up. + let size = ((source.length - psz) * FACTOR + 1) >>> 0; // log(58) / log(256), rounded up. let b256 = new Uint8Array(size); // Process the characters. while (source[psz]) { // Decode character let carry = BASE_MAP[source.charCodeAt(psz)]; // Invalid character - if (carry === 255) { return; } + if (carry === 255) { + return; + } let i = 0; - for (let it3 = size - 1; (carry !== 0 || i < length) && (it3 !== -1); it3--, i++) { + for ( + let it3 = size - 1; + (carry !== 0 || i < length) && it3 !== -1; + it3--, i++ + ) { carry += (BASE * b256[it3]) >>> 0; - b256[it3] = (carry % 256) >>> 0; + b256[it3] = carry % 256 >>> 0; carry = (carry / 256) >>> 0; } - if (carry !== 0) { throw new Error("Non-zero carry"); } + if (carry !== 0) { + throw new Error("Non-zero carry"); + } length = i; psz++; } @@ -192,20 +236,22 @@ goog.scope(function() { return vch; } - function decode (string) { + function decode(string) { let buffer = decodeUnsafe(string); - if (buffer) { return buffer; } + if (buffer) { + return buffer; + } throw new Error("Non-base" + BASE + " character"); } return { encode: encode, decodeUnsafe: decodeUnsafe, - decode: decode + decode: decode, }; } // MORE bases here: https://github.com/cryptocoinjs/base-x/tree/master - const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const BASE62 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; self.bufferToBase62 = getBaseCodec(BASE62).encode; - }); 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 39dc8d447f..b4220a7d95 100644 --- a/common/src/app/common/svg/path/arc_to_bezier.js +++ b/common/src/app/common/svg/path/arc_to_bezier.js @@ -14,7 +14,7 @@ goog.provide("app.common.svg.path.arc_to_bezier"); // https://raw.githubusercontent.com/fontello/svgpath/master/lib/a2c.js -goog.scope(function() { +goog.scope(function () { const self = app.common.svg.path.arc_to_bezier; var TAU = Math.PI * 2; @@ -27,20 +27,23 @@ goog.scope(function() { // we can use simplified math (without length normalization) // function unit_vector_angle(ux, uy, vx, vy) { - var sign = (ux * vy - uy * vx < 0) ? -1 : 1; - var dot = ux * vx + uy * vy; + var sign = ux * vy - uy * vx < 0 ? -1 : 1; + var dot = ux * vx + uy * vy; // Add this to work with arbitrary vectors: // dot /= Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); // rounding errors, e.g. -1.0000000000000002 can screw up this - if (dot > 1.0) { dot = 1.0; } - if (dot < -1.0) { dot = -1.0; } + if (dot > 1.0) { + dot = 1.0; + } + if (dot < -1.0) { + dot = -1.0; + } return sign * Math.acos(dot); } - // Convert from endpoint to center parameterization, // see http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes // @@ -53,11 +56,11 @@ goog.scope(function() { // points. After that, rotate it to line up ellipse axes with coordinate // axes. // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; - var rx_sq = rx * rx; - var ry_sq = ry * ry; + var rx_sq = rx * rx; + var ry_sq = ry * ry; var x1p_sq = x1p * x1p; var y1p_sq = y1p * y1p; @@ -66,33 +69,33 @@ goog.scope(function() { // Compute coordinates of the centre of this ellipse (cx', cy') // in the new coordinate system. // - var radicant = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq); + var radicant = rx_sq * ry_sq - rx_sq * y1p_sq - ry_sq * x1p_sq; if (radicant < 0) { // due to rounding errors it might be e.g. -1.3877787807814457e-17 radicant = 0; } - radicant /= (rx_sq * y1p_sq) + (ry_sq * x1p_sq); + radicant /= rx_sq * y1p_sq + ry_sq * x1p_sq; radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); - var cxp = radicant * rx/ry * y1p; - var cyp = radicant * -ry/rx * x1p; + var cxp = ((radicant * rx) / ry) * y1p; + var cyp = ((radicant * -ry) / rx) * x1p; // Step 3. // // Transform back to get centre coordinates (cx, cy) in the original // coordinate system. // - var cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2; - var cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2; + var cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2; + var cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2; // Step 4. // // Compute angles (theta1, delta_theta). // - var v1x = (x1p - cxp) / rx; - var v1y = (y1p - cyp) / ry; + var v1x = (x1p - cxp) / rx; + var v1y = (y1p - cyp) / ry; var v2x = (-x1p - cxp) / rx; var v2y = (-y1p - cyp) / ry; @@ -106,7 +109,7 @@ goog.scope(function() { delta_theta += TAU; } - return [ cx, cy, theta1, delta_theta ]; + return [cx, cy, theta1, delta_theta]; } // @@ -114,24 +117,33 @@ goog.scope(function() { // see http://math.stackexchange.com/questions/873224 // function approximate_unit_arc(theta1, delta_theta) { - var alpha = 4/3 * Math.tan(delta_theta/4); + var alpha = (4 / 3) * Math.tan(delta_theta / 4); var x1 = Math.cos(theta1); var y1 = Math.sin(theta1); var x2 = Math.cos(theta1 + delta_theta); var y2 = Math.sin(theta1 + delta_theta); - return [ x1, y1, x1 - y1*alpha, y1 + x1*alpha, x2 + y2*alpha, y2 - x2*alpha, x2, y2 ]; + return [ + x1, + y1, + x1 - y1 * alpha, + y1 + x1 * alpha, + x2 + y2 * alpha, + y2 - x2 * alpha, + x2, + y2, + ]; } function calculate_beziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { - var sin_phi = Math.sin(phi * TAU / 360); - var cos_phi = Math.cos(phi * TAU / 360); + var sin_phi = Math.sin((phi * TAU) / 360); + var cos_phi = Math.cos((phi * TAU) / 360); // Make sure radii are valid // - var x1p = cos_phi*(x1-x2)/2 + sin_phi*(y1-y2)/2; - var y1p = -sin_phi*(x1-x2)/2 + cos_phi*(y1-y2)/2; + var x1p = (cos_phi * (x1 - x2)) / 2 + (sin_phi * (y1 - y2)) / 2; + var y1p = (-sin_phi * (x1 - x2)) / 2 + (cos_phi * (y1 - y2)) / 2; // console.log("L", x1p, y1p) @@ -145,7 +157,6 @@ goog.scope(function() { return []; } - // Compensate out-of-range radii // rx = Math.abs(rx); @@ -157,25 +168,20 @@ goog.scope(function() { ry *= Math.sqrt(lambda); } - // Get center parameters (cx, cy, theta1, delta_theta) // var cc = get_arc_center(x1, y1, x2, y2, fa, fs, rx, ry, sin_phi, cos_phi); - var result = []; var theta1 = cc[2]; var delta_theta = cc[3]; - - // Split an arc to multiple segments, so each segment // will be less than τ/4 (= 90°) // var segments = Math.max(Math.ceil(Math.abs(delta_theta) / (TAU / 4)), 1); delta_theta /= segments; - for (var i = 0; i < segments; i++) { var item = approximate_unit_arc(theta1, delta_theta); result.push(item); @@ -195,8 +201,8 @@ goog.scope(function() { y *= ry; // rotate - var xp = cos_phi*x - sin_phi*y; - var yp = sin_phi*x + cos_phi*y; + var xp = cos_phi * x - sin_phi * y; + var yp = sin_phi * x + cos_phi * y; // translate curve[i + 0] = xp + cc[0]; diff --git a/common/src/app/common/svg/path/parser.js b/common/src/app/common/svg/path/parser.js index 5bbcddd3a0..f427874528 100644 --- a/common/src/app/common/svg/path/parser.js +++ b/common/src/app/common/svg/path/parser.js @@ -31,74 +31,81 @@ class Segment { toPersistentMap() { const fromArray = (data) => { return cljs.PersistentArrayMap.fromArray(data); - } + }; let command, params; - switch(this.command) { - case "M": - command = MOVE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + switch (this.command) { + case "M": + command = MOVE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "Z": - command = CLOSE_PATH; - params = cljs.PersistentArrayMap.EMPTY; - break; + case "Z": + command = CLOSE_PATH; + params = cljs.PersistentArrayMap.EMPTY; + break; - case "L": - command = LINE_TO; - params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); - break; + case "L": + command = LINE_TO; + params = fromArray([K_X, this.params[0], K_Y, this.params[1]]); + break; - case "C": - command = CURVE_TO; - params = fromArray([K_C1X, this.params[0], - K_C1Y, this.params[1], - K_C2X, this.params[2], - K_C2Y, this.params[3], - K_X, this.params[4], - K_Y, this.params[5]]); - break; - default: - command = null - params = null; + case "C": + command = CURVE_TO; + params = fromArray([ + K_C1X, + this.params[0], + K_C1Y, + this.params[1], + K_C2X, + this.params[2], + K_C2Y, + this.params[3], + K_X, + this.params[4], + K_Y, + this.params[5], + ]); + break; + default: + command = null; + params = null; } if (command === null || params === null) { throw new Error("invalid segment"); } - return fromArray([K_COMMAND, command, - K_PARAMS, params]) + return fromArray([K_COMMAND, command, K_PARAMS, params]); } } function validCommand(c) { switch (c) { - case "Z": - case "M": - case "L": - case "C": - case "Q": - case "A": - case "H": - case "V": - case "S": - case "T": - case "z": - case "m": - case "l": - case "c": - case "q": - case "a": - case "h": - case "v": - case "s": - case "t": - return true; - default: - return false; + case "Z": + case "M": + case "L": + case "C": + case "Q": + case "A": + case "H": + case "V": + case "S": + case "T": + case "z": + case "m": + case "l": + case "c": + case "q": + case "a": + case "h": + case "v": + case "s": + case "t": + return true; + default: + return false; } } @@ -118,11 +125,11 @@ class Parser { next() { const done = !this.hasNext(); if (done) { - return {done: true}; + return { done: true }; } else { return { done: false, - value: this.parseSegment() + value: this.parseSegment(), }; } } @@ -130,8 +137,10 @@ class Parser { hasNext() { if (this._currentIndex === 0) { const command = this._peekSegmentCommand(); - return ((this._currentIndex < this._endIndex) && - (command === "M" || command === "m")); + return ( + this._currentIndex < this._endIndex && + (command === "M" || command === "m") + ); } else { return this._currentIndex < this._endIndex; } @@ -148,7 +157,10 @@ class Parser { } // Check for remaining coordinates in the current command. - if ((ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && this._prevCommand !== "Z") { + if ( + (ch === "+" || ch === "-" || ch === "." || (ch >= "0" && ch <= "9")) && + this._prevCommand !== "Z" + ) { if (this._prevCommand === "M") { command = "L"; } else if (this._prevCommand === "m") { @@ -177,7 +189,12 @@ class Parser { } else if (cmd === "M" || cmd === "L" || cmd === "T") { params = [this._parseNumber(), this._parseNumber()]; } else if (cmd === "S" || cmd === "Q") { - params = [this._parseNumber(), this._parseNumber(), this._parseNumber(), this._parseNumber()]; + params = [ + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + this._parseNumber(), + ]; } else if (cmd === "C") { params = [ this._parseNumber(), @@ -185,7 +202,7 @@ class Parser { this._parseNumber(), this._parseNumber(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "A") { params = [ @@ -195,7 +212,7 @@ class Parser { this._parseArcFlag(), this._parseArcFlag(), this._parseNumber(), - this._parseNumber() + this._parseNumber(), ]; } else if (cmd === "Z") { this._skipOptionalSpaces(); @@ -217,7 +234,10 @@ class Parser { _isCurrentSpace() { var ch = this._string[this._currentIndex]; - return ch <= " " && (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"); + return ( + ch <= " " && + (ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f") + ); } _skipOptionalSpaces() { @@ -228,14 +248,19 @@ class Parser { } _skipOptionalSpacesOrDelimiter() { - if (this._currentIndex < this._endIndex && - !this._isCurrentSpace() && - this._string[this._currentIndex] !== ",") { + if ( + this._currentIndex < this._endIndex && + !this._isCurrentSpace() && + this._string[this._currentIndex] !== "," + ) { return false; } if (this._skipOptionalSpaces()) { - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ",") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "," + ) { this._currentIndex += 1; this._skipOptionalSpaces(); } @@ -258,16 +283,25 @@ class Parser { this._skipOptionalSpaces(); // Read the sign. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "+") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "+" + ) { this._currentIndex += 1; - } else if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === "-") { + } else if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "-" + ) { this._currentIndex += 1; sign = -1; } - if (this._currentIndex === this._endIndex || - ((this._string[this._currentIndex] < "0" || this._string[this._currentIndex] > "9") && - this._string[this._currentIndex] !== ".")) { + if ( + this._currentIndex === this._endIndex || + ((this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9") && + this._string[this._currentIndex] !== ".") + ) { // The first chacter of a number must be one of [0-9+-.]. return null; } @@ -275,9 +309,11 @@ class Parser { // Read the integer part, build right-to-left. var startIntPartIndex = this._currentIndex; - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { this._currentIndex += 1; // Advance to first non-digit. } @@ -293,19 +329,26 @@ class Parser { } // Read the decimals. - if (this._currentIndex < this._endIndex && this._string[this._currentIndex] === ".") { + if ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] === "." + ) { this._currentIndex += 1; // There must be a least one digit following the . - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { frac *= 10; decimal += (this._string[this._currentIndex] - "0") / frac; this._currentIndex += 1; @@ -313,10 +356,14 @@ class Parser { } // Read the exponent part. - if (this._currentIndex !== startIndex && - this._currentIndex + 1 < this._endIndex && - (this._string[this._currentIndex] === "e" || this._string[this._currentIndex] === "E") && - (this._string[this._currentIndex + 1] !== "x" && this._string[this._currentIndex + 1] !== "m")) { + if ( + this._currentIndex !== startIndex && + this._currentIndex + 1 < this._endIndex && + (this._string[this._currentIndex] === "e" || + this._string[this._currentIndex] === "E") && + this._string[this._currentIndex + 1] !== "x" && + this._string[this._currentIndex + 1] !== "m" + ) { this._currentIndex += 1; // Read the sign of the exponent. @@ -328,17 +375,21 @@ class Parser { } // There must be an exponent. - if (this._currentIndex >= this._endIndex || - this._string[this._currentIndex] < "0" || - this._string[this._currentIndex] > "9") { + if ( + this._currentIndex >= this._endIndex || + this._string[this._currentIndex] < "0" || + this._string[this._currentIndex] > "9" + ) { return null; } - while (this._currentIndex < this._endIndex && - this._string[this._currentIndex] >= "0" && - this._string[this._currentIndex] <= "9") { + while ( + this._currentIndex < this._endIndex && + this._string[this._currentIndex] >= "0" && + this._string[this._currentIndex] <= "9" + ) { exponent *= 10; - exponent += (this._string[this._currentIndex] - "0"); + exponent += this._string[this._currentIndex] - "0"; this._currentIndex += 1; } } @@ -380,7 +431,7 @@ class Parser { this._skipOptionalSpacesOrDelimiter(); return flag; } -}; +} function absolutizePathData(pdata) { var currentX = null; @@ -389,212 +440,210 @@ function absolutizePathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i 1.0) ? 1.0 : (dot < -1.0) ? -1.0 : dot; + dot = dot > 1.0 ? 1.0 : dot < -1.0 ? -1.0 : dot; return sign * Math.acos(dot); } function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { - let x1p = (cosPhi * ((x1 - x2) / 2)) + (sinPhi * ((y1 - y2) / 2)); - let y1p = (-sinPhi * ((x1 - x2) / 2)) + (cosPhi * ((y1 - y2) / 2)); + let x1p = cosPhi * ((x1 - x2) / 2) + sinPhi * ((y1 - y2) / 2); + let y1p = -sinPhi * ((x1 - x2) / 2) + cosPhi * ((y1 - y2) / 2); let rxSq = rx * rx; let rySq = ry * ry; @@ -602,9 +651,9 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let y1pSq = y1p * y1p; let radicant = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq; - radicant = (radicant < 0) ? 0 : radicant; - radicant /= (rxSq * y1pSq + rySq * x1pSq); - radicant = (Math.sqrt(radicant) * ((fa === fs) ? -1 : 1)) + radicant = radicant < 0 ? 0 : radicant; + radicant /= rxSq * y1pSq + rySq * x1pSq; + radicant = Math.sqrt(radicant) * (fa === fs ? -1 : 1); let cxp = radicant * (rx / ry) * y1p; let cyp = radicant * (-ry / rx) * x1p; @@ -618,8 +667,8 @@ function getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi) { let theta1 = unitVectorAngle(1, 0, v1x, v1y); let dtheta = unitVectorAngle(v1x, v1y, v2x, v2y); - dtheta = (fs === 0 && dtheta > 0) ? dtheta - Math.PI * 2 : dtheta; - dtheta = (fs === 1 && dtheta < 0) ? dtheta + Math.PI * 2 : dtheta; + dtheta = fs === 0 && dtheta > 0 ? dtheta - Math.PI * 2 : dtheta; + dtheta = fs === 1 && dtheta < 0 ? dtheta + Math.PI * 2 : dtheta; return [cx, cy, theta1, dtheta]; } @@ -639,7 +688,7 @@ function approximateUnitArc(theta1, dtheta) { x2 + y2 * alpha, y2 - x2 * alpha, x2, - y2 + y2, ]; } @@ -674,7 +723,7 @@ function processCurve(curve, cx, cy, rx, ry, sinPhi, cosPhi) { export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { const tau = Math.PI * 2; - const phiTau = phi * tau / 360; + const phiTau = (phi * tau) / 360; const sinPhi = Math.sin(phiTau); const cosPhi = Math.cos(phiTau); @@ -688,7 +737,7 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { } if (rx === 0 || ry === 0) { - // one of the radii is zero + // one of the radii is zero return []; } @@ -696,8 +745,8 @@ export function arcToBeziers(x1, y1, x2, y2, fa, fs, rx, ry, phi) { ry = Math.abs(ry); let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); - rx = (lambda > 1) ? rx * Math.sqrt(lambda) : rx; - ry = (lambda > 1) ? ry * Math.sqrt(lambda) : ry; + rx = lambda > 1 ? rx * Math.sqrt(lambda) : rx; + ry = lambda > 1 ? ry * Math.sqrt(lambda) : ry; const cc = getArcCenter(x1, y1, x2, y2, fa, fs, rx, ry, sinPhi, cosPhi); const cx = cc[0]; @@ -736,175 +785,183 @@ function simplifyPathData(pdata) { var subpathX = null; var subpathY = null; - for (let i=0; i { - if (typeof global.crypto !== "undefined" && - typeof global.crypto.getRandomValues !== "undefined") { + if ( + typeof global.crypto !== "undefined" && + typeof global.crypto.getRandomValues !== "undefined" + ) { return (buf) => { global.crypto.getRandomValues(buf); return buf; @@ -30,7 +32,7 @@ goog.scope(function() { return (buf) => { const bytes = randomBytes(buf.length); - buf.set(bytes) + buf.set(bytes); return buf; }; } else { @@ -39,8 +41,10 @@ goog.scope(function() { return (buf) => { for (let i = 0, r; i < buf.length; i++) { - if ((i & 0x03) === 0) { r = Math.random() * 0x100000000; } - buf[i] = r >>> ((i & 0x03) << 3) & 0xff; + if ((i & 0x03) === 0) { + r = Math.random() * 0x100000000; + } + buf[i] = (r >>> ((i & 0x03) << 3)) & 0xff; } return buf; }; @@ -50,31 +54,38 @@ goog.scope(function() { function toHexString(buf) { const hexMap = encoding.hexMap; let i = 0; - return (hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + '-' + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]] + - hexMap[buf[i++]]); - }; + return ( + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + "-" + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + + hexMap[buf[i++]] + ); + } function getBigUint64(view, byteOffset, le) { const a = view.getUint32(byteOffset, le); const b = view.getUint32(byteOffset + 4, le); const leMask = Number(!!le); const beMask = Number(!le); - return ((BigInt(a * beMask + b * leMask) << 32n) | - (BigInt(a * leMask + b * beMask))); + return ( + (BigInt(a * beMask + b * leMask) << 32n) | BigInt(a * leMask + b * beMask) + ); } function setBigUint64(view, byteOffset, value, le) { @@ -83,8 +94,7 @@ goog.scope(function() { if (le) { view.setUint32(byteOffset + 4, hi, le); view.setUint32(byteOffset, lo, le); - } - else { + } else { view.setUint32(byteOffset, hi, le); view.setUint32(byteOffset + 4, lo, le); } @@ -104,17 +114,18 @@ goog.scope(function() { } self.shortID = (function () { - const buff = new ArrayBuffer(8); + const buff = new ArrayBuffer(8); const int8 = new Uint8Array(buff); - const view = new DataView(buff); + const view = new DataView(buff); const base = 0x0000_0000_0000_0000n; return function shortID(ts) { const tss = currentTimestamp(timeRef); - const msb = (base - | (nextLong() & 0xffff_ffff_0000_0000n) - | (tss & 0x0000_0000_ffff_ffffn)); + const msb = + base | + (nextLong() & 0xffff_ffff_0000_0000n) | + (tss & 0x0000_0000_ffff_ffffn); setBigUint64(view, 0, msb, false); return encoding.toBase62(int8); }; @@ -139,9 +150,9 @@ goog.scope(function() { const maxCs = 0x0000_0000_0000_3fffn; // 14 bits space let countCs = 0n; - let lastRd = 0n; - let lastCs = 0n; - let lastTs = 0n; + let lastRd = 0n; + let lastCs = 0n; + let lastTs = 0n; let baseMsb = 0x0000_0000_0000_8000n; let baseLsb = 0x8000_0000_0000_0000n; @@ -149,12 +160,9 @@ goog.scope(function() { lastCs = nextLong() & maxCs; const create = function create(ts, lastRd, lastCs) { - const msb = (baseMsb - | (lastRd & 0xffff_ffff_ffff_0fffn)); + const msb = baseMsb | (lastRd & 0xffff_ffff_ffff_0fffn); - const lsb = (baseLsb - | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) - | lastCs); + const lsb = baseLsb | ((ts << 14n) & 0x3fff_ffff_ffff_c000n) | lastCs; setBigUint64(view, 0, msb, false); setBigUint64(view, 8, lsb, false); @@ -167,10 +175,10 @@ goog.scope(function() { let ts = currentTimestamp(timeRef); // Protect from clock regression - if ((ts - lastTs) < 0) { - lastRd = (lastRd - & 0x0000_0000_0000_0f00n - | (nextLong() & 0xffff_ffff_ffff_f0ffn)); + if (ts - lastTs < 0) { + lastRd = + (lastRd & 0x0000_0000_0000_0f00n) | + (nextLong() & 0xffff_ffff_ffff_f0ffn); countCs = 0n; continue; } @@ -209,63 +217,63 @@ goog.scope(function() { // Parse ........-....-....-####-............ int8[8] = (rest = parseInt(uuid.slice(19, 23), 16)) >>> 8; - int8[9] = rest & 0xff, - - // Parse ........-....-....-....-############ - // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) - int8[10] = ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + (int8[9] = rest & 0xff), + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + (int8[10] = + ((rest = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff); int8[11] = (rest / 0x100000000) & 0xff; int8[12] = (rest >>> 24) & 0xff; int8[13] = (rest >>> 16) & 0xff; int8[14] = (rest >>> 8) & 0xff; int8[15] = rest & 0xff; - } + }; const fromPair = (hi, lo) => { view.setBigInt64(0, hi); view.setBigInt64(8, lo); return encoding.bufferToHex(int8, true); - } + }; const getHi = (uuid) => { fillBytes(uuid); return view.getBigInt64(0); - } + }; const getLo = (uuid) => { fillBytes(uuid); return view.getBigInt64(8); - } + }; const getBytes = (uuid) => { fillBytes(uuid); return Int8Array.from(int8); - } + }; const getUnsignedParts = (uuid) => { fillBytes(uuid); const result = new Uint32Array(4); - result[0] = view.getUint32(0) + result[0] = view.getUint32(0); result[1] = view.getUint32(4); result[2] = view.getUint32(8); result[3] = view.getUint32(12); return result; - } + }; const fromUnsignedParts = (a, b, c, d) => { - view.setUint32(0, a) - view.setUint32(4, b) - view.setUint32(8, c) - view.setUint32(12, d) + view.setUint32(0, a); + view.setUint32(4, b); + view.setUint32(8, c); + view.setUint32(12, d); return encoding.bufferToHex(int8, true); - } + }; const fromArray = (u8data) => { int8.set(u8data); return encoding.bufferToHex(int8, true); - } + }; const setTag = (tag) => { tag = BigInt.asUintN(64, "" + tag); @@ -273,9 +281,9 @@ goog.scope(function() { throw new Error("illegal arguments: tag value should fit in 4bits"); } - lastRd = (lastRd - & 0xffff_ffff_ffff_f0ffn - | ((tag << 8) & 0x0000_0000_0000_0f00n)); + lastRd = + (lastRd & 0xffff_ffff_ffff_f0ffn) | + ((tag << 8) & 0x0000_0000_0000_0f00n); }; factory.create = create; @@ -290,9 +298,9 @@ goog.scope(function() { return factory; })(); - self.shortV8 = function(uuid) { + self.shortV8 = function (uuid) { const buff = encoding.hexToBuffer(uuid); - const short = new Uint8Array(buff, 4); + const short = new Uint8Array(buff, 4); return encoding.bufferToBase62(short); }; @@ -307,7 +315,7 @@ goog.scope(function() { return self.v8.fromPair(hi, lo); }; - self.fromBytes = function(data) { + self.fromBytes = function (data) { if (data instanceof Uint8Array) { return self.v8.fromArray(data); } else if (data instanceof Int8Array) { @@ -325,15 +333,15 @@ goog.scope(function() { return self.v8.getUnsignedParts(uuid); }; - self.fromUnsignedParts = function(a,b,c,d) { - return self.v8.fromUnsignedParts(a,b,c,d); + self.fromUnsignedParts = function (a, b, c, d) { + return self.v8.fromUnsignedParts(a, b, c, d); }; self.getHi = function (uuid) { return self.v8.getHi(uuid); - } + }; self.getLo = function (uuid) { return self.v8.getLo(uuid); - } + }; }); diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js index 2379ea7e14..2c6fa8db53 100644 --- a/common/src/app/common/weak/impl_weak_map.js +++ b/common/src/app/common/weak/impl_weak_map.js @@ -67,8 +67,11 @@ export class WeakEqMap { } set(key, value) { - if (key === null || (typeof key !== 'object' && typeof key !== 'function')) { - throw new TypeError('WeakEqMap keys must be objects (like WeakMap).'); + if ( + key === null || + (typeof key !== "object" && typeof key !== "function") + ) { + throw new TypeError("WeakEqMap keys must be objects (like WeakMap)."); } const hash = this._hash(key); const bucket = this._getBucket(hash); diff --git a/common/tests.edn b/common/tests.edn index 9f487a7eaf..0a0582fed6 100644 --- a/common/tests.edn +++ b/common/tests.edn @@ -1,4 +1,4 @@ #kaocha/v1 - {:tests [{:id :unit - :test-paths ["test"]}] - :kaocha/reporter [kaocha.report/dots]} +{:tests [{:id :unit + :test-paths ["test"]}] + :kaocha/reporter [kaocha.report/dots]} diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index efa134d999..07fbab0bb4 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -18,6 +18,7 @@ RUN set -ex; \ curl \ bash \ git \ + ripgrep \ \ curl \ ca-certificates \ diff --git a/exporter/package.json b/exporter/package.json index 70b64bea7d..ba6570f3cc 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -34,7 +34,8 @@ "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:clj": "cljfmt fix --parallel=true src/", - "lint:clj": "cljfmt check --parallel src/ && clj-kondo --parallel --lint src/" + "fmt": "cljfmt fix --parallel=true src/", + "check-fmt": "cljfmt check --parallel=true src/", + "lint": "clj-kondo --parallel --lint src/" } } diff --git a/frontend/package.json b/frontend/package.json index f1fb0b3feb..b2a1c7da1d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,15 @@ "build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app": "pnpm run clear:shadow-cache && pnpm run build:app:main && pnpm run build:app:libs", + "check-fmt:clj": "cljfmt check --parallel=true src/ test/", + "check-fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js", + "check-fmt:scss": "prettier -c resources/styles -c src/**/*.scss", "fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w", "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", - "lint:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js text-editor/**/*.js", - "lint:scss": "prettier -c resources/styles -c src/**/*.scss", + "lint:clj": "clj-kondo --parallel --lint ../common/src src/", + "lint:js": "exit 0", + "lint:scss": "exit 0", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", diff --git a/library/package.json b/library/package.json index c3f3d1c32a..f5dff418e8 100644 --- a/library/package.json +++ b/library/package.json @@ -26,8 +26,9 @@ "clear:shadow-cache": "rm -rf .shadow-cljs", "build": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs release library", "build:bundle": "./scripts/build", - "fmt:clj": "cljfmt fix --parallel=true src/ test/", - "lint:clj": "cljfmt check --parallel=false src/ test/ && clj-kondo --parallel --lint src/", + "fmt": "cljfmt fix --parallel=true src/ test/", + "check-fmt": "cljfmt check --parallel=true src/ test/", + "lint": "clj-kondo --parallel --lint src/", "test": "node --test", "watch:test": "node --test --watch", "watch": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch library" diff --git a/scripts/check-fmt b/scripts/check-fmt new file mode 100755 index 0000000000..ce5c635630 --- /dev/null +++ b/scripts/check-fmt @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -ex + +cljfmt --parallel=true check \ + common/src/ \ + common/test/ \ + frontend/src/ \ + frontend/test/ \ + backend/src/ \ + backend/test/ \ + exporter/src/ \ + library/src; diff --git a/scripts/lint b/scripts/lint index d17e6d3c86..4ab59aed13 100755 --- a/scripts/lint +++ b/scripts/lint @@ -2,16 +2,6 @@ set -ex -cljfmt check --parallel=true \ - common/src/ \ - common/test/ \ - frontend/src/ \ - frontend/test/ \ - backend/src/ \ - backend/test/ \ - exporter/src/ \ - library/src; - clj-kondo --parallel=true --lint common/src; clj-kondo --parallel=true --lint frontend/src; clj-kondo --parallel=true --lint backend/src;