Copying a component with variants from a shared library file ("Lib")
and pasting it into a file that uses that library ("Using Lib") would
crash the destination file with the referential-integrity validator
error:
{:code :component-main-external
:hint "Main instance should refer to a component in the same file"}
Root cause
----------
Paste goes through `generate-duplicate-shape-change` in
`common/src/app/common/logic/libraries.cljc`. When the shape is a
main instance of a known component and the copy set includes its
variant container, dispatch lands in `duplicate-variant`, then
`generate-duplicate-component`, and finally `duplicate-component`,
which clones the main-instance shape tree. Its `update-new-shape`
helper already re-links the new outer main's `:component-id` to the
freshly created local component (`new-component-id`), but it never
touches `:component-file`. The cloned shape therefore inherits
`:component-file` from the source library while the new component is
registered in the destination's local library
(`:apply-changes-local-library? true`), leaving the main-instance
dangling.
Fix
---
Extend `update-new-shape` with a second clause, sibling to the
existing `:component-id` rewrite: when a destination file id is
provided and differs from the new main's current `:component-file`,
re-root the shape. The same `(= (:component-id new-shape) (:id
component))` guard already used for the id rewrite ensures only the
outer main-instance is touched; nested shapes are unaffected.
The destination file id is threaded from the paste entry point
through the two orchestration functions that already knew the
source/destination distinction:
- `generate-duplicate-shape-change` — supplies the destination
`file-id` it already has in scope when dispatching to
`generate-duplicate-component-change`.
- `generate-duplicate-component-change` — accepts `:new-component-file`
as a kwarg; renames its internal `file-id` binding to
`source-file-id` for clarity (it was always the component's
originating library file); forwards `new-component-file` to
`duplicate-variant`.
- `duplicate-variant` — takes and forwards the `new-component-file`
positional arg.
- `generate-duplicate-component` — accepts `:new-component-file` kwarg
and passes it to `duplicate-component`.
- `duplicate-component` — applies the rewrite inside
`update-new-shape`. The `new-component-file` parameter is placed
right after `new-component-id` since component-id and component-file
are typically managed together.
Same-file duplication is not affected: without `:new-component-file`
the new clause is skipped, and when source and destination match the
`(not= new-component-file (:component-file new-shape))` guard fails.
Tests
-----
Added in `common/test/common_tests/logic/comp_creation_test.cljc`:
- `test-duplicate-component-rewrites-component-file-to-destination`
asserts that passing `:new-component-file` to
`generate-duplicate-component` produces a main-instance with
`:component-file` equal to the destination id.
- `test-duplicate-component-keeps-component-file-without-dest`
baseline: without `:new-component-file`, `:component-file` is left
untouched, matching pre-existing same-file behavior.
Github #8144
Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add HEX, HSB, and HSL support in the third color tab
Relabel the existing HSVA tab to HSBA (the math was already HSB) and add
an inline HSB ↔ HSL model toggle inside the tab, matching Figma's color
panel. Sliders, gradients, and labels update dynamically per mode; HSL
values roundtrip through RGB/HSV so no color-storage changes are needed.
Model choice persists across sessions.
* 💄 Fix lint errors
Signed-off-by: juan-flores077 <toptalent399@gmail.com>
* 🐛 Fix Plugin API token application for JS array of strings (#9166)
* 🐛 Fix Plugin API token application for JS array of strings
Plugin code calling `shape.applyToken(token, ["fill"])` or
`token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS
array of strings. The plugin proxies expected a Clojure set of
keywords, and two coupled defects made the calls silently no-op (or,
with `throwValidationErrors` enabled, throw "check error"):
1. `token-attr-plugin->token-attr` only consulted its alias map when
the input was already a keyword. String inputs like "fill" fell
through to the identity branch, so the downstream
`cto/token-attr?` predicate (which checks against a set of
keywords) returned false for every string. Coerce strings to
keywords first.
2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas
used plain `[:set ...]`, which has no `:decode/json` transformer
for JS array → Clojure set coercion. Switch to the registered
`[::sm/set ...]` (in `app.common.schema`) which provides the
array → set decoder. After the switch, the standard JSON pipeline
converts `["fill"]` to `#{"fill"}`, then the inner
`[:and ::sm/keyword [:fn token-attr?]]` decodes each element to a
keyword and validates it.
Also extends the docstring on `token-attr-plugin->token-attr` to make
the string-friendly contract explicit, and registers a new
`tokens-test` ns under `frontend/test/frontend_tests/plugins/` with
six `deftest` blocks covering:
- known keywords passing through unchanged
- keyword aliases (`:r1` → `:border-radius-top-left`, etc.)
- string inputs coerced to keywords (regression for #9162)
- `token-attr?` accepting both keyword and string inputs
- `token-attr?` rejecting unknown attrs and nil
Closes#9162
* 🐛 Fix wrong direction in plugin-name alias tests
The added tests in tokens_test.cljs and the new docstring in tokens.cljs
described the alias resolution in the wrong direction. The map is
{:r1 :border-radius-top-left, …} then map-invert'd, so
token-attr-plugin->token-attr maps verbose plugin-side names
(:border-radius-top-left) to canonical internal short names (:r1),
not the other way around. Inputs already in canonical form (:r1, :fill,
"fill", …) pass through unchanged. Flipped the alias-resolution test
expectations and the keyword/string-input cases, refreshed the docstring
and the regression-coverage comment to match.
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 💄 Fix sucess typo in subscription dialog i18n keys (#9204)
Rename subscription.settings.sucess.dialog.{title,footer} to
subscription.settings.success.dialog.{title,footer} in en.po and
update the three callsites in subscription.cljs.
Closes#9203
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* 🐛 Fix HSVA → HSBA test rename and Prettier formatting
Signed-off-by: juan-flores077 <toptalent399@gmail.com>
* 🐛 Fix CI failures and address review feedback for HSB color tab
Signed-off-by: juan-flores077 <toptalent399@gmail.com>
* 💄 Resolve Conflicts
Signed-off-by: juan-flores077 <toptalent399@gmail.com>
---------
Signed-off-by: juan-flores077 <toptalent399@gmail.com>
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: boskodev790 <boskomaljkovic790@outlook.com>
Co-authored-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Let users pick the pixel grid color from a standard color picker.
The grid color was previously hardcoded, making it invisible on
mid-tone canvases. Choice is stored on the file so it persists
across sessions. Defaults preserve the current appearance when
unset.
Closes#7750
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* 🐛 Ensure typography-ref attrs are always present and fix nil encoding
Add :typography-ref-file and :typography-ref-id (both defaulting to nil)
to default-text-attrs so these keys are always present in text node maps,
whether or not a typography is attached.
Skip nil values in attrs-to-styles (Draft.js style encoder) and in
attrs->styles (v2 CSS custom-property mapper) so nil typography-ref
entries are never serialised to CSS.
Replace when with if/acc in get-styles-from-style-declaration to prevent
the accumulator from being clobbered to nil when a mixed-value entry is
skipped during style decoding.
* 🎉 Add test
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🎉 Add get-file-stats RPC command
Introduce a new lightweight RPC query that returns aggregate statistics
for a single file: page count, shape counts by type, component/color/
typography counts, and inbound and outbound library reference counts.
Mirrors the existing get-file-summary permission and decoding pattern.
Useful for plugin authors enforcing per-file budgets, the
@penpot/library npm SDK, and future admin dashboards. Purely additive
— no migrations, no UI, no breaking changes.
Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>
* 🐛 Bind *load-fn* around file data walk in get-file-stats
The binding previously wrapped only — a plain key
lookup that does not realize any pointers — so by the time
walked and accessed on
each page, was unbound and every PointerMap
dereference threw , failing the three new tests.
Move inside the form so the walk runs
with available, matching the existing pattern used in
.
Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>
---------
Signed-off-by: edwin-rivera-dev <bytelogic772@gmail.com>
Signed-off-by: Edwin Rivera <bytelogic772@gmail.com>
Three critical fixes for app.common.geom.shapes.grid-layout.layout-data:
1. case dispatch on runtime booleans in get-cell-data (case→cond fix)
In get-cell-data, column-gap and row-gap were computed with (case ...)
using boolean locals auto-width? and auto-height? as dispatch values.
In Clojure/ClojureScript, case compares against compile-time constants,
so those branches never matched at runtime. Replaced both case forms
with cond, using explicit equality tests for keyword branches.
2. divide-by-zero guards in fr/auto/span calc (JVM ArithmeticException fix)
Guard against JVM ArithmeticException when all grid tracks are fixed
(no flex or auto tracks):
- (get allocated %1) → (get allocated %1 0) in set-auto-multi-span
- (get allocate-fr-tracks %1) → (get allocate-fr-tracks %1 0) in set-flex-multi-span
- (/ fr-column/row-space column/row-frs) guarded with (zero?) check
- (/ auto-column/row-space column/row-autos) guarded with (zero?) check
In JS, integer division by zero produces Infinity (caught by mth/finite),
but on the JVM it throws before mth/finite can intercept.
3. Exhaustive tests for set-auto-multi-span behavior
Cover all code paths and edge cases:
- span=1 cells filtered out (unchanged track-list)
- empty shape-cells no-op
- even split across multiple auto tracks
- gap deduction per extra span step
- fixed track reducing budget; only auto tracks grow
- smaller children not shrinking existing track sizes (max semantics)
- flex tracks causing cell exclusion (handled by set-flex-multi-span)
- non-spanned tracks preserved via (get allocated %1 0) default
- :row type symmetry with :column type
- row-gap correctly deducted in :row mode
- documents that (sort-by span -) yields ascending order (smaller spans
first), correcting the misleading code comment
All tests pass on both JS (Node.js) and JVM environments.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
In the CLJS branch of resolve-modif-tree-ids, get-parent-seq returns
shape maps, but the js/Set was populated with UUIDs. As a result,
.has and .add were passing full shape maps instead of their :id
values, so parent deduplication never worked in ClojureScript.
Fixed both .has and .add calls to extract (:id %) from the shape map.
Also update the collinear-overlap test in geom-shapes-intersect-test
to expect true now that the ::coplanar keyword fix (commit 847bf51)
makes on-segment? collinear checks actually reachable.
drop_area.cljc: v-end? was guarded by row? instead of col?, making
vertical-end alignment check fire under horizontal layout conditions.
Aligned with v-center? which correctly uses col?.
positions.cljc: In get-base-line, the col? around? branch passed 2 as
a third argument to max instead of as a divisor in (/ free-width
num-lines 2). This made the offset clamp to at least 2 pixels rather
than computing half the per-line free space. Fixed parenthesization.
layout_data.cljc: The second cond branch (and col? space-evenly?
auto-height?) was permanently unreachable because the preceding branch
(and col? space-evenly?) is a strict superset. Removed the dead branch.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
All concrete constraint-modifier methods accept 6 arguments
(type, axis, child-before, parent-before, child-after, parent-after)
but the :default fallback only declared 5 parameters. Any unknown
constraint type would therefore receive 6 args and throw an arity
error at runtime. Added the missing sixth underscore parameter.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
When both dot-x and dot-y were negative (both axes flipped),
(update :rotation -) was applied twice which cancelled itself out,
leaving rotation unchanged. The intended behaviour is to negate
rotation once per flip, but flipping both axes simultaneously is
equivalent to a 180° rotation and should not alter the stored angle.
Replaced the two separate conditional rotation updates with a single
one gated on (not= (neg? dot-x) (neg? dot-y)) so the rotation is
negated only when exactly one axis is flipped.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
orientation returns the auto-qualified keyword ::coplanar
(app.common.geom.shapes.intersect/coplanar) but intersect-segments?
was comparing against the plain unqualified :coplanar keyword, which
never matches. This caused all collinear/on-segment edge cases to be
silently skipped, potentially missing valid segment intersections.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
update-rect with :size type was only updating :x2 and :y2 but not
:x1 and :y1, leaving the Rect record in an inconsistent state (x1/y1
would not match x/y). Aligned its behaviour with update-rect! which
correctly updates all four corner fields.
corners->rect was calling unqualified abs which is not imported in
app.common.geom.rect namespace. Replaced with mth/abs which is
the proper namespaced version already available in the ns require.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
gpt/multiply had a copy-paste docstring from gpt/subtract claiming it
performs subtraction; corrected to accurately describe multiplication.
gpt/abs was using clojure.core/update on a Point record, which returns
a plain IPersistentMap instead of a Point instance, causing point?
checks on the result to return false. Replaced with a direct pos->Point
constructor call using mth/abs on each coordinate.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
matrix->str was producing malformed strings like '1,0,0,1,0,0,'
instead of '1,0,0,1,0,0', breaking string serialization of matrix
values used in transit and print-dup handlers.
Also remove the first pp/simple-dispatch registration for Matrix at
line 362 which was dead code shadowed by the identical registration
further down in the file.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Clojure's = uses .equals on doubles, and Double.equals(Double.NaN)
returns true, so (not= v v) was always false for NaN. Use
Double/isNaN with a number? guard instead.
The CLJS branch of num-string? checked (string? v) first, but the
JVM branch did not. Passing non-string values (nil, keywords, etc.)
would rely on exception handling inside parse-double for control
flow. Add the string? check for consistency and to avoid using
exceptions for normal control flow.
The removev function used 'fn' as its predicate parameter name,
which shadows clojure.core/fn. Rename to 'pred' for clarity and
to follow the naming convention used elsewhere in the namespace.
When called with an empty string as the base class, append-class
was producing " bar" (with a leading space) because (some? "")
returns true. Use (seq class) instead to treat both nil and empty
string as absent, avoiding invalid CSS class strings with leading
whitespace.
The :else branch of diff-attr was calling (get m1 key) and
(get m2 key) again, but v1 and v2 were already bound to those
exact values. Reuse the existing bindings to avoid the extra
lookups.