diff --git a/.gitignore b/.gitignore index 1c277c16b3..cc695a46e3 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ opencode.json /.idea *.iml /.claude +/CLAUDE.md /.playwright-mcp /.devenv/mcp/ /opencode.json diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 533fde904e..89678417d0 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -166,7 +166,6 @@ (not (ctob/token-name-path-exists? token-name tokens-tree))) new-tokens))))]]) (defn find-refs [value] - (prn value) (cond (string? value) (cto/find-token-value-references value) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index b8edda5239..a39bae29b0 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}" restart: always ports: - 9001:8080 @@ -111,7 +111,7 @@ services: # PENPOT_DISABLE_IPV6_LISTEN: "true" penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-2.16}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.17}" restart: always volumes: @@ -180,13 +180,13 @@ services: PENPOT_SMTP_SSL: "false" penpot-mcp: - image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}" + image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}" restart: always networks: - penpot penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}" restart: always depends_on: diff --git a/docs/mcp/index.md b/docs/mcp/index.md index 8509bb7574..3f11995d2b 100644 --- a/docs/mcp/index.md +++ b/docs/mcp/index.md @@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca title="Quick demo: Penpot MCP server in action" width="100%" height="480" - src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0" + src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" diff --git a/docs/user-guide/first-steps/index.njk b/docs/user-guide/first-steps/index.njk index c26777b24f..e707f6d46e 100644 --- a/docs/user-guide/first-steps/index.njk +++ b/docs/user-guide/first-steps/index.njk @@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial

Useful resources to better understand Penpot

