mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 21:19:49 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
1dfa2cd9f2
1
.gitignore
vendored
1
.gitignore
vendored
@ -99,6 +99,7 @@ opencode.json
|
|||||||
/.idea
|
/.idea
|
||||||
*.iml
|
*.iml
|
||||||
/.claude
|
/.claude
|
||||||
|
/CLAUDE.md
|
||||||
/.playwright-mcp
|
/.playwright-mcp
|
||||||
/.devenv/mcp/
|
/.devenv/mcp/
|
||||||
/opencode.json
|
/opencode.json
|
||||||
|
|||||||
@ -166,7 +166,6 @@
|
|||||||
(not (ctob/token-name-path-exists? token-name tokens-tree)))
|
(not (ctob/token-name-path-exists? token-name tokens-tree)))
|
||||||
new-tokens))))]])
|
new-tokens))))]])
|
||||||
(defn find-refs [value]
|
(defn find-refs [value]
|
||||||
(prn value)
|
|
||||||
(cond
|
(cond
|
||||||
(string? value)
|
(string? value)
|
||||||
(cto/find-token-value-references value)
|
(cto/find-token-value-references value)
|
||||||
|
|||||||
@ -78,7 +78,7 @@ services:
|
|||||||
# - "443:443"
|
# - "443:443"
|
||||||
|
|
||||||
penpot-frontend:
|
penpot-frontend:
|
||||||
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
|
image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 9001:8080
|
- 9001:8080
|
||||||
@ -111,7 +111,7 @@ services:
|
|||||||
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
# PENPOT_DISABLE_IPV6_LISTEN: "true"
|
||||||
|
|
||||||
penpot-backend:
|
penpot-backend:
|
||||||
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
|
image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@ -180,13 +180,13 @@ services:
|
|||||||
PENPOT_SMTP_SSL: "false"
|
PENPOT_SMTP_SSL: "false"
|
||||||
|
|
||||||
penpot-mcp:
|
penpot-mcp:
|
||||||
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
|
image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- penpot
|
- penpot
|
||||||
|
|
||||||
penpot-exporter:
|
penpot-exporter:
|
||||||
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
|
image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@ -26,7 +26,7 @@ Penpot MCP enables **multi-directional workflows** between design and code. Beca
|
|||||||
title="Quick demo: Penpot MCP server in action"
|
title="Quick demo: Penpot MCP server in action"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="480"
|
height="480"
|
||||||
src="https://www.youtube.com/embed/CfvcgMQEmLk?rel=0"
|
src="https://www.youtube.com/embed/7V01SKVG6PQ?rel=0"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
referrerpolicy="strict-origin-when-cross-origin"
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
|||||||
@ -31,4 +31,10 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
|
|||||||
<p>Useful resources to better understand Penpot</p>
|
<p>Useful resources to better understand Penpot</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user-guide/first-steps/migration-guide">
|
||||||
|
<h2>Migration Guide →</h2>
|
||||||
|
<p>Move a design system from Figma to Penpot</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
31
docs/user-guide/first-steps/migration-guide.njk
Normal file
31
docs/user-guide/first-steps/migration-guide.njk
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: Migration Guide
|
||||||
|
order: 6
|
||||||
|
desc: Move a design system from Figma to Penpot. Read a short summary of the enterprise migration guide and open the full PDF.
|
||||||
|
---
|
||||||
|
|
||||||
|
<h1 id="migration-guide">Migration Guide</h1>
|
||||||
|
|
||||||
|
<p class="main-paragraph">If you are moving a design system to Penpot, especially from Figma, start with the enterprise migration guide. It covers file and library migration, tokens, validation, dual-tool workflows, and how different roles can run a pilot.</p>
|
||||||
|
|
||||||
|
<div class="advice">
|
||||||
|
<p><strong>Open the full guide (PDF)</strong></p>
|
||||||
|
<p><a href="https://nextcloud.kaleidos.net/index.php/s/mKordyz62QF3PQ4?dir=/&editing=false&openfile=true" target="_blank" rel="noopener"><strong>The Enterprise Guide to Migrating Design Systems from Figma to Penpot</strong></a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="what-the-guide-covers">What the guide covers</h2>
|
||||||
|
<p>The document is written for teams that need to move more than a few mockups: libraries, tokens, variants, and the workflows around them. It focuses on Figma, but the same audit, pilot, and validation steps apply if you are coming from another tool.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Before you export:</strong> audit critical files, component chains, token usage, and plugins that will not come along. Split oversized files and clean unused libraries while you are still in Figma.</li>
|
||||||
|
<li><strong>Static assets:</strong> export SVG, PNG, or JPG from Figma and place them in Penpot.</li>
|
||||||
|
<li><strong>Complex files and libraries:</strong> use the Penpot Exporter plugin for Figma (design files, slides, components, variants, auto layout, styles, variables, and libraries). Expect some layout cleanup, Figma Auto Layout becomes Flex and Grid in Penpot.</li>
|
||||||
|
<li><strong>Tokens:</strong> if you already use Tokens Studio, export JSON and import it in Penpot. Native Figma Variables can go through Tokens Studio, or through the Exporter plugin.</li>
|
||||||
|
<li><strong>Validate before you scale:</strong> migrate one representative file (or a sandbox library), write down recurring cleanup, then roll the same checklist out to the rest of the workspace.</li>
|
||||||
|
<li><strong>People and pilots:</strong> the second half of the guide has paths for designers, frontend developers, DesignOps, design-system leads, and product/engineering pilots, including how Penpot MCP can help with post-import cleanup.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>The guide also covers running Figma and Penpot in parallel for a while. The exporter is for one-off migration, not continuous sync.</p>
|
||||||
|
|
||||||
|
<h2 id="discuss-the-guide">Questions and discussion</h2>
|
||||||
|
<p>If you want to ask about a migration, or share how yours is going, use the Community post <a href="https://community.penpot.app/t/the-enterprise-guide-to-migrating-design-systems-to-penpot/10768" target="_blank" rel="noopener">The Enterprise Guide to Migrating Design Systems to Penpot</a>.</p>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Troubleshooting WebGL
|
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.
|
desc: Diagnose and fix common WebGL issues in Penpot, enable WebGL rendering (Beta), and troubleshoot browser, GPU, and system checks.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -584,11 +584,38 @@
|
|||||||
(into {}))]
|
(into {}))]
|
||||||
(merge resolved dropped)))
|
(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
|
(defn resolve-tokens
|
||||||
[tokens]
|
[tokens]
|
||||||
(let [tokens-tree (ctob/tokens-tree tokens)]
|
(let [valid-tokens (into {} (filter valid-token-value?) tokens)
|
||||||
(->> (resolve-tokens-tree tokens-tree #(get tokens (sd-token-name %)))
|
tokens-tree (ctob/tokens-tree valid-tokens)]
|
||||||
(rx/map #(merge-name-collisions 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
|
(defn resolve-tokens-interactive
|
||||||
"Interactive check of resolving tokens.
|
"Interactive check of resolving tokens.
|
||||||
@ -610,15 +637,18 @@
|
|||||||
computation we can restore any token, even clashing ones with the
|
computation we can restore any token, even clashing ones with the
|
||||||
same :name path by just looking up that :id in the ids map."
|
same :name path by just looking up that :id in the ids map."
|
||||||
[tokens]
|
[tokens]
|
||||||
(let [{:keys [tokens-tree ids]} (ctob/backtrace-tokens-tree 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 %)))
|
(->> (resolve-tokens-tree tokens-tree #(get ids (sd-token-uuid %)))
|
||||||
(rx/map #(merge-name-collisions tokens %)))))
|
(rx/map #(merge-name-collisions valid-tokens %))
|
||||||
|
(rx/map #(merge-invalid-value-tokens tokens %)))))
|
||||||
|
|
||||||
(defn resolve-tokens-with-verbose-errors [tokens]
|
(defn resolve-tokens-with-verbose-errors [tokens]
|
||||||
|
(let [valid-tokens (into {} (filter valid-token-value?) tokens)]
|
||||||
(resolve-tokens-tree
|
(resolve-tokens-tree
|
||||||
(ctob/tokens-tree tokens)
|
(ctob/tokens-tree valid-tokens)
|
||||||
#(get tokens (sd-token-name %))
|
#(get valid-tokens (sd-token-name %))
|
||||||
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
|
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"})))))
|
||||||
|
|
||||||
;; === Hooks
|
;; === Hooks
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.geom.rect :as grc]
|
[app.common.geom.rect :as grc]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
|
[app.common.logging :as log]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
@ -579,10 +580,21 @@
|
|||||||
modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)]
|
modifiers (calculate-modifiers state ignore-constraints ignore-snap-pixel modif-tree page-id params)]
|
||||||
(assoc state :workspace-modifiers modifiers))))))
|
(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
|
(defn- parse-structure-modifiers
|
||||||
[modif-tree]
|
[modif-tree]
|
||||||
(into
|
(into
|
||||||
[]
|
[]
|
||||||
|
(comp
|
||||||
(mapcat
|
(mapcat
|
||||||
(fn [[parent-id data]]
|
(fn [[parent-id data]]
|
||||||
(when (ctm/has-structure? (:modifiers data))
|
(when (ctm/has-structure? (:modifiers data))
|
||||||
@ -617,6 +629,8 @@
|
|||||||
:index 0
|
:index 0
|
||||||
:value (:value modifier)}]
|
:value (:value modifier)}]
|
||||||
nil)))))))
|
nil)))))))
|
||||||
|
(filter (fn [{:keys [id parent]}]
|
||||||
|
(and (some? id) (some? parent)))))
|
||||||
modif-tree))
|
modif-tree))
|
||||||
|
|
||||||
|
|
||||||
@ -624,7 +638,7 @@
|
|||||||
(let [default-transform (gmt/matrix)]
|
(let [default-transform (gmt/matrix)]
|
||||||
(keep (fn [[id data]]
|
(keep (fn [[id data]]
|
||||||
(cond
|
(cond
|
||||||
(= id uuid/zero)
|
(or (nil? id) (= id uuid/zero))
|
||||||
nil
|
nil
|
||||||
|
|
||||||
(ctm/has-geometry? (:modifiers data))
|
(ctm/has-geometry? (:modifiers data))
|
||||||
@ -693,6 +707,7 @@
|
|||||||
subtree-ids-by-id selection-rect-cache]
|
subtree-ids-by-id selection-rect-cache]
|
||||||
:or {ignore-constraints false ignore-snap-pixel false}
|
:or {ignore-constraints false ignore-snap-pixel false}
|
||||||
:as params}]
|
:as params}]
|
||||||
|
(let [modif-tree (without-nil-ids modif-tree)]
|
||||||
(ptk/reify ::set-wasm-modifiers
|
(ptk/reify ::set-wasm-modifiers
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
@ -751,7 +766,7 @@
|
|||||||
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
(cached-translation-selrect ids (second (first modifiers)) selection-rect-cache)
|
||||||
(wasm.api/get-selection-rect ids)))]
|
(wasm.api/get-selection-rect ids)))]
|
||||||
(rx/of (set-temporary-selrect selrect)
|
(rx/of (set-temporary-selrect selrect)
|
||||||
(set-temporary-modifiers modifiers))))))))
|
(set-temporary-modifiers modifiers)))))))))
|
||||||
|
|
||||||
(defn propagate-structure-modifiers
|
(defn propagate-structure-modifiers
|
||||||
[modif-tree objects]
|
[modif-tree objects]
|
||||||
@ -782,6 +797,7 @@
|
|||||||
subtree-ids-by-id]
|
subtree-ids-by-id]
|
||||||
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
|
:or {ignore-constraints false ignore-snap-pixel false snap-ignore-axis nil undo-transation? true}
|
||||||
:as params}]
|
:as params}]
|
||||||
|
(let [modif-tree (without-nil-ids modif-tree)]
|
||||||
(ptk/reify ::apply-wasm-modifiers
|
(ptk/reify ::apply-wasm-modifiers
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
@ -886,6 +902,7 @@
|
|||||||
ids)
|
ids)
|
||||||
|
|
||||||
undo-id (js/Symbol)]
|
undo-id (js/Symbol)]
|
||||||
|
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(if undo-transation?
|
(if undo-transation?
|
||||||
(rx/of (dwu/start-undo-transaction undo-id))
|
(rx/of (dwu/start-undo-transaction undo-id))
|
||||||
@ -907,7 +924,7 @@
|
|||||||
|
|
||||||
(if undo-transation?
|
(if undo-transation?
|
||||||
(rx/of (dwu/commit-undo-transaction undo-id))
|
(rx/of (dwu/commit-undo-transaction undo-id))
|
||||||
(rx/empty))))))))
|
(rx/empty)))))))))
|
||||||
|
|
||||||
(def ^:private
|
(def ^:private
|
||||||
xf-rotation-shape
|
xf-rotation-shape
|
||||||
|
|||||||
@ -335,14 +335,15 @@
|
|||||||
(let [page-id (:current-page-id state)
|
(let [page-id (:current-page-id state)
|
||||||
objects (dsh/lookup-page-objects state page-id)
|
objects (dsh/lookup-page-objects state page-id)
|
||||||
shape (get objects shape-id)
|
shape (get objects shape-id)
|
||||||
container (get objects (:parent-id shape))
|
container (get objects (:parent-id shape))]
|
||||||
width (+ (:width container) (:width shape) 20) ;; 20 is the default gap for variants
|
(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
|
x (- width (+ (:width shape) 30))] ;; 30 is the default margin for variants
|
||||||
(rx/of
|
(rx/of
|
||||||
(dwt/update-dimensions [(:parent-id shape)] :width width)
|
(dwt/update-dimensions [(:parent-id shape)] :width width)
|
||||||
(dwt/update-position shape-id
|
(dwt/update-position shape-id
|
||||||
{:x x}
|
{:x x}
|
||||||
{:absolute? false}))))))
|
{:absolute? false}))))))))
|
||||||
|
|
||||||
(defn add-new-variant
|
(defn add-new-variant
|
||||||
"Create a new variant and add it to the variant-container"
|
"Create a new variant and add it to the variant-container"
|
||||||
@ -359,8 +360,9 @@
|
|||||||
shape (get objects shape-id)
|
shape (get objects shape-id)
|
||||||
shape (if (ctc/is-variant-container? shape)
|
shape (if (ctc/is-variant-container? shape)
|
||||||
(get objects (last (:shapes shape)))
|
(get objects (last (:shapes shape)))
|
||||||
shape)
|
shape)]
|
||||||
component-id (:component-id shape)
|
(when (some? shape)
|
||||||
|
(let [component-id (:component-id shape)
|
||||||
component (ctkl/get-component data component-id)
|
component (ctkl/get-component data component-id)
|
||||||
|
|
||||||
container-id (:parent-id shape)
|
container-id (:parent-id shape)
|
||||||
@ -391,7 +393,7 @@
|
|||||||
(dws/shift-select-shapes new-shape-id)
|
(dws/shift-select-shapes new-shape-id)
|
||||||
(dws/select-shape new-shape-id)))
|
(dws/select-shape new-shape-id)))
|
||||||
(->> (rx/of (focus-property (:id variant-container)))
|
(->> (rx/of (focus-property (:id variant-container)))
|
||||||
(rx/delay 250))))))))
|
(rx/delay 250))))))))))
|
||||||
|
|
||||||
(defn transform-in-variant
|
(defn transform-in-variant
|
||||||
"Given the id of a main shape of a component, creates a variant structure for
|
"Given the id of a main shape of a component, creates a variant structure for
|
||||||
|
|||||||
@ -118,8 +118,8 @@
|
|||||||
(tr "nitrate.form.contact-upgrade")
|
(tr "nitrate.form.contact-upgrade")
|
||||||
(tr "nitrate.form.contact-trial"))]
|
(tr "nitrate.form.contact-trial"))]
|
||||||
[:p {:class (stl/css :modal-text-large)}
|
[:p {:class (stl/css :modal-text-large)}
|
||||||
[:a {:class (stl/css :link) :href "mailto:sales@penpot.net"}
|
[:a {:class (stl/css :link) :href "mailto:sales@penpot.app"}
|
||||||
"sales@penpot.net"]]
|
"sales@penpot.app"]]
|
||||||
[:div {:class (stl/css :activation-code)}
|
[:div {:class (stl/css :activation-code)}
|
||||||
[:p {:class (stl/css :modal-text-large)}
|
[:p {:class (stl/css :modal-text-large)}
|
||||||
[:a {:class (stl/css :link)
|
[:a {:class (stl/css :link)
|
||||||
|
|||||||
@ -848,8 +848,8 @@
|
|||||||
[:div {:class (stl/css :modal-text)}
|
[: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"))]
|
(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)}
|
[:div {:class (stl/css :modal-text)}
|
||||||
[:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.net"}
|
[:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"}
|
||||||
"sales@penpot.net"]]])]]))
|
"sales@penpot.app"]]])]]))
|
||||||
|
|
||||||
(mf/defc nitrate-contact-sales-dialog
|
(mf/defc nitrate-contact-sales-dialog
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
@ -898,7 +898,7 @@
|
|||||||
:on-click handle-close-dialog} (tr "ds.confirm-cancel")]
|
:on-click handle-close-dialog} (tr "ds.confirm-cancel")]
|
||||||
[:> button* {:variant "primary"
|
[:> button* {:variant "primary"
|
||||||
:type "button"
|
: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/defc nitrate-cancel-contact-sales-dialog
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
@ -908,7 +908,7 @@
|
|||||||
(js/encodeURIComponent email)
|
(js/encodeURIComponent email)
|
||||||
|
|
||||||
mailto-url
|
mailto-url
|
||||||
(dm/str "mailto:sales@penpot.net"
|
(dm/str "mailto:sales@penpot.app"
|
||||||
"?subject=Request%20to%20Cancel%20Enterprise%20Subscription"
|
"?subject=Request%20to%20Cancel%20Enterprise%20Subscription"
|
||||||
"&body=Hello%2C%0A%0A"
|
"&body=Hello%2C%0A%0A"
|
||||||
"I%20would%20like%20to%20cancel%20my%20Enterprise%20subscription.%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-content)}
|
||||||
[:div {:class (stl/css :modal-text-medium)}
|
[:div {:class (stl/css :modal-text-medium)}
|
||||||
(tr "nitrate.subscription.settings.manual-contact-us")]
|
(tr "nitrate.subscription.settings.manual-contact-us")]
|
||||||
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.net"}
|
[:a {:class (stl/css :cta-link) :href "mailto:sales@penpot.app"}
|
||||||
"sales@penpot.net"]
|
"sales@penpot.app"]
|
||||||
[:div {:class (stl/css :action-buttons)}
|
[:div {:class (stl/css :action-buttons)}
|
||||||
[:> button* {:class (stl/css :button-full-width)
|
[:> button* {:class (stl/css :button-full-width)
|
||||||
:variant "primary"
|
:variant "primary"
|
||||||
|
|||||||
@ -276,7 +276,7 @@
|
|||||||
(seq strokes)
|
(seq strokes)
|
||||||
[:> h/sortable-container* {}
|
[:> h/sortable-container* {}
|
||||||
(for [[index value] (d/enumerate (:strokes values []))]
|
(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
|
:index index
|
||||||
:stroke value
|
:stroke value
|
||||||
:title (tr "workspace.options.stroke-color")
|
:title (tr "workspace.options.stroke-color")
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
|
[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.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]]
|
||||||
[app.util.i18n :refer [tr]]
|
[app.util.i18n :refer [tr]]
|
||||||
[beicon.v2.core :as rx]
|
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
@ -43,11 +42,14 @@
|
|||||||
(defn- validate-typography-token
|
(defn- validate-typography-token
|
||||||
[{:keys [token-value] :as props}]
|
[{:keys [token-value] :as props}]
|
||||||
(cond
|
(cond
|
||||||
;; Entering form without a value - show no error just resolve nil
|
|
||||||
(nil? token-value) (rx/of nil)
|
|
||||||
;; Validate refrence string
|
;; Validate refrence string
|
||||||
(cto/composite-token-reference? token-value) (default-validate-token props)
|
(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
|
:else
|
||||||
(-> props
|
(-> props
|
||||||
(update :token-value
|
(update :token-value
|
||||||
|
|||||||
@ -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)))))))
|
||||||
@ -44,6 +44,7 @@
|
|||||||
[frontend-tests.logic.path-tools-test]
|
[frontend-tests.logic.path-tools-test]
|
||||||
[frontend-tests.logic.sidebar-transform-coalescing-test]
|
[frontend-tests.logic.sidebar-transform-coalescing-test]
|
||||||
[frontend-tests.logic.update-position-test]
|
[frontend-tests.logic.update-position-test]
|
||||||
|
[frontend-tests.logic.wasm-modifiers-nil-id-test]
|
||||||
[frontend-tests.main-errors-test]
|
[frontend-tests.main-errors-test]
|
||||||
[frontend-tests.plugins.comments-test]
|
[frontend-tests.plugins.comments-test]
|
||||||
[frontend-tests.plugins.context-shapes-test]
|
[frontend-tests.plugins.context-shapes-test]
|
||||||
@ -150,6 +151,7 @@
|
|||||||
'frontend-tests.main-errors-test
|
'frontend-tests.main-errors-test
|
||||||
'frontend-tests.logic.sidebar-transform-coalescing-test
|
'frontend-tests.logic.sidebar-transform-coalescing-test
|
||||||
'frontend-tests.logic.update-position-test
|
'frontend-tests.logic.update-position-test
|
||||||
|
'frontend-tests.logic.wasm-modifiers-nil-id-test
|
||||||
'frontend-tests.plugins.comments-test
|
'frontend-tests.plugins.comments-test
|
||||||
'frontend-tests.plugins.context-shapes-test
|
'frontend-tests.plugins.context-shapes-test
|
||||||
'frontend-tests.plugins.file-test
|
'frontend-tests.plugins.file-test
|
||||||
|
|||||||
@ -122,6 +122,43 @@
|
|||||||
(get-in resolved-tokens ["typography.bad" :errors 0 :error/code])))
|
(get-in resolved-tokens ["typography.bad" :errors 0 :error/code])))
|
||||||
(done))))))))
|
(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/deftest resolve-tokens-interactive-test
|
||||||
(t/async
|
(t/async
|
||||||
done
|
done
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user