Bring iop-vc-preview up to develop 43f3bda8b5, which carries the
final squash of penpot#12420 (93d36d04d8).
iop-vc-preview carried an earlier copy of penpot#12420 (42a2e33ffe
and a6c20b3e95). Every file the early or the final copy touches was
merged three-way with a6c20b3e95 as the base, so develop's side wins
for every penpot#12420 hunk and iop-vc-preview keeps only its own
deltas. Changes from other develop PRs (#12524, #12192, #12731,
#12686, #12442) are kept.
Adaptations to develop:
- files_update.clj: apply-changes returns the triple
[file op-changes media-index] on the #12524 auto-repair path too,
and a repaired branch save logs the repair changes ahead of the
client's ones.
- files.clj: absorb-library-by-file takes develop's update-file! map.
- migrations.cljc: migrate keeps its target arity and develop's
ordered :migrations set.
- climit.edn: the branch global limits declare a queue, as #12686's
example-config-bounds-every-wait requires.
- Tests: the repair tests destructure the triple, the branch import
test uses the #12192 export cfg keys, and the ref-paths test
classifies :set-own-tokens-status.
- Dashboard comments and placeholder take develop's design-system
icons around iop-vc-preview's review list; en.po and es.po keep the
union of both sides.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* ✨ Stabilize fragmented file data across snapshots and GC
Snapshots, automatic and user, now store pointer-based rows that share
fragments with the live file instead of duplicating page content.
`get-file-snapshot` resolves those pointers on the server so version
previews keep rendering plain data, and `restore!` reuses the shared
rows. Export still materializes (the package carries no fragments) and
import regenerates them.
`file-gc` treats the main file and every unpurged snapshot as liveness
roots, reads the file unrealized, and reattaches the original pointer
wherever the cleaned value is unchanged, so a pass over an untouched
file writes no fragments and a pass over an edited file keeps the ids
of the pages it did not touch. Snapshot creation and the restore
backup take the file row `FOR SHARE`, which serializes them with the
pass and closes the race that let a new snapshot reference fragments
the pass was about to mark.
All backend pointer bindings go through the `fdata/with-pointers`,
`with-tracked`, `with-tracked-pointers` and `with-untracked` macros.
The loader reads fragments a previous pass marked until `objects-gc`
purges them, and the frontend fails loudly with retry instead of
dropping an unresolved fragment.
Review fixes in this branch: the automatic snapshot attaches before
pending migrations, `realize-pointers` is the single verb,
`reattach-unchanged` takes the identity fast path, the restore guard
takes its revn from the backup read, and `PointerMap.withMeta`
generates a fresh id when metadata changes.
Tests cover both snapshot writers (red without the lock), snapshot
sharing and pin release through the real delete and purge paths,
reattach against orphan marking, preview after offload, the pointer
binding contracts and the with-meta id change.
Closes#12410
AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: deepseek-v4.1-flash
♻️ Pin no-churn behavior for untouched pages
Pin the behavior the churn verification measured: a save keeps the
fragment id of every page it does not touch, with a page of eleven keys
(PersistentHashMap), a 46-shape objects map and a nine-page index (hash
map). Also pin the mixed case: a data-level objects-map flip rewrites
the old pointer pages once and the next save keeps the new ids.
The memory records the identity rule behind it: re-assoc with the same
value returns the same map in array and hash maps alike.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
⚡ Stop loading untouched pages when enabling objects-map
`enable-objects-map` wrapped every page on every save, and reading a
page's objects forced its PointerMap to load the fragment. A save of
one page in a seven-page file therefore made seven fragment reads.
Skip unloaded pointers: their fragment was already wrapped by the
encode-file that wrote it, so loading it only to wrap it again is a
no-op. The save now loads only the pages it edits. A page with a plain
fragment (a data-level feature flip) keeps its plain fragment until it
is first loaded or edited; a migration that enables the feature must
rewrite the data itself.
Cover the change with load-count tests for save and restore, update the
flip test to the new contract, and drop the stale restore comment.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
⚡ Keep page fragment ids through pending migrations
Applying a pending migration on read realized the file, ran the
migration and persisted it again; the persist wrapped every plain page
in a new PointerMap, so every page got a new fragment id even when the
migration did not change it. That leaves the fragment cache cold and
rewrites every fragment on the first read after a deploy.
Capture the original pointers before realizing, reattach the unchanged
positions after migrating (same value comparison file-gc already uses),
persist the reattached file and return the realized one: the read
contract is plain data. Read-only connections skip the persist and the
reattach. A migration that adds objects-map skips the reattach so the
pages get wrapped.
Move the three helpers to fdata so file-gc and the migration path share
them, and cover the path with a real 0031 migration test: the unchanged
page keeps its id, the changed page gets a new one, the caller gets
realized data and a second read does not migrate again.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Realize library pointers with each library's own loader
Fragment rows are keyed by file id, and every caller binds the
fragment loader of the file it works on. get-resolved-file-libraries
returned libraries with their pages still as pointers, so the first
read of a library page under fdata/pointer-map asked the consuming
file for the fragment and failed with :fragment-not-found. That
broke migrations and file validation for every file using a
pointer-mapped library.
The weak-map loader now realizes each library's pointers with the
library's own id. The test fails without the change.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* 🐛 Restore the pointer-map require the repair path still needs
The branch dropped `app.util.pointer-map` from the namespace while the
auto-repair path (added to develop before this branch) still rebinds
`pmap/*tracked*` around validation and repair in
`repair-preexisting-damage` and `process-changes-and-validate`. The
namespace did not compile, so the whole backend failed to load.
Those toggles do not fit the fdata macros: `with-untracked` binds nil
and `with-tracked` creates a fresh atom, while the repair path has to
turn tracking off and restore the existing atom (and read it before the
repair starts). Restore the require and keep the bindings explicit.
Also pin the feature-off path with a test: with `fdata/pointer-map` off
(the default), a save creates no fragments, file-gc writes nothing and
the snapshot preview still returns plain data. Drop a stray `\n` char
literal left at the end of one deftest.
Update the memories with the exception to the macro rule, the snapshot
FOR SHARE locking and the pre-migration automatic snapshot.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Validate debug files with their own pointer loader
The `/dbg/actions` file validation passed the file id where
`get-resolved-file-libraries` expects the file map, so the library map
was seeded with a nil key. It also validated a file read without
realizing it, so the first page lookup of a pointer-mapped file raised
"load is not supported when *load-fn* is not bind" and the endpoint
could not validate exactly the files the feature touches.
Read the file realized (validation walks every page and component
anyway) and build the library map from the file. The library loader
already resolves each library's pointers with its own id, so the
realized file and its libraries validate as plain data. Covered by a
handler-level test that fails before the change.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Bind the snapshot loader through the fdata helper
`reduce-snapshots` hand-rolled the same `binding` that
`fdata/with-pointers` builds (it also loaded the function's own
pointer loader before entering the reduction). Use the macro so the
rule holds there too and the namespace no longer needs
`app.util.pointer-map`; the loader is identical (same cfg and file-id)
and snapshot walking behaves the same.
AI-assisted-by: deepseek-v4.1-flash
* 📚 Note the pointer-binding exceptions and snapshot ordering
Record what the feature review settled:
- Two direct `binding`s stay on purpose: the fine-grained `*tracked*`
toggles of `app.rpc.commands.files-update` (validation/repair turn
tracking off and restore the existing atom) and
`binfile.common/encode-file` (fresh tracked set inheriting the
current one). Atom-scoped macros would only be worth it if the set
grows.
- The automatic snapshot is taken before the pending migration, and
the pre-migration window is narrow: the `get-file` RPC migrates on
read and its ETag hashes the available migrations, so a post-deploy
read cannot be a 304 from the pre-deploy state. The old-format
snapshot only appears when the save is the first request to touch
the file after the deploy, and `get-file-snapshot` previews it as
stored.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Apply pending migrations when previewing a version
Automatic snapshots are attached before the save applies pending file
migrations, so a version can store the old format. The preview RPC
handed that data to the UI as stored, and the viewport renders it with
the current code.
Realize the snapshot with the live file id (fragments are keyed by
file, not by snapshot), normalize its migrations to a set (the decoder
keeps the pgarray as a vector, and `set/difference` would compare
indexes) and apply the pending migrations with the pure
`fmg/migrate-file` and the live libraries. Nothing is persisted: the
stored version keeps its data and restoring it re-migrates on the next
read. The response now carries the snapshot's migrations.
Use `:features` the way `get-file` does for `fdata/objects-map`: a
client that does not declare it gets plain objects. The historical
feature set is not checked against the enabled one on purpose: a
version may predate a feature change.
Cover the preview of a snapshot with a pending migration and the
objects realization; both pin that the stored row is untouched. Update
the file-data memory with the preview contract and the vector/set
trap.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Decode snapshot migrations as an ordered set
`file-snapshots/decode-snapshot` handed the `file_change.migrations`
pgarray as a vector. `set/difference` falls back to index lookups when
the first collection is shorter than the second, so the pending set was
right only by accident: a stored subset happens to work, while a
stored set larger than `available-migrations` (a snapshot taken by a
newer build) marks every available migration as pending and re-applies
it to already migrated data.
Decode it as a `d/ordered-set`, the type the rest of the code and
`schema:file` expect. This also covers `restore!` and the snapshot
list, and it lets `get-file-snapshot` drop the `(into #{} ...)`
normalization it was carrying. Re-wrap the union in `fmg/migrate` so
the invariant holds even when a caller hands a plain set.
A snapshot with NULL `migrations` predates SQL migration 0140 and
keeps the format version of its time. Pin that legacy path: the
preview regenerates the applied set from that version, migrates in
memory and leaves the stored row untouched. Update the file-data
memory with the invariant and the legacy path.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix flaky waiting-gauge assertion in climit test
The throwable-while-waiting test asserted the exact list of
waiting-gauge publications, but the component sampler republishes
the same count when its first tick lands inside the recorded
window under load. Assert only the last value, which is the
contract the helper documents.
AI-assisted-by: muse-spark-1.3
---------
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* 🐛 Fix stack overflow when applying a library update
Each shape updated by a component sync added its reg-objects undo change
with (update :undo-changes concat [...]). That wrapped the whole undo list
in one more lazy concat per shape, with the old list at the head. After a
few thousand shapes, walking the list overflowed the stack, which matches
the nested LazySeq frames reported when a library update is applied.
add-update-attr-changes now conjs the reg-objects before the mod-obj, so
undo restores each shape and then recalculates its parents, as redo does.
The trailing reg-objects also recalculated groups after undoing shapes
that the sync added or removed. To keep that, add-shape-to-instance,
add-shape-to-main and remove-shape now add their own undo reg-objects,
conjed before their other undo changes so it runs after them.
Tests cover many calls to add-update-attr-changes, a sync and undo of
many shapes, and undoing a sync that moves, adds or removes a child of a
group in the copy.
Closes#12576
AI-assisted-by: claude-opus-5-5
* 🐛 Test undo of an inverse sync that adds a shape
The previous commit added an undo :reg-objects to add-shape-to-main, so
undoing an "update main component" recalculates the main's groups after
removing the added shapes. No test covered it.
The new test adds a shape at the start of a group in the copy and changes
a sibling's fill, runs the inverse sync and undoes it. Without the undo
reg, the main group keeps the bounds of the removed shape.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep swapped and moved children in place when a component syncs
A sync resets an untouched shape of a copy to the main's geometry and the
layout then moves it back. Children the sync keeps, because they are
swapped or the user moved them, did not take the first move but did take
the second, so each sync pushed them away from their parent by the gap
between the copy and the main (e.g. a swapped icon in a copy that fills a
wider layout).
The sync now moves those children by the offset it applies to their
parent. Flex-child attributes still sync as before, so swapped children
keep their absolute position.
Closes#12657
* 🐛 Place swapped children where the swap puts them on sync and switch
A swapped child that the user never moved now goes to the top-left of the
shape it replaced, as a swap does: on a sync, the place of the main's slot
occupant; on a variant switch, the place of the new variant's occupant.
A swapped child the user moved keeps its offset inside its parent.
This stops the variant switch from leaving the swapped child at its old
position while the new variant is created at the main's size, and puts
back children that drifted before the sync fix the next time their copy
syncs.
* 🐛 Make kept children follow their parent's constraints on component sync
When a sync resets the size of a copy's shape, for example a copy root
that fills a wider layout, the layout then grows it back and moves its
children by their constraints. Children the sync keeps (moved by the
user, or swapped) only followed the parent's top-left, so with a right,
center or scale constraint they drifted on every sync, and a swapped
child with a scale constraint grew on every sync.
The sync now passes the parent's geometry before and after down the
tree, and kept children follow that change through their constraints.
Untouched swapped children also undo the change through their
constraints before they go to the swap place, so they keep their size.
The default constraint modifier returns empty modifiers instead of an
empty vector, which add-modifiers could not take.
* 🐛 Make swapped children follow their parent's constraints on switch
A variant switch only moved a swapped child by the parent's position
change. When the new variant's root had a different size, a user-moved
child with a right constraint drifted, and a scale-constrained one kept
its grown size.
The switch now applies the parent change from the old parent to the new
one through the child's constraints. An untouched swapped child then
takes the place of the shape it replaces, as a swap does.
* 🐛 Sync nested swapped copies before the copies that hold them
A swapped child is a copy of its own component, so one sync handles it
twice: once on its own, and once when the copy that holds it places it.
Each step read the shape from the data as it was before the sync, so
the step that ran last won, and the order came from the order of shape
ids. When the swapped child came last, it wrote its old size and
position back over the placement, leaving its x and selrect apart, and
a swapped child with a scale constraint grew on every sync.
The sync now visits the most deeply nested shapes first, and each step
reads the shapes as the earlier steps left them, so the placement
always comes last and builds on the child's own sync.
* 🐛 Keep moved children with a scale constraint at their size on switch
A variant switch carries the size of a child the user moved as an
override. In a copy that fills a wider layout, a child with a scale
constraint already has the size the layout gave it, so the switch
carried that size onto the new variant, created at the main's size, and
the layout then scaled it again: the child grew on every switch.
Before comparing, the switch now resizes the previous child the way its
constraints follow the change from its old parent to its new one. A
child that only grew with the layout then matches the main and is not
carried as an override.
* 🐛 Keep the content of swapped children at its size on sync and switch
When a sync or a variant switch undid the parent change on a swapped
child through its constraints, it applied the child's resize to every
shape inside it. The layout then grows the child back through its own
constraint, but its content follows its own constraints (left and top
by default), so it stayed shrunk: the shape inside a swapped icon with
a scale constraint lost most of its width on every switch.
The change of the swapped child now goes down its subtree the way a
layout update does, each shape through its own constraints.
* 🐛 Accept mixed and empty values in the layout size constraints menu
With several shapes or a group selected, the sidebar merges the layout
item attributes and tokens of the shapes: nil when a shape lacks one,
:multiple when they differ. The props schema of layout-size-constraints*
only accepted the values of a single shape, so selecting such shapes
with fill sizing raised "invalid props".
The schema now accepts nil and :multiple, as the numeric inputs those
values go to do.
* ♻️ Share the swapped child follow-parent step between sync and switch
The component sync and the variant switch both make a swapped child and
its children follow the parent change through their constraints and then
move them to the swap place. Both now use generate-follow-parent, so the
two paths stay the same. The helper that writes geometry without marking
it touched is renamed to update-geometry-untouched.
Adds a test for a swapped child without absolute position in a slot
without layout, and fixes the test comments on why constraints are set
again after a swap.
pnpm 12.5.1 -> 12.16.1: the tarball pin of docker/devenv and of the
media-processor, exporter and mcp images, the packageManager field of
every workspace manifest, and the lockfile metadata of the 11
workspaces. The new pnpm also normalizes one engine range per lockfile
(>=18.* to >=18).
node 24.21.0 -> 24.22.2: devenv NODE_VERSION and both NODE_SHA256,
.nvmrc, and the runtime pin of the two plugins-deploy workflows. The
dhi.io image tags stay as they are until their digests are available.
AI-assisted-by: deepseek-v4.1-flash
The copies summary now follows the same cascade the post-merge sync
does: when a copy that follows a changed component sits inside another
component's main, that outer component's copies are listed too, marked
with the component they follow through. The summary is computed only
when a caller asks for it (the compare view and the conflict
responses), so the ahead and behind counts skip it.
AI-assisted-by: claude-opus-5-5
The merge summary now says, for each component whose main the incoming
side changed, which copies will follow it after the integration and
which keep an override, per sync group and token slot. Each copy entry
lists what the copy rule settled, with the side and the reason, and
what the user still decides. A copy the rule settles entirely stays in
the summary as an informational entry instead of disappearing, and
applies nothing. A conflict on a component's main counts the copies
its resolution also decides.
AI-assisted-by: claude-opus-5-5
A copy's applied tokens were one attribute outside every sync group,
so when both sides applied different tokens to a component, every
untouched copy showed a conflict of its own.
The copy rule now covers applied tokens one slot at a time, as
Penpot's own component sync does: a slot follows the component unless
the sync group of the attribute it drives is overridden. A token
applied to a copy by hand on one side wins, and only a slot both sides
applied differently by hand stays a conflict. The side the user picks
for that conflict decides only the open slots.
AI-assisted-by: claude-opus-5-5
A copy whose override the base holds can be reset on one side while
the other side leaves it alone. The tests check that the reset reaches
the other file in both directions: the copy loses its override, takes
the component's value, and follows a later component edit. The merge
direction failed before copies followed their component.
AI-assisted-by: claude-opus-5-5
A merge or an update from main compared a component's main and each
copy as separate shapes. A copy added on the other side kept the old
look, and when both sides changed the component the dialog showed one
conflict per copy, so a copy could end up disagreeing with its
component without being marked as overridden.
Copies now follow the merged component, as they do when the component
is edited. Per copy attribute: if no side overrode it, it raises no
conflict and follows the component; if one side overrode it, that
side wins; only overrides on both sides that differ stay a conflict.
After the merge or update applies, the copies of every local component
whose main changed are synced, nested copies included, and the sync
changes are validated and recorded with the rest. A clean merge of a
copy no longer marks it as overridden on the target.
AI-assisted-by: claude-opus-5-5
The schema safety net covered shapes only. It now also walks the file
data schema and each change type of the change schema, and fails when
a uuid field is neither a reference the remap rewrites nor classified
as none. Behaviour tests plant an id at every reference and check the
remap rewrites it and keeps every other id.
The nets found two gaps, both fixed: `remap-refs` kept the owning
`:file-id` of each media entry, which `remap-changes` already rewrote
in `:add-media`, and `remap-changes` skipped `:assign` operations of a
`:mod-obj`, which the change schema accepts.
AI-assisted-by: claude-opus-5-5
Every branch read and compare re-points the references a document
holds to a file or a media object. The generic walk visited every map
of the document: 704 ms on a file of 90,720 shapes. The relinker now
visits only the places listed in `cfh/shape-ref-paths` (plus the paths
older data versions used, which binfile import still meets), compiled
once into closures that rebuild only what changed: 100 ms on the same
file, with an equal result. `remap-changes` dispatches on the change
type instead of walking whole change maps.
A new test derives every uuid path from the shape schema and fails
when a path is neither listed as a reference nor classified as none,
so a reference field added later cannot be missed silently. The grid
colour schema now declares the library reference it already carried.
AI-assisted-by: claude-opus-5-5
A branch derives its document from main's base snapshot, and the derive
never re-pointed main's ids: :data :id stayed main's, and so did every
inherited reference to the file itself (component instances, library
colour and typography refs). The branch workspace then took its own
components for an external library it never loads and read its own
tokens through main: validation reported :component-main-external and
:component-not-found on a fresh branch, the local component sync
skipped inherited copies, an update from main refused once the branch
held its own copy of a base component, and materialize detached the
main instance of every inherited component.
The derive now sets the branch's id on the document before the replay,
so a change or migration comparing a reference with the document's id
sees the branch, and re-routes main's id to the branch's after it, so
op log rows written before this fix are covered too. The base keeps
main's ids because it shares main's fragments; comparisons already map
the branch's id back to main's.
relink-refs rebuilds a collection only when something inside it
changed (median 803 ms instead of 1506 ms on a 90,612-shape file), and
it now relinks the :ref-file of shadow and grid colours, the key
migration 0015 gave them, which it had missed since.
AI-assisted-by: claude-opus-5-5
The merge engine never compared the file's tokens source, so a branch
that switched its tokens to a library, or back to its own, lost that
change on merge, and main's change was lost on an update from main.
The source is now a mergeable kind, :tokens-source, compared raw (nil
means the file itself, and a value naming the file reads as nil). A
change on one side applies as a :set-tokens-source change, and a
change on both sides is a conflict resolved like the base font size.
remap-refs now also rewrites the document's own :id and its
:tokens-source through the id map, and remap-changes rewrites the
:library-id of a :set-tokens-source change, so a branch document moved
into main's id space names main as itself. The compare panel labels
the new kind.
AI-assisted-by: claude-opus-5-5
When a layer goes, Penpot also removes the prototype interactions that
lead to it and the flows that start on it, and resizes the boards with
a layout around it. The diff lists those changes as entries of their
own, with nothing that ties them to their cause.
The detail pane of an added or deleted layer now ends with what goes
(or comes) with it:
- Interactions that led into it and the flows that started on it, as
warnings, naming the layers and flows they belong to.
- Each board with a flex or grid layout around it, and how it resizes,
or how many of its other layers move when its size is fixed.
- The names of the layers folded under it.
Added and deleted flow entries carry the flow as `:flow`, as shape
entries carry `:shape`, so the pane can name the board a deleted flow
started on.
The compare dialog drew nothing for a deleted layer when the branch was
open, and nothing for an added one when main was: the diff named the
layer by id and label only, and the open file lacks it. The detail pane
was left with the layer's name and nothing else.
Added and deleted shape entries now carry the whole shape, as the
document that holds it has it, under `:shape`. The key names no side,
so it reads the same in both directions.
The previews use it. A layer added or deleted inside a board is shown
in that board on both sides, outlined in green or red on the side that
holds it, so the change reads as what the board gains or loses. The
facts of the detail pane (where it was, type, size) read it as well.
A board that moved or resized now draws at its own size on the side
the open file lacks: the diff leaves out `:selrect` and `:points`, so
they are rebuilt from position and size instead of kept from the open
side.
Merges develop at f6f9df9a1a. Twelve files conflicted; each keeps
both intents.
files_update.clj: develop's per-stage validation timings wrap the
branch save path. `update-file` keeps the branch's early timer and
passes it to `update-file*`, inside develop's try/catch timing log.
`process-changes-and-validate` times the changes application and
still returns the file, the fixed-up change vector and the media
index. The audit props carry develop's revn and modified-at plus the
branch props. The timing test destructures the new return shape.
v3.clj keeps the branch-file overwrite guard and takes develop's
`configure-import-transaction`. migrations.clj puts develop's 0155
to 0157 before the branch's 0800 to 0805, so the branch's stay the
tail of the vector.
errors.cljs checks the merged and archived branch refusals before
develop's retrying case. left_header.cljs keeps the branch wrapper,
the review badge and the branch bar on develop's markup: the status
dot without icons and the DS library icon on the shared badge.
palette.scss and assets.scss keep the branch clearance terms on
develop's logical properties. The test runners, requires and
deftests are unions.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* 🐛 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>
A branch whose only change was file-level plugin data or the base font
size merged with a success response, and the change was lost. The merge
engine compared colours, typographies, components, media, pages, and
tokens only, so such a branch produced an empty summary and the merge
took the empty-changes shortcut.
The engine now classifies two more kinds three-way: file plugin data,
flattened to namespace and key as page plugin data already is, and the
file options, where an unset base font size reads as the default. Both
kinds count towards ahead, behind, and conflicts, appear in the compare
dialog, and translate into set-plugin-data and set-base-font-size
changes for merge and update from main. Both sides changing the same
key to different values is a modify-modify conflict.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
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