+
  • + +

    Migration Guide →

    +

    Move a design system from Figma to Penpot

    +
    +
  • diff --git a/docs/user-guide/first-steps/migration-guide.njk b/docs/user-guide/first-steps/migration-guide.njk new file mode 100644 index 0000000000..cd8600ff68 --- /dev/null +++ b/docs/user-guide/first-steps/migration-guide.njk @@ -0,0 +1,31 @@ +--- +title: Migration Guide +order: 6 +desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF. +--- + +

    Migration Guide

    + +

    If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.

    + +
    +

    Open the full guide (PDF)

    +

    The Enterprise Guide to Migrating Design Systems from Figma to Penpot

    +
    + +

    What the guide covers

    +

    The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.

    + + + +

    The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.

    + +

    Questions and discussion

    +

    If you want to ask about a migration, or share how yours is going, use the Community post The Enterprise Guide to Migrating Design Systems to Penpot.

    diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk index 7188090ac8..c3893f90e8 100644 --- a/docs/user-guide/first-steps/troubleshooting-webgl.njk +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -1,6 +1,6 @@ --- title: Troubleshooting WebGL -order: 5 +order: 7 desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks. --- diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index e101cea063..9b37274b5f 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -584,11 +584,38 @@ (into {}))] (merge resolved dropped))) +(defn- valid-token-value? + [[_ token]] + (some? (:value token))) + +(def ^:private xform-invalid-value-tokens + (comp + (remove valid-token-value?) + (map (fn [[k token]] + [k (assoc token :errors [(wte/get-error-code :error.token/empty-input)])])))) + +(defn- merge-invalid-value-tokens + "Tokens with a `nil` value (e.g. a composite typography token saved with + no fields filled in) must never reach StyleDictionary: some of its + preprocessors (`@tokens-studio/sd-transforms`'s font-styles preprocessor, + in particular) assume a typography token's value is never null and throw + an uncaught exception when it is, taking down token resolution for the + whole file. + + `tokens` is the full, unfiltered token map; `resolved` only contains the + valid subset that was actually sent to StyleDictionary. Tag the invalid + ones with the same \"empty value\" error the token forms already use + instead of ever letting them reach the resolver." + [tokens resolved] + (into resolved xform-invalid-value-tokens tokens)) + (defn resolve-tokens [tokens] - (let [tokens-tree (ctob/tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + tokens-tree (ctob/tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get valid-tokens (sd-token-name %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-interactive "Interactive check of resolving tokens. @@ -610,15 +637,18 @@ computation we can restore any token, even clashing ones with the same :name path by just looking up that :id in the ids map." [tokens] - (let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree tokens)] - (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) - (rx/map #(merge-name-collisions tokens %))))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens) + {:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree valid-tokens)] + (->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %))) + (rx/map #(merge-name-collisions valid-tokens %)) + (rx/map #(merge-invalid-value-tokens tokens %))))) (defn resolve-tokens-with-verbose-errors [tokens] - (resolve-tokens-tree - (ctob/tokens-tree tokens) - #(get tokens (sd-token-name %)) - (StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))) + (let [valid-tokens (into {} (filter valid-token-value?) tokens)] + (resolve-tokens-tree + (ctob/tokens-tree valid-tokens) + #(get valid-tokens (sd-token-name %)) + (StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))) ;; === Hooks diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index bb29d773b9..b78add32b7 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -15,6 +15,7 @@ [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] [app.common.geom.shapes :as gsh] + [app.common.logging :as log] [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.container :as ctn] @@ -579,44 +580,57 @@ modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)] (assoc state :workspace-modifiers modifiers)))))) +(defn- without-nil-ids + "Drop nil-keyed entries from a modif-tree. A nil shape id (possible in + production builds, where the upstream asserts are elided) would crash + the WASM heap write with `uuid/get-u32` being called on nil." + [modif-tree] + (if (contains? modif-tree nil) + (do (log/warn :hint "modif-tree contains a nil shape id; ignoring entry") + (dissoc modif-tree nil)) + modif-tree)) + (defn- parse-structure-modifiers [modif-tree] (into [] - (mapcat - (fn [[parent-id data]] - (when (ctm/has-structure? (:modifiers data)) - (->> (concat - (get-in data [:modifiers :structure-parent]) - (get-in data [:modifiers :structure-child])) - (mapcat - (fn [modifier] - (case (:type modifier) - :remove-children - (->> (:value modifier) - (map (fn [child-id] - {:type :remove-children - :parent parent-id - :id child-id - :index 0 - :value 0}))) + (comp + (mapcat + (fn [[parent-id data]] + (when (ctm/has-structure? (:modifiers data)) + (->> (concat + (get-in data [:modifiers :structure-parent]) + (get-in data [:modifiers :structure-child])) + (mapcat + (fn [modifier] + (case (:type modifier) + :remove-children + (->> (:value modifier) + (map (fn [child-id] + {:type :remove-children + :parent parent-id + :id child-id + :index 0 + :value 0}))) - :add-children - (->> (:value modifier) - (map (fn [child-id] - {:type :add-children - :parent parent-id - :id child-id - :index (:index modifier) - :value 0}))) + :add-children + (->> (:value modifier) + (map (fn [child-id] + {:type :add-children + :parent parent-id + :id child-id + :index (:index modifier) + :value 0}))) - :scale-content - [{:type :scale-content - :parent parent-id - :id parent-id - :index 0 - :value (:value modifier)}] - nil))))))) + :scale-content + [{:type :scale-content + :parent parent-id + :id parent-id + :index 0 + :value (:value modifier)}] + nil))))))) + (filter (fn [{:keys [id parent]}] + (and (some? id) (some? parent))))) modif-tree)) @@ -624,7 +638,7 @@ (let [default-transform (gmt/matrix)] (keep (fn [[id data]] (cond - (= id uuid/zero) + (or (nil? id) (= id uuid/zero)) nil (ctm/has-geometry? (:modifiers data)) @@ -693,65 +707,66 @@ subtree-ids-by-id selection-rect-cache] :or {ignore-constraints false ignore-snap-pixel false} :as params}] - (ptk/reify ::set-wasm-modifiers - ptk/UpdateEvent - (update [_ state] - (let [property-changes (extract-property-changes modif-tree)] - (if (d/not-empty? property-changes) - (-> state - (assoc :prev-wasm-props (:wasm-props state)) - (assoc :wasm-props property-changes)) - state))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::set-wasm-modifiers + ptk/UpdateEvent + (update [_ state] + (let [property-changes (extract-property-changes modif-tree)] + (if (d/not-empty? property-changes) + (-> state + (assoc :prev-wasm-props (:wasm-props state)) + (assoc :wasm-props property-changes)) + state))) - ptk/WatchEvent - (watch [_ state _] - ;; Entering an interactive transform (drag/resize/rotate). Flip - ;; the renderer into fast + atlas-backdrop mode so the live - ;; preview is cheap, tiles never appear sequentially and the main - ;; thread is not blocked. The pair is closed in - ;; `clear-local-transform`. - (ensure-interactive-transform-start!) - (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + ptk/WatchEvent + (watch [_ state _] + ;; Entering an interactive transform (drag/resize/rotate). Flip + ;; the renderer into fast + atlas-backdrop mode so the live + ;; preview is cheap, tiles never appear sequentially and the main + ;; thread is not blocked. The pair is closed in + ;; `clear-local-transform`. + (ensure-interactive-transform-start!) + (let [snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + translation? (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (if translation? - ;; Pure translation: no structure changes needed. If structure - ;; modifiers were active from a previous non-translation frame - ;; (e.g. shape hovered over a frame then dragged back out), - ;; clear them now so the shape is not clipped by the old frame. - (when @wasm-structure-modifiers-active? - (wasm.api/clean-modifiers) - (vreset! wasm-structure-modifiers-active? false)) - (let [objects (dsh/lookup-page-objects state)] - (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) - (wasm.api/clean-modifiers) - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) - (vreset! wasm-structure-modifiers-active? true))) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) - wasm-ready? (wasm.api/initialized?) - ;; While the GL context is down (lost / mid-reload), keep the - ;; root transforms so SVG selection/preview can still move. - ;; `propagate-modifiers` returns [] when not ready, do not - ;; treat that as "no modifiers". - modifiers - (cond - (or (not wasm-ready?) - (and translation? (not snap-pixel?))) - root-modifiers + (if translation? + ;; Pure translation: no structure changes needed. If structure + ;; modifiers were active from a previous non-translation frame + ;; (e.g. shape hovered over a frame then dragged back out), + ;; clear them now so the shape is not clipped by the old frame. + (when @wasm-structure-modifiers-active? + (wasm.api/clean-modifiers) + (vreset! wasm-structure-modifiers-active? false)) + (let [objects (dsh/lookup-page-objects state)] + (set-wasm-props! objects (:prev-wasm-props state) (:wasm-props state)) + (wasm.api/clean-modifiers) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (vreset! wasm-structure-modifiers-active? true))) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + wasm-ready? (wasm.api/initialized?) + ;; While the GL context is down (lost / mid-reload), keep the + ;; root transforms so SVG selection/preview can still move. + ;; `propagate-modifiers` returns [] when not ready, do not + ;; treat that as "no modifiers". + modifiers + (cond + (or (not wasm-ready?) + (and translation? (not snap-pixel?))) + root-modifiers - :else - (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (if (seq propagated) propagated root-modifiers)))] - (when wasm-ready? - (wasm.api/set-modifiers modifiers)) - (let [ids (into [] xf:map-key geometry-entries) - selrect (when wasm-ready? - (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids)))] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers)))))))) + :else + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (if (seq propagated) propagated root-modifiers)))] + (when wasm-ready? + (wasm.api/set-modifiers modifiers)) + (let [ids (into [] xf:map-key geometry-entries) + selrect (when wasm-ready? + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids)))] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers))))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -782,58 +797,44 @@ subtree-ids-by-id] :or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true} :as params}] - (ptk/reify ::apply-wasm-modifiers - ptk/WatchEvent - (watch [_ state _] - (let [translation? - (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] - (wasm.api/clean-modifiers) - (when-not translation? - (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) + (let [modif-tree (without-nil-ids modif-tree)] + (ptk/reify ::apply-wasm-modifiers + ptk/WatchEvent + (watch [_ state _] + (let [translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))] + (wasm.api/clean-modifiers) + (when-not translation? + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))) - ;; Apply property changes (e.g. grow-type) to WASM shapes before - ;; propagating geometry, so propagate_modifiers sees the updated state. - (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] - (when (= property :grow-type) - (wasm.api/use-shape id) - (wasm.api/set-shape-grow-type value))) + ;; Apply property changes (e.g. grow-type) to WASM shapes before + ;; propagating geometry, so propagate_modifiers sees the updated state. + (doseq [[id {:keys [property value]}] (extract-property-changes modif-tree)] + (when (= property :grow-type) + (wasm.api/use-shape id) + (wasm.api/set-shape-grow-type value))) - (let [objects (dsh/lookup-page-objects state) + (let [objects (dsh/lookup-page-objects state) - geometry-entries - (parse-geometry-modifiers modif-tree) + geometry-entries + (parse-geometry-modifiers modif-tree) - snap-pixel? - (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) - transforms - (cond - (and translation? (not snap-pixel?)) - ;; Mirror WASM `propagate_modifiers` in CLJS: splat the - ;; translation matrix onto every descendant. Without - ;; this step the commit would only touch the dragged - ;; primaries and descendants would snap back to their - ;; pre-drag positions on drop. - ;; - ;; Skipped when `snap-pixel?` is on: WASM applies - ;; per-shape pixel correction (different scale/translate - ;; per descendant) which we can't replicate cheaply on - ;; the CLJS side. - (reduce - (fn [acc [id data]] - (let [t (:transform data) - subtree-ids - (or (get subtree-ids-by-id id) - (cfh/get-children-ids-with-self objects id))] - (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) - {} - geometry-entries) - - ;; Context lost / mid-reload: do not call into WASM. Use - ;; root transforms (and splat translation onto descendants - ;; when we can) so the commit still lands in file data. - (not (wasm.api/initialized?)) - (if translation? + transforms + (cond + (and translation? (not snap-pixel?)) + ;; Mirror WASM `propagate_modifiers` in CLJS: splat the + ;; translation matrix onto every descendant. Without + ;; this step the commit would only touch the dragged + ;; primaries and descendants would snap back to their + ;; pre-drag positions on drop. + ;; + ;; Skipped when `snap-pixel?` is on: WASM applies + ;; per-shape pixel correction (different scale/translate + ;; per descendant) which we can't replicate cheaply on + ;; the CLJS side. (reduce (fn [acc [id data]] (let [t (:transform data) @@ -843,71 +844,87 @@ (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) {} geometry-entries) - (into {} - (map (fn [[id data]] [id (:transform data)])) - geometry-entries)) - :else - (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) + ;; Context lost / mid-reload: do not call into WASM. Use + ;; root transforms (and splat translation onto descendants + ;; when we can) so the commit still lands in file data. + (not (wasm.api/initialized?)) + (if translation? + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} + (map (fn [[id data]] [id (:transform data)])) + geometry-entries)) - ignore-tree - (calculate-ignore-tree-wasm transforms objects) + :else + (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) - options - (-> params - (assoc :reg-objects? true) - (assoc :ignore-tree ignore-tree) - (assoc :translation? translation?) - ;; Attributes that can change in the transform. This - ;; way we don't have to check all the attributes - (assoc :attrs transform-attrs)) + ignore-tree + (calculate-ignore-tree-wasm transforms objects) - modif-tree - (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) + options + (-> params + (assoc :reg-objects? true) + (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) + ;; Attributes that can change in the transform. This + ;; way we don't have to check all the attributes + (assoc :attrs transform-attrs)) - ids - (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) + modif-tree + (propagate-structure-modifiers modif-tree (dsh/lookup-page-objects state)) - update-shape - (fn [shape] - (let [shape-id (dm/get-prop shape :id) - transform (get transforms shape-id) - modifiers (dm/get-in modif-tree [shape-id :modifiers])] - (-> shape - (gsh/apply-transform transform) - (ctm/apply-structure-modifiers modifiers)))) + ids + (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) - bool-ids - (into #{} - (comp - (mapcat (partial cfh/get-parents-with-self objects)) - (filter cfh/bool-shape?) - (map :id)) - ids) + update-shape + (fn [shape] + (let [shape-id (dm/get-prop shape :id) + transform (get transforms shape-id) + modifiers (dm/get-in modif-tree [shape-id :modifiers])] + (-> shape + (gsh/apply-transform transform) + (ctm/apply-structure-modifiers modifiers)))) - undo-id (js/Symbol)] - (rx/concat - (if undo-transation? - (rx/of (dwu/start-undo-transaction undo-id)) - (rx/empty)) - (rx/of - (clear-local-transform) - (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) - (ptk/event ::dwcm/move-frame-comment-threads transforms) - (dwsh/update-shapes ids update-shape options) + bool-ids + (into #{} + (comp + (mapcat (partial cfh/get-parents-with-self objects)) + (filter cfh/bool-shape?) + (map :id)) + ids) - ;; The update to the bool path needs to be in a different operation because it - ;; needs to have the updated children info. - ;; `update-layout? false`: recalculating a bool path can never change - ;; `:hidden`, and the layout check would recompute the whole boolean - ;; path in WASM once per bool shape just to find that out. - (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options - :with-objects? true - :update-layout? false))) + undo-id (js/Symbol)] - (if undo-transation? - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/empty)))))))) + (rx/concat + (if undo-transation? + (rx/of (dwu/start-undo-transaction undo-id)) + (rx/empty)) + (rx/of + (clear-local-transform) + (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) + (ptk/event ::dwcm/move-frame-comment-threads transforms) + (dwsh/update-shapes ids update-shape options) + + ;; The update to the bool path needs to be in a different operation because it + ;; needs to have the updated children info. + ;; `update-layout? false`: recalculating a bool path can never change + ;; `:hidden`, and the layout check would recompute the whole boolean + ;; path in WASM once per bool shape just to find that out. + (dwsh/update-shapes bool-ids path/update-bool-shape (assoc options + :with-objects? true + :update-layout? false))) + + (if undo-transation? + (rx/of (dwu/commit-undo-transaction undo-id)) + (rx/empty))))))))) (def ^:private xf-rotation-shape diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index d1911b675e..9a3ee11095 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -335,14 +335,15 @@ (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape (get objects shape-id) - container (get objects (:parent-id shape)) - width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants - x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants - (rx/of - (dwt/update-dimensions [(:parent-id shape)] :width width) - (dwt/update-position shape-id - {:x x} - {:absolute? false})))))) + container (get objects (:parent-id shape))] + (when (and (some? shape) (some? container)) + (let [width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants + x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants + (rx/of + (dwt/update-dimensions [(:parent-id shape)] :width width) + (dwt/update-position shape-id + {:x x} + {:absolute? false})))))))) (defn add-new-variant "Create a new variant and add it to the variant-container" @@ -359,39 +360,40 @@ shape (get objects shape-id) shape (if (ctc/is-variant-container? shape) (get objects (last (:shapes shape))) - shape) - component-id (:component-id shape) - component (ctkl/get-component data component-id) + shape)] + (when (some? shape) + (let [component-id (:component-id shape) + component (ctkl/get-component data component-id) - container-id (:parent-id shape) - variant-container (get objects container-id) - has-layout? (ctsl/any-layout? variant-container) + container-id (:parent-id shape) + variant-container (get objects container-id) + has-layout? (ctsl/any-layout? variant-container) - new-component-id (uuid/next) - new-shape-id (uuid/next) + new-component-id (uuid/next) + new-shape-id (uuid/next) - prop-num (dec (count (:variant-properties component))) + prop-num (dec (count (:variant-properties component))) - changes (-> (pcb/empty-changes it page-id) - (pcb/with-library-data data) - (pcb/with-objects objects) - (pcb/with-page-id page-id) - (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) + changes (-> (pcb/empty-changes it page-id) + (pcb/with-library-data data) + (pcb/with-objects objects) + (pcb/with-page-id page-id) + (clv/generate-add-new-variant shape (:variant-id component) new-component-id new-shape-id prop-num)) - undo-id (js/Symbol)] - (rx/concat - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (when-not has-layout? - (resposition-and-resize-variant new-shape-id)) - (dwu/commit-undo-transaction undo-id) - (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) - (if multiselect? - (dws/shift-select-shapes new-shape-id) - (dws/select-shape new-shape-id))) - (->> (rx/of (focus-property (:id variant-container))) - (rx/delay 250)))))))) + undo-id (js/Symbol)] + (rx/concat + (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (when-not has-layout? + (resposition-and-resize-variant new-shape-id)) + (dwu/commit-undo-transaction undo-id) + (ptk/data-event :layout/update {:ids [(:parent-id shape)]}) + (if multiselect? + (dws/shift-select-shapes new-shape-id) + (dws/select-shape new-shape-id))) + (->> (rx/of (focus-property (:id variant-container))) + (rx/delay 250)))))))))) (defn transform-in-variant "Given the id of a main shape of a component, creates a variant structure for diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 489ba391b9..93e3a61eda 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -118,8 +118,8 @@ (tr "nitrate.form.contact-upgrade") (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]] + [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index ff38ffb1c6..957ffc6fa0 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -848,8 +848,8 @@ [:div {:class (stl/css :modal-text)} (tr "nitrate.form.enterprise-intro" ".") " " (if nitrate-license (tr "nitrate.form.contact-us-upgrade") (tr "nitrate.form.contact-us-free-trial"))] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"} - "sales@penpot.net"]]])]])) + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} + "sales@penpot.app"]]])]])) (mf/defc nitrate-contact-sales-dialog {::mf/register modal/components @@ -898,7 +898,7 @@ :on-click handle-close-dialog} (tr "ds.confirm-cancel")] [:> button* {:variant "primary" :type "button" - :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.net?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) + :on-click #(dom/open-new-window (dm/str "mailto:sales@penpot.app?subject=Switch%20to%20the%20" subscription-type "%20plan"))} (tr "nitrate.contact-sales.button")]]])]]])) (mf/defc nitrate-cancel-contact-sales-dialog {::mf/register modal/components @@ -908,7 +908,7 @@ (js/encodeURIComponent email) mailto-url - (dm/str "mailto:sales@penpot.net" + (dm/str "mailto:sales@penpot.app" "?subject=Request%20to%20Cancel%20Enterprise%20Subscription" "&body=Hello%2C%0A%0A" "I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%0A" @@ -930,8 +930,8 @@ [:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-text-medium)} (tr "nitrate.subscription.settings.manual-contact-us")] - [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"} - "sales@penpot.net"] + [:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"} + "sales@penpot.app"] [:div {:class (stl/css :action-buttons)} [:> button* {:class (stl/css :button-full-width) :variant "primary" diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index 874f353d6e..1bd5bd2270 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -276,7 +276,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) + [:> stroke-row* {:key (dm/str "stroke-" index) :index index :stroke value :title (tr "workspace.options.stroke-color") diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index 4e07ed1c69..d23a9a1543 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -18,7 +18,6 @@ [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]] [app.util.i18n :refer [tr]] - [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -43,11 +42,14 @@ (defn- validate-typography-token [{:keys [token-value] :as props}] (cond - ;; Entering form without a value - show no error just resolve nil - (nil? token-value) (rx/of nil) ;; Validate refrence string (cto/composite-token-reference? token-value) (default-validate-token props) - ;; Validate composite token + ;; Validate composite token. `token-value` may be nil when the form is + ;; submitted without any composite field filled in — normalize it to `{}` + ;; so `check-empty-typography-token` catches it and rejects the submit, + ;; instead of silently saving a token with a `nil` value (which later + ;; crashes token resolution: the tokens-studio StyleDictionary + ;; preprocessor assumes a typography token's value is never null). :else (-> props (update :token-value diff --git a/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs new file mode 100644 index 0000000000..7e39bf91ee --- /dev/null +++ b/frontend/test/frontend_tests/logic/wasm_modifiers_nil_id_test.cljs @@ -0,0 +1,109 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC Sucursal en España SL + +(ns frontend-tests.logic.wasm-modifiers-nil-id-test + "Reproduces the production crash \"Cannot read properties of null + (reading '__u32_buffer')\". + + A modif-tree containing a nil shape id (production builds elide the + asserts that catch this upstream, e.g. `update-dimensions` called + with `[(:parent-id shape)]` when `shape` is missing) reached + `wasm.api/propagate-modifiers` / `wasm.api/set-structure-modifiers`, + and `mem.h32/write-uuid` crashed calling `uuid/get-u32` on nil while + writing to the WASM heap. + + These tests assert that no nil id ever crosses the WASM boundary and + that valid shapes in the same modif-tree are still processed." + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid] + [app.main.data.workspace.modifiers :as dwm] + [app.render-wasm.api :as wasm.api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(def ^:private captured-geometry-entries + "Entries passed to `wasm.api/propagate-modifiers` during a test." + (atom [])) + +(def ^:private captured-structure-entries + "Entries passed to `wasm.api/set-structure-modifiers` during a test." + (atom [])) + +(defn- install-capturing-spies! + "Replace the plain WASM mocks with variants that record their input. + Must run after `thw/setup-wasm-mocks!` so teardown still restores + the real implementations." + [] + (set! wasm.api/propagate-modifiers + (fn [entries _pixel-precision] + (swap! captured-geometry-entries into entries) + (into [] + (map (fn [[id data]] [id (:transform data)])) + entries))) + (set! wasm.api/set-structure-modifiers + (fn [entries] + (swap! captured-structure-entries into entries) + nil))) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! captured-geometry-entries []) + (reset! captured-structure-entries []) + (thw/setup-wasm-mocks!) + (install-capturing-spies!)) + :after (fn [] + (thw/teardown-wasm-mocks!))}) + +(t/deftest nil-id-does-not-reach-propagate-modifiers + ;; A nil-keyed entry must be dropped before the WASM heap write while + ;; the valid entry is still resized. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + resize (ctm/change-dimensions-modifiers rect :width 200) + modif-tree {nil {:modifiers resize} + (:id rect) {:modifiers resize}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [new-state] + (let [entry-ids (into #{} (map first) @captured-geometry-entries) + file' (ths/get-file-from-state new-state) + rect' (cths/get-shape file' :rect1) + width (-> rect' :points grc/points->rect :width)] + (t/is (not (contains? entry-ids nil))) + (t/is (contains? entry-ids (:id rect))) + (t/is (mth/close? 200 width)))))))) + +(t/deftest nil-id-does-not-reach-set-structure-modifiers + ;; A nil-keyed entry with structure modifiers must not produce + ;; structure entries with a nil :parent or :id. + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1 :x 10 :y 20 :width 100 :height 50)) + store (ths/setup-store file) + rect (cths/get-shape file :rect1) + modif-tree {nil {:modifiers (ctm/add-children nil [(uuid/next)] 0)} + (:id rect) {:modifiers (ctm/change-dimensions-modifiers rect :width 200)}} + events [(dwm/apply-wasm-modifiers modif-tree {:ignore-snap-pixel true})]] + (ths/run-store + store done events + (fn [_new-state] + (t/is (every? #(some? (:parent %)) @captured-structure-entries)) + (t/is (every? #(some? (:id %)) @captured-structure-entries))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 033d18b354..a139201f54 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -44,6 +44,7 @@ [frontend-tests.logic.path-tools-test] [frontend-tests.logic.sidebar-transform-coalescing-test] [frontend-tests.logic.update-position-test] + [frontend-tests.logic.wasm-modifiers-nil-id-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] @@ -150,6 +151,7 @@ 'frontend-tests.main-errors-test 'frontend-tests.logic.sidebar-transform-coalescing-test 'frontend-tests.logic.update-position-test + 'frontend-tests.logic.wasm-modifiers-nil-id-test 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test diff --git a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs index 2ad4599656..8e7422e1a3 100644 --- a/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs +++ b/frontend/test/frontend_tests/tokens/style_dictionary_test.cljs @@ -122,6 +122,43 @@ (get-in resolved-tokens ["typography.bad" :errors 0 :error/code]))) (done)))))))) +;; Regression: a token with a `nil` value (e.g. a composite typography +;; token saved via the workspace form with no fields filled in) must never +;; reach StyleDictionary — its `tokens-studio` preprocessor assumes a +;; typography token's value is never null and throws an uncaught exception +;; on it, which used to take down resolution for every other token in the +;; file. It should be tagged with an empty-input error instead. +(t/deftest resolve-tokens-nil-value-test + (t/async + done + (let [tokens (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) + :name "core")) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "typography.empty" + :value nil + :type :typography})) + (ctob/add-token (cthi/id :core-set) + (ctob/make-token {:name "borderRadius.sm" + :value "12px" + :type :border-radius})) + (ctob/get-all-tokens-map))] + (->> (sd/resolve-tokens tokens) + (rx/subs! + (fn [resolved-tokens] + (t/testing "the nil-value token is tagged with an error instead of crashing" + (t/is (contains? resolved-tokens "typography.empty")) + (t/is (nil? (get-in resolved-tokens ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input + (get-in resolved-tokens ["typography.empty" :errors 0 :error/code])))) + (t/testing "other tokens still resolve normally" + (t/is (= 12 (get-in resolved-tokens ["borderRadius.sm" :resolved-value]))))) + (fn [err] + (t/do-report {:type :error :message "Stream error" :actual err}) + (done)) + (fn [] + (done))))))) + (t/deftest resolve-tokens-interactive-test (t/async done