generate-update-shapes validates every instance root of the updated
shapes, including all their children. A variant switch syncs the
copies of the changed main in separate commits, and the layout update
it schedules runs 100 ms later through apply-modifiers. When that
update lands before the sync ends, the validation finds a nested copy
whose component-id still differs from its updated main, and raises.
The layout update stops halfway, and the composable test suite fails
in VariantSwitchPropagates.
Transforms only write transform-attrs (geometry, fills, strokes),
which can't break the references between components. Add a
:skip-validation? option to generate-update-shapes and update-shapes,
and set it in apply-modifiers* and apply-wasm-modifiers.
AI-assisted-by: claude-opus-5-5
The docstrings of generate-add-new-property and
generate-make-shapes-variant, and a comment in
generate-make-shapes-no-variant, described who calls them and how.
Say instead that they are part of a higher level operation, and that
the validation must run once the whole operation is complete.
AI-assisted-by: claude-opus-5-5
generate-update-property-name and generate-remove-property change all
the components of the variant, and validated each main instance. The
container validation checks every main instance too, and also that all
the components keep the same property names, which is what these
functions may break. Validate the container instead.
AI-assisted-by: claude-opus-5-5
generate-duplicate-changes looked for the instance roots of the copied
shapes in the page they were copied from, and mapped them to the new
ids. Pasting a main into another main creates a nested copy, but the
copy was validated on its own and reported :nested-copy-not-allowed.
Shapes copied from inside a component and pasted outside were not
validated at all: the unchanged source root was validated instead.
Look for the instance roots from the new shapes, in the objects of the
changes builder, where they already are in their new place.
This fixes three tests in frontend pasting_in_containers_test.
AI-assisted-by: claude-opus-5-5
generate-make-shapes-variant does not validate, as its callers may run
it before the shapes are inside the container. Validate in the callers
instead, once the action is complete:
- generate-relocate validates the variant containers the variants come
from (unless they are deleted for being empty) and the one they go
into.
- prepare-restore-component validates the container when it restores a
component into a variant.
- duplicate-variant validates the container after moving the new
variant into it.
AI-assisted-by: claude-opus-5-5
generate-add-new-property changes all the components of the variant, so
it now validates the variant container.
generate-make-shapes-variant calls it while some shapes of the container
may not be variants yet, e.g. when a component is restored into the
container, which gave a false :main-instance-not-a-variant. Reordering
the calls changes the resulting property names, so add a
:skip-validation? option instead. generate-make-shapes-variant uses it
and does not validate on its own: its callers validate the container
once the shapes are inside it.
AI-assisted-by: claude-opus-5-5
generate-new-shape-for-swap set the swap slot after instantiating the
new copy, through change-touched. The validation made when the copy is
instantiated ran before that and reported :missing-slot.
The slot only depends on the old shape, so compute it before the copy is
created and pass it to generate-instantiate-component with the new
:swap-slot option. The copy is added with its slot, and the validation
sees it complete. The resulting file and its undo are the same as
before.
AI-assisted-by: claude-opus-5-5
generate-instantiate-component validated the new copy on its own. When
the copy is created inside another component it is a nested copy, but
the validator starts with a :not-component context and reported
:nested-copy-not-allowed, a false error that broke 34 common tests on JS.
When the new copy is not an instance root, validate the instance roots
that hold its parent instead. The validator then walks the copy with the
right context, and also checks the parents that may change (e.g.
layouts).
AI-assisted-by: claude-opus-5-5
Fix whitespace reported by check-fmt:clj in files/variant.cljc,
logic/variant_properties.cljc and logic/libraries.cljc.
AI-assisted-by: claude-opus-5-5
prepare-restore-component and generate-delete-shapes fall back to other
data when the changes builder has no library data, objects or page. Use
an if on the has-*? check instead of (or (when ...) ...), which is
easier to read.
AI-assisted-by: claude-opus-5-5
apply-changes-local and the page index worker drop the :validate-shapes
changes with the same inline filter. Move it to
app.common.files.changes as skip-validate-changes, next to the
:validate-shapes change, so it is easy to find every place that skips
the validation if it changes in the future.
AI-assisted-by: claude-opus-5-5
generate-update-shapes only validates changes made on a page, and it
checked that with (and page-id ...) after pcb/get-page-id. The getter
now asserts that a page-id was given, so the check could never be false
and changes on a component container would fail on the assert instead of
skipping the validation.
Add pcb/has-page-id? and check it before calling the getter.
AI-assisted-by: claude-opus-5-5
generate-make-shapes-no-variant now validates the main instances it
takes out of the variant. Only those main instances change. The old
container is not validated, as callers such as generate-relocate call
this function before they move the shapes out of it.
Check with thf/validates-shapes-last? that the function validates the
expected page and shapes.
AI-assisted-by: claude-opus-5-5
The changes builder getters now assert that their context was given,
but prepare-restore-component and generate-delete-shapes relied on them
returning nil to fall back to other data. The workspace restores
components with changes that carry no library data, so restoring a
main component or a variant from one of its copies failed.
Add has-library-data?, has-objects? and has-page? to the changes builder,
and check them in those fallbacks before calling the getters.
Add tests that restore a component, and a variant whose container was
deleted, with changes that carry no library data, as the workspace does.
AI-assisted-by: claude-opus-5-5
generate-reorder-variant-poperties now validates the variant container
when the reorder changes something. All the components of the variant
change, so validating the container checks every main instance and that
all of them keep the same order of properties. A reorder that changes
nothing still returns the same changes, with no validation.
Check with thf/validates-shapes-last? that the function validates the
expected page and container.
AI-assisted-by: claude-opus-5-5
generate-set-variant-error now validates the main instance of the
component once its :variant-error is set or cleared. The change only
touches that main instance, so there is no need to validate the whole
variant container.
Check with thf/validates-shapes-last? that the function validates the
expected page and shapes.
AI-assisted-by: claude-opus-5-5
generate-update-property-value now validates the main instance of the
component once the new value is set. The change only touches that
component and its main instance, so validating the whole variant
container would also check the variants that did not change.
Add thf/validates-shapes-last?, a test helper that checks that the last
redo change is a :validate-shapes change for the expected page and
shapes, and use it to test the function. Register
variant-properties-test in the runner so that it also runs on JS.
AI-assisted-by: claude-opus-5-5
apply-changes-local replays the pending redo changes on a local copy of
the data, so that later builder calls can read up-to-date values. It
replayed :validate-shapes too, so the validator ran on that copy, which
has no real file id and no libraries, and reported false errors such as
:component-main-external. This broke Ctrl+K on a component when it
duplicates the main instance and resizes the variant container.
Skip :validate-shapes when applying changes locally.
AI-assisted-by: claude-opus-5-5
* 🐛 Remap library refs inside deleted components on absorb
Deleted components keep their shapes in :objects, to be restored and
to resolve their remaining copies. When a library was absorbed into a
file (export with libraries merged, unsharing or deleting the
library), absorb-assets only looked for usages in pages and in live
components. The shapes inside deleted components kept pointing to the
library, so they no longer matched their copies in the pages: the file
failed validation with :component-id-mismatch and could not be saved.
Also look for usages in deleted components, so their library
components, colors and typographies are remapped to the local file.
Relates to #10806
AI-assisted-by: claude-opus-5-5
* 🐛 Persist pointer-map fragments when absorbing a library
absorb-library-by-file saved the file without its :features. Without
"fdata/pointer-map" there, encode-file does not persist the changed
fragments, so in files with pointer maps the pages and components
that absorb-assets remapped were never written. After unpublishing or
deleting a library, its consumers failed to load with "fragment not
found".
Pass :features to update-file!. Exporting with shared libraries does
not go through this path, so it was not affected.
AI-assisted-by: claude-opus-5-5
Setting a library as tokens source did not update shapes that use tokens,
because set-tokens-source never ran the token propagation that every other
token operation runs. Run it after the commit.
Going back to the local tokens, or unlinking the library that was the
source, left no local set active: the file has a single tokens status, and
switching to a library replaced it with the library's one. Keep the status
of the file's own tokens lib aside in a new optional :own-tokens-status
attribute while an external library is the source, and restore it when the
file is its own source again. Exporting without libraries and absorbing a
deleted source library restore it as well.
Closes#12430
AI-assisted-by: claude-opus-5-5
Move the backend JDK to its latest maintenance build within Java 26 and
refresh the dependency versions pinned by hand in the development and
production images.
- Backend: Zulu JDK 26.0.2 to 26.0.2.1. The jlink module list keeps
jdk.management, so the prometheus process metrics stay exported.
- Node.js 24.22.1 in .nvmrc, the development image tarball and the two
plugins deploy workflows that pin it.
- pnpm 12.15.0 in the four Dockerfiles, the 34 packageManager fields and
the eleven lockfiles.
- Development tools: clj-kondo 2026.10.20, Babashka 1.13.226, Pixi
0.83.0, GitHub CLI 2.103.0, AWS CLI 2.38.5 and uv 0.13.5.
- Infra services: RustFS 1.0.3, Mailpit v1.32.0, and Valkey pinned to
8.1.10 instead of the floating 8.1 tag.
Fix SERENA_UPDATE_VERSION in the compose file. A value there makes the
start script reinstall that Serena release on every start, overriding
the one baked into the image; it was replacing 1.7.0 with 1.6.1.
The hardened base image updates are left to Renovate.
Closes#12599
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Replace the @penpot/tokenscript npm package (the TypeScript
interpreter) with penpot/tokenscript-interpreter, a Clojure and
ClojureScript interpreter that now lives in its own repository and
compiles into the frontend build.
The frontend adapter (app.main.data.tokenscript) resolves tokens with
it behind the existing :tokenscript flag and maps its results to the
values and errors the workspace uses:
- Token values are validated by the interpreter's token types, passed
by their DTCG names: `1foo` is not a dimension, a number has no
unit, an opacity is between 0 and 1. Penpot only adds its own
limits (font weight names, text decoration keywords).
- Division by zero, incompatible units, unknown functions and
function arguments out of range get messages of their own, and
token pills show the message of their error.
- Font family names are quoted before resolving, as in the adapter
this replaces (#11750): `Red Hat` stays one name.
The token form's value input passed the token name to the tokenscript
resolver in place of the typed value, and no name at all for a new
token. It now resolves the typed value under the form's token name, as
the StyleDictionary path does.
StyleDictionary stays the default until the interpreter is stable.
frontend/deps.edn pins the interpreter by tag and commit, v0.1.0.
Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com>
* 🐛 Fix legacy path migrations on content without coordinates
PRE still returned 500 on get-team-shared-files after #12357, now with
"expected rect instance" in legacy-6. When a path has no points, legacy-6
rebuilds its rect from the content, but content whose points are not
numbers (NaN or infinite coordinates, segments without params, only a
close path) gives no rect either, and rect->points rejects nil.
Fall back to the empty rect in legacy-6. A path with segments missing
coordinates then failed later, in 0003-convert-path-content-v2, since the
binary path data can not store them; drop those segments there.
Relates to #12343
AI-assisted-by: claude-opus-5-5
* 🐛 Skip shared files that fail to load in get-team-shared-files
get-team-shared-files loads and migrates every shared file of the team,
so one file whose migration fails made the whole request return 500 and
the "Add library" modal could not open.
Catch the error per file, log it with the file and team ids, and leave
that file out of the list. The other libraries still show, and the error
report names the broken file.
Closes#12343
AI-assisted-by: claude-opus-5-5
Noto Sans Symbols lacks most arrows and math operators. Fall back to
Noto Sans Math for them, and to Noto Sans for letterlike symbols.
Closes#12469
AI-assisted-by: claude-opus-5-5
get-initials used an ASCII-only [a-zA-Z] match, so names like Ábaco
and Órbita skipped the leading accented letter. Match Unicode letters
with \p{L} (CLJS RegExp + u/g flags) so org default avatars match the
expected first letter. Closes#12324.
AI-assisted-by: Cursor <cursoragent@cursor.com>
With the SVG renderer, resizing a shape with an image fill and the mod
key showed the picture scaled during the drag, as in a normal resize.
The crop only showed after releasing the mouse.
The SVG preview applies the resize matrix to the shape node and ignores
the fill transform change carried by the modifiers. Now the pictures of
the shape fill patterns get a matrix that takes them from the old fill
transform to the new one, which undoes that resize and keeps them in
place. This works for shapes and boards.
Closes#12349
AI-assisted-by: claude-opus-5-5
* 🐛 Fix token tree collapsing repeated path segments
Tokens whose path contains the same segment name twice in a row
(e.g. test.pointer.pointer) collapsed to a single level in the
token tree.
build-tree-node marked a node as a leaf when the current segment
name matched the first segment of every remaining path. Because
group-by-first-segment rewrites nested paths into the segment
name, a still-nested remaining path was indistinguishable from a
token ending at the current node, so test.pointer.pointer merged
into one leaf.
Mark rewritten segments with an internal ::remaining? marker and
strip it when the rest-path is fully consumed; is-leaf? only
accepts unmarked remaining segments as leaves.
Closes#11588
AI-assisted-by: glm-5.3-flash
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Preserve ancestor tokens in path trees
Keep a node's token and descendants independently when imported paths
contain both an ancestor and a child. Render both in the token tree and
include the ancestor token in existing path-based group operations.
Cover accepted same-set imports, leaf IDs, repeated segments, and internal
marker removal without changing import or collision validation.
AI-assisted-by: GPT-6
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* ♻️ Rework token tree path building
Carry the segments left to descend as a ::remaining-path vector on each
segment and advance it by subvec, instead of rewriting the segment :name
and re-splitting it later.
With that, build-tree-node finds the leaf token with a predicate over
::remaining-path and takes children from the segments that still have
one, so the pre-grouping children-first sort no longer changes the
result. Drop sort-children-first and its segment-has-children? helper,
and document inside-path?.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix and relocate the token tree tests
The ancestor-and-descendant test called a function that does not exist
in app.common.types.tokens-lib, so the namespace failed to load and the
whole common suite errored. Use make-tokens-status-from-lib and the
two-argument get-tokens-in-active-sets instead.
Move the build-tree-root tests to path-names-tree-test so
path-names-test stays a pure split/join unit test.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Refactor the token tree rendering and share path helpers
Extract the token pill into a leaf-node* component and treat a node as a
leaf only when it holds a token and has no children, so a node can carry
both a token and its children.
Make token-separator public and reuse it from the token tree and the
management panel instead of local copies. Both management path filters
inlined the same ancestor-or-descendant rule; extract it as
token-in-path? and cover it with a unit test.
AI-assisted-by: deepseek-v4.1-flash
---------
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: luisddm <luis.dedios@kaieldos.net>
Bump the pnpm pin to 12.14.0 in the devenv and the production images
and stamp the same version into every package.json, realigning the
drift between the devenv (12.9.1) and the rest (12.6.0).
Bump Node to v24.22.0, the newest patch of the Krypton LTS line, in
.nvmrc, the devenv and image Dockerfiles and the plugins-deploy
workflows.
The pnpm arch checksums are those of the GitHub release tarballs, not
of the npm package. Lockfile changes are metadata only.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix silent failure when uploading custom fonts
Custom font upload crashed with re-seq must match
against a string when opentype.js returned nil
subfamily names, leaving the user with no feedback.
Unknown signatures and unparseable files were also
silently dropped.
Make variant parsing nil-safe, harden entry
preparation for missing tables, report every
per-file failure with a bad-font notification, and
show a generic error if the stream itself fails.
Closes#12304
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Rename font mtype helper and mark helpers private
Avoid confusion with app.worker.import/parse-mtype,
which detects binfile signatures. The font helper now
lives as parse-font-mtype, and the upload helpers only
used internally are private, with tests reaching them
through var references.
AI-assisted-by: muse-spark-1.3-contributor
* 💄 Fix formatting in font upload tests
Realign map keys shifted by the var-quote prefix so
cljfmt check passes again. No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix infer warnings in fonts metrics code
Add missing ^js type hints to the hhea and os2 metric lookups in
prepare-font so shadow-cljs can infer the interop targets and the
release build reports zero warnings.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Ensure normalized names in components and variants
We have a rule of that shape names that include / separator should have
it normalized, with one space before and one after. But this rule is
being enforced only in the UI and the plugins API. There may be other
ways of creating shapes that does not follow it.
For normal shapes this is not really a problem. But for main instances
it may cause that the name of the shape does not match the joined
path+name of the component, and this may generate some problems.
We have decided to add a migration to fix this in all main instances and
variant containers and enforce it in the future with integrity
validations (normal shapes may be left as is).
* 🐛 Fix bad variant-id in the component and the main instance
* 🔧 Fix some issues detected in PR review
* 🐛 Add nil guard