Copy texts in files older than mid-2025 can hold
:text-content-structure-same-attrs next to :text-content-structure,
which marked a structure change of a text with one style. The text
override migration recomputed the groups and dropped the structure flag
but left this one in :touched, where nothing reads it, and validation
did not report it. It is now one of the legacy text groups, so the
migration and the :legacy-text-touched repair drop it too. Found in
the copy A2 of the QA fixture text-components-propagation.penpot while
re-running PENPOT-2261.
convert-text-overrides-test: the flag goes with the structure flag and
without it. repair-components-test/legacy-text-touched: a copy holding
it is reported and repaired.
AI-assisted-by: claude-opus-5-5
Fixes in what export does to the copies of libraries it leaves out, and in
the migrations of swap slots and copy texts (Phase 3c).
- Drop library color refs in fills and strokes on detached export.
The binfile export that detaches libraries drops each reference to another
file. For colors it read only where the older format kept them
(:fill-color-ref-file and :stroke-color-ref-file on the shape and on text
nodes), so a library color ref inside :fills or :strokes, the current
format, stayed and the exported file pointed at a library it does not
carry (F57). Every color of the shape, its text nodes included, now goes
through cts/process-shape-colors, which detaches the ones that refer to
another file.
file-test: a fill ref inside :fills is dropped.
- Detach copies by where their refs and components point on export.
The binfile export that detaches libraries decided for each copy shape
from one file, the :component-file of the shape or of the closest head
above it, and then dropped its component and its :shape-ref together
(F52). That is wrong both ways:
- a library copy nested in a local component refers, in each copy of that
component, to the nested copy in the local main; using the head's own
library, its :shape-ref was dropped, and the next sync treated the shape
as one the copy added;
- a dropped link kept :touched, :remote-synced and :main-instance;
- the shapes stored in deleted components were not detached at all.
A shape's :shape-ref now points into the file of the component of the top
head of its copy, or of the closest swapped head above it, and the link
goes with the shape's other component attributes (ctk/detach-shape) only
when that file is another one. A head whose own component is in another
file stops being a head and loses :swap-group, but stays a copy while its
main shape is in this file. Deleted components' shapes are detached the
same way.
file-test: a dropped link loses :touched; a library copy nested in a local
component keeps its ref in the component's copy, and the file validates; a
deleted component's nested library copy is detached.
- Follow a swap slot's chain through the pages of its mains.
find-ref-id-for-swapped, which migration 0010 runs on files older than the
swap group, walks a swapped shape's ref chain to the main child its swap
slot names. It read each step's children from the page of the step's own
component, not from the page the step lives on, so when the mains of a
nesting sat on different pages it found nothing, and the migration then
removed a valid slot: the swap was lost (F56). Each step now goes on in
the container the ref lookup found it in.
file-test: the original ref of a swap is found with the mains on two
pages.
- Remove ctf/get-component-container-from-head.
Its last caller, the swap slot chain walk of migration 0010, now takes
each step's container from the ref lookup.
- Recompute copy texts touched with the content group alone.
Files older than the text override groups mark a copy text whose content
changed with :content-group alone. Sync reads that as nothing touched in
the text and writes the main's content, so the copy lost its text and
styles at the next sync of its main (F50). Migration 0034 converted the
later groups but left this one.
convert-text-overrides now also converts a copy text with :content-group
and no text group, reading it as allowing every text group: compared with
its near main, it takes the groups where it differs (all of them when the
near main cannot be reached). Migration 0035 runs it on files that already
passed 0034; a binfile import converts them with the other old text
groups, deferred as they are.
convert-text-overrides-test: such a copy takes the letters and styles that
differ, nothing where nothing differs, and through the migration.
text-sync-test: once migrated, the copy keeps its text and takes the
main's new size.
AI-assisted-by: claude-opus-5-5
Fixes in the validation of component refs and in the file repairs (Phase
3c).
- Name the component in the :ref-shape-is-head error.
A copy shape whose near main is a nested copy head must be a head of the
same component. The check reported no component, so its repair reheaded
the shape with nil ids, validation found the same error again and the file
never converged (F48). The error now carries the near main's component
file and id, as :ref-shape-is-not-head does, and the repair reheads the
shape with them.
repair-components-test repairs a nested copy head that lost its component.
- Remove a stray component file in the :component-main repair.
Validation reports :component-main on a shape inside a main that is not a
head when it has :main-instance or :component-file. The repair only
removed :main-instance, so a shape with :component-file kept the error
after every pass and the file never converged (F47). For a shape that is
not a head, the repair also removes :component-file.
repair-components-test repairs a main child with a component file.
- Check refs inside copies at every level where they are local.
Validation of the shapes inside a copy had two faults:
- it checked the ref of a nested copy head (it exists, and is a child of
the near main of its parent) only for heads directly under a top copy
root: the flag that allowed it reached one level down, so heads under a
group or nested twice were never checked (F87);
- for a library copy it reported a ref the library has deleted since the
file last took its updates (F104). That state is normal and lasts until
the user takes the update, which removes the copy shape; the repair
detached the shape or took another position instead, so the update no
longer removed it.
The flag now says whether the shapes refer into this file, and passes
through every level of the copy: the children of a top copy refer into its
component's file, and those of a swapped head into its own component's.
:ref-shape-not-found and :ref-not-in-near-main are checked on nested heads
only where it holds. The call on copy shapes that are not heads read the
library from the shape itself, which only heads have, so it never ran; it
goes.
repair-components-test: a dangling ref on a head nested twice and on one
under a group is reported; a library main that deleted its nested copy
leaves the consuming file valid.
- Check the ref of every local copy shape that is not a head.
Validation never reported a dangling :shape-ref on a copy shape that is
not a head (F46). It now checks it wherever the shapes of the copy refer
into this file; refs into a library may name a shape the library deleted
since the file last took its updates (F104). The repair of
:ref-shape-not-found looks for a matching main shape and, finding none,
detaches the shape, which stays in the copy as a shape of its own.
repair-components-test repairs a copy child with a dangling ref.
- Keep copies linked while their component's main is repaired.
When a component's :main-instance-id or :main-instance-page was wrong,
validation also reported :ref-shape-not-found on every copy of it, as
their near main could not be found. Both repairs ran in the same pass: the
component got its pointer back, and the copies, which the repair could not
match against the old pointer, were detached (F60). Every user file the
backend repaired lost those links.
While some error in a component's main instance pointer is repaired, the
:ref-shape-not-found errors wait for the next pass, which finds the near
main and reports nothing.
repair-components-test: the copies keep their link after the repair of
:invalid-main-instance-id, :invalid-main-instance-page and
:invalid-main-instance.
- Look up a library component's shapes in its library in repairs.
The :ref-shape-not-found repair looks for a main shape the dangling ref
can be matched to among the shapes of the copy's component. It found that
component in the right file, local or library, but then read its shapes
from the local file data, where a library component's main is not: nothing
matched, and the whole copy was detached (F88). It now reads them from the
component's own file.
repair-components-test: a library copy that points at another copy of the
same component is reassigned to the library main.
- Keep file repairs from touching the copies they fix.
The repairs of a file's shapes wrote through the changes builder like an
edit, so a repair of a copy shape touched the groups it changed: a copy
shape whose geometry was reset kept the reset size as an override, and
sync never gave it back its main's (F7, part 1). A repair is not an
override; the repairs of shapes now touch nothing, and those that fix the
touched groups still set them.
repair-test: a copy shape with invalid geometry is repaired and stays
untouched.
AI-assisted-by: claude-opus-5-5
- Most repairs change one shape, or the shape and its subtree, with a
function, and each one wrote the same 12 to 15 lines around it: the
log line, the empty changes of the page, the file data and the
update of the shape. repair-shapes and repair-component hold that,
so each method keeps its own name and says only what it changes.
The functions several repairs repeated go once: detach-shape, the
frame lookup of :frame-not-found and :invalid-frame, the no-op of
the repairs that cannot be done, and the variant id of three
variant repairs. The log lines now name the code that was repaired,
which for :ref-shape-is-not-head and :ref-shape-is-head they
misspelled.
- Three error codes could never be reported, because each check runs
only on shapes that already pass it: :component-not-main (in the
check of a main head, reached only when :main-instance is true, and
of a copy head, reached only when it is not),
:should-be-component-root (reached only when :component-root is
true) and :not-component-not-allowed (a main context counts as
inside a main). They go, with their repairs and their place in
error-codes. The check that a shape inside a component is no head
also tested :component-id, which such a shape never has. The branch
of check-shape for a shape that is neither a head nor a copy shape
is a cond.
No behavior change: the repair tests and the suites are unchanged.
repair.cljc: 815 -> 534 lines.
AI-assisted-by: claude-opus-5-5
Files older than the feature components/text-overrides mark a copy text
with :text-content-attribute (any style) or :text-content-structure
(letters and styles). Migration 0034 compares each such copy with its
near main and gives it the groups its old flags allowed where it
differs; an old flag where nothing differs sets nothing. A copy whose
near main cannot be reached keeps every group its flags allowed, so it
loses no override. The migration adds the feature, so a client on the
old code cannot write the old groups into a migrated file.
Binfile import leaves the copies whose near main is in a library not
linked yet, as it does with swap slots, and converts them once the
libraries are linked: the deferred pass of the import now runs both
conversions (convert-deferred!, *defer-unreachable*).
Validation reports the old groups (:legacy-text-touched), and its
repair recomputes them against the near main; a text override group
without :content-group is still :invalid-text-touched. A shape with no
letters in it and its main no longer counts as differing in its styles.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 34.8 / 0.99
edit-through-a-chain-of-dependents 68.9 / 65.0 / 0.94
library-update-into-copies 83.1 / 86.9 / 1.05
main-edit-into-10-copies 1.8 / 1.5 / 0.86
main-edit-into-100-copies 15.4 / 15.0 / 0.97
main-edit-into-1000-copies 153.1 / 150.6 / 0.98
variant-switch-into-copies 32.1 / 26.8 / 0.84
AI-assisted-by: claude-opus-5-5
When a copy head took the place of a main shape and its component was
the one of that place, it stopped being a swap, but its children kept
referring to the main of its own component instead of to the children
of that place. Validation did not see it, and the next sync deleted
those children and added them again, losing their overrides, or the
head did not follow later changes to that place. It happened in:
- a variant switch that carries a swapped child into a variant whose
child there has the same component;
- update main of a copy that swapped a nested copy back to the
component a deeper main puts in that place, after the main in between
swapped it for another one; the clone in the main also kept
:swap-group;
- the repair of :ref-not-in-near-main, which also never cleared
:swap-group.
The swap in a copy, the switch, update main and the repair now share
ctf/take-position: the head refers to the main shape of its position,
is swapped while its component differs from that shape's, and else its
descendants refer to the descendants of that shape.
The switch also read the swapped child from the objects of the swap,
where it was already gone with its old parent. It now reads it once it
is moved into the new copy, so its placement turns its children too,
and the undo takes it back to the root as the move to the root left it.
The swap finds the new shapes in its own objects, not in its changes.
The component memory says how a head takes a position.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 37.1 / 1.05
edit-through-a-chain-of-dependents 68.9 / 70.5 / 1.02
library-update-into-copies 83.1 / 77.3 / 0.93
main-edit-into-10-copies 1.8 / 1.7 / 0.94
main-edit-into-100-copies 15.4 / 16.0 / 1.04
main-edit-into-1000-copies 153.1 / 160.6 / 1.05
variant-switch-into-copies 32.1 / 28.7 / 0.89
AI-assisted-by: claude-opus-5-5
The migration 0033 stores every swap of a file as an override group.
A swap made in a copy is paired with the child of the near main that
holds the same position, as sync paired it; any other slot is dropped.
All pairings are found on the data as it was, so swaps nested in swaps
pair against the content of the outer one, and libraries pair the same
whether they are migrated or not. A head that refers to its own slot
only inherited it, and drops it even when its near main cannot be
reached. Any other slot that matches no child, or whose near main is in
a library that is not loaded, keeps the slot as its ref, so validation
reports it. An import, which migrates its files before it links their
libraries, leaves those slots and converts them once the libraries are
linked. A migrated head that sits elsewhere than the child it replaces
would place it gets its geometry marked as touched, so the first sync
does not move it. Migrated and new files carry the feature
components/swap-group, so a client that still writes slots cannot open
them.
Validation checks the new form: :misplaced-slot reports any slot,
:misplaced-swap-group the group anywhere but on a nested copy head in a
copy, :ref-not-in-near-main a nested copy head whose ref is not a child
of the near main of its parent, and :duplicate-ref two children of a
copy that refer to the same main shape. Each has a repair.
Nothing but the migrations reads slots any more: the slot branches go
from sync, swap, switch, reset, detach, paste, duplicate and move. The
helpers that read and write a slot on a shape move to files/swap-slots,
and the ones that follow refs to files/comp-processors. The graph
projection drops the FillsSwapSlot edge, the components debugger shows
the component a swapped head takes its content from, and the plugin API
documents componentRefShape for a swapped copy. The swap tests are
named after what they check.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 34.6 / 0.98
edit-through-a-chain-of-dependents 68.9 / 63.5 / 0.92
library-update-into-copies 83.1 / 76.4 / 0.92
main-edit-into-10-copies 1.8 / 1.6 / 0.87
main-edit-into-100-copies 15.4 / 15.1 / 0.98
main-edit-into-1000-copies 153.1 / 148.8 / 0.97
variant-switch-into-copies 32.1 / 26.4 / 0.82
AI-assisted-by: claude-opus-5-5
A swapped copy sub-head now keeps the shape-ref of the main child it
replaces (its position), takes its content from its own component, and
carries the touched group :swap-group. It used to point at the root of
the new component and store a swap slot naming the replaced child.
Every path reads and writes that form:
- sync pairs a swapped head with the child it replaces and syncs it
from its own component; it takes from the replaced child only its
placement: position, constraints and layout item attributes;
- reset undoes a swap in place, keeping the head's id and the other
overrides of the copy;
- detach, paste and duplicate make a head that leaves every copy level
a plain copy of its own component, and advance a deeper one by
position; the children of a swapped head never advance;
- a variant switch keeps a swap stored this way, and counts only the
copy's own swap, not one made in a main of the chain;
- update main replaces the main child with a clone of the swapped one,
and from a swapped head updates its own component;
- a swap inside a copy keeps the old shape-ref and adds :swap-group;
swapping back to the component of that position gives a plain nested
copy whose children refer to the position's children; a swap inside
a main is a plain nested copy.
Files that hold swap slots keep working until a migration converts
them. The component memories describe the model.
This fixes what came from the slot: reset of a swapped child cleared
the other overrides of the copy (F12) and gave copies of a main new
ids (F66); update main left a swap mark (F16); a second variant switch
lost the overrides (F29); grid cells did not pair a swapped child
(F44); a swapped child did not follow its position (F65). F49 keeps
failing, in another form.
The tests that asserted slots now assert the override group; the tests
of the slot code build the old form with :as-slot?.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 35.4 / 1.01
edit-through-a-chain-of-dependents 68.9 / 68.7 / 1.00
library-update-into-copies 83.1 / 77.5 / 0.93
main-edit-into-10-copies 1.8 / 1.6 / 0.92
main-edit-into-100-copies 15.4 / 15.4 / 1.00
main-edit-into-1000-copies 153.1 / 151.1 / 0.99
variant-switch-into-copies 32.1 / 32.4 / 1.01
AI-assisted-by: claude-opus-5-5
No components v1 file reaches the code any more: opening one fails the
feature check, and imports reject it or give it the feature. What only
served v1 goes:
- validate-file and validate-file-affected no longer skip a file
without components/v2;
- the root of a deleted component is found by its main instance id
only, in the type helper and the component sprite;
- generate-sync-library, which synced shapes inside live components,
and the live components in containers-seq, so asset usages walk the
pages only (deleted components keep their objects and still sync);
- the unused :components-v2 file option, from the schema, the file
data and the test fixtures; stored files that carry it still
validate;
- the team migration-in-progress check and its frontend alert, which
leaves ephimeral features out of the team comparison;
- the components v2 introduction dialog, its styles and icons, and the
:v2-info-shown profile prop;
- the graphic asset events and builders and the reads of graphics in
the selection (the :add-media, :mod-media and :del-media changes stay,
so stored changes still apply), and the uncalled render-page and
render-components helpers.
AI-assisted-by: claude-opus-5-5
A review found tests that would pass with what they test broken.
- Runners: both judge one variant at a time, fail a variant with no
assertion and a known-failure mark that applies nowhere, and give
the asserter the state the operations produced. The pure runner
validates the final file and rejects operations of unknown kind.
The frontend interpreter fails a step when the store reports an
event error (Potok only logs it), reports asserter errors against
their case, and stops the watchers that install! starts.
- Nodes drive the real code: a main root rename goes through
end-rename-shape, and the clipboard's advance of nested copies is
one common function, cll/advance-copied-objects, that copy-selected
and both test paths call.
- Cases N, AH, AI, AK, AD, AE, K, C, H, S, AY, I, Y, AX and F check
what they claim; O, R and T get real sizes.
- Stricter common unit tests found F60 (a repair detaches healthy
copies), F61 (a switch drops a fixed-sizing size), F62 (the ref
chain never stops at its target) and F63 (latent); all pinned.
- Frontend library tests: the RPC double records on subscribe, the
dropped commits check that nothing syncs, and the link and share
tests check the events sent.
- Plugin suite: OpSwap and OpDetach throw when the API call did
nothing, and case AB checks that the reset head is an instance of
the original component.
- The composable suite memory records the new rules.
Fill the unit test gaps in common and frontend component code:
- One test per component error code: break a file in one way, check
the codes validation reports, repair it as the backend does (until
valid, at most ten passes) and check what the repair changed.
Pinned: F46 (a dangling ref in a copy child is never reported), F47
and F48 (two repairs that never converge). :component-not-main,
:should-be-component-root and :not-component-not-allowed have no
test, as no file can produce them.
- Library color and typography sync must not touch copies. Both do
(F3): the color sync touches :fill-group, the typography sync
:content-group and :text-content-attribute, and a later main edit of
that attribute then no longer reaches the copy. Pinned.
- Twenty live thf/dump-file calls in common tests go; they printed
whole file trees on every run and asserted nothing.
- The disabled copy-nested-3 frontend test runs again: a stray
backquote quoted its run-store call, so it never called done.
* 🐛 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
* ✨ Handle tokens libraries when exporting .penpot files
Exporting a file that takes its tokens from an external library left
the :tokens-source of the file pointing at a library that is not in the
export, and the tokens lib of that library was not exported.
With :detach-libraries, unset the :tokens-source and the tokens status
in detach-external-references. The file is exported without any link to
the library, and the shapes keep their applied tokens.
With :merge-libraries, add cfo/absorb-tokens-lib, which copies the tokens
lib of a library into a file and unsets its :tokens-source, so the file
becomes its own tokens source. The tokens status of the file is kept; if
it has none, the one of the library is copied. If the library has no
tokens lib, only the source is unset. embed-assets calls it for the
library that is the tokens source of the file, and ignores the other
linked libraries.
Add unit tests for absorb-tokens-lib, and binfile tests that export a
file with tokens from an external library with both modes, checking the
exported files, the tokens lib, the tokens source and the applied
tokens.
Also reformat the export-type docstring in binfile v3 and remove a
leftover debug prn in the export dialog.
AI-assisted-by: claude-sonnet-5-5
* 🐛 Preserve active token sets when importing linked libraries
Cache each decoded tokens library for the duration of an import so files
and their tokens source resolve active sets against the same generated IDs.
Add a round-trip regression test for exports that include linked libraries.
* 🐛 Preserve tokens when deleting or unpublishing libraries
Absorb the tokens library into files that use it as their tokens source.
Keep referencing file IDs in the deletion task before removing relations.
Embed tokens from deleted, unlinked sources during export, and clear stale
source and status data when the library no longer exists. Add regression
tests for deletion, unpublishing, and exports with unavailable libraries.
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* 🐛 Detect and repair children missing from their parent's list
A shape could point to an existing parent that did not list it in its
:shapes. The validator missed it, since it only walks the tree from
the root plus the shapes whose parent does not exist. Operations that
walk :shapes missed it too: deleting a variant also deleted its
container, which looked empty, and left the other variants without a
parent, so the backend rejected the change and the file stopped
saving.
- get-orphan-shapes also returns shapes not listed by their parent,
so :child-not-in-parent is reported.
- The :child-not-in-parent repair adds every unlisted child of the
parent. All repairs share the same file data and each one sets the
whole :shapes, so with several children only the last one was kept.
- New migration 0032-fix-children-not-in-parent repairs the affected
files when they are opened.
Closes#11758
AI-assisted-by: claude-opus-5-5
* 🐛 List shapes moved to root by the parent-not-found repair
The :parent-not-found repair set the shape's parent to the root but
did not add it to the root's :shapes, so it left the shape in the
child-not-in-parent state that the validator now reports. It now
adds every shape without parent to the root at once, since all
repairs are built from the same file data and each one sets the
whole :shapes.
Also cover the missing parent case in the repair tests.
AI-assisted-by: claude-opus-5-5
* ♻️ Simplify unlisted children validation and repair
Address review comments on the validation and repair of shapes that
their parent does not list.
- get-orphan-shapes no longer rebuilds *children-sets*: both callers
bind it. The lookup moves to a listed-in-parent? helper shared with
check-parent-children, which scans :shapes when the var is unbound.
- The :child-not-in-parent and :parent-not-found repairs no longer
walk every shape of the page. Each one moves only its own shape with
pcb/change-parent. The :mov-objects change adds the shape to the
parent's :shapes when applied, so several repairs on one parent no
longer overwrite each other.
- The :child-not-in-parent repair test uses plain shapes instead of
variants.
AI-assisted-by: claude-opus-5-5
Setting the local file back as the tokens source, or unlinking the
library that was the source, stored the file's own id in
:tokens-source. ensure-tokens-lib only created a tokens-lib when
:tokens-source was nil, so a file without a tokens-lib never got one,
and every token change was dropped without any error.
Now ensure-tokens-lib also creates the tokens-lib when the source is
the file itself, which fixes files already in that state, and
set-tokens-source removes the attribute instead of storing the file's
own id.
Closes#12180
AI-assisted-by: claude-opus-5-5
* 🐛 Gate pastes on page load and harden base-shape lookup
Pasting while the workspace is still opening crashed the
session: the layer-order lookup called rseq on a missing
root children list.
Ignore paste events until the page objects are loaded (the
clipboard keeps its content, so retrying works), return empty
instead of throwing from the shared layer-order helpers, and
fall back to pasting at the pointer position when the selection
is detached from the shape tree. Selecting the page root keeps
working as before through the frame branches.
Closes#11666
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Cover props paste and root-plus-other in paste guards
Address review follow-ups on the paste-before-init fix: gate
props pasting on page readiness like the shape entries, and
route root-plus-other selections without a base shape to the
pointer fallback instead of the unguarded else branch. Pin
single-root selection to the frame path with a regression test.
Closes#11666
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Hoist page lookup out of paste-shapes gate
Bind page and page-objects once in an outer let instead of
calling lookup-page twice (once for the readiness gate and
once inside the body). No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Reuse bound ids in paste-shapes page lookup
Bind file-id and page-id once and pass them to the lookup-page
arity that takes both, instead of resolving the page twice and
rebinding file-id in the inner let. No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Auto link tokens when adding external libraries (provisional)
* 🔧 Refactor tokens-lib initialization
* 🔧 Add separated TokenStatus to store status apart of TokensLib
* 🔧 Make all status operations use the new data structure
* 🔧 Normalize status helper functions and access token sets by id
* 🔧 Rename :tokens-file to :tokens-source
* 🎉 Allow the user to choose the tokens-source of a file
* 🎉 Make tokens library readonly when it's in an external file
* 🎉 Show tokens in library summaries
* 🎉 Show source info in sidebar
* 🔧 Fix integration tests
* 🐛 Propagate changes of token values in external library
* 🎉 Layout updates
* 🔧 Refactor tokens source calculations
* 🔧 Add harder checks for nil or empty values in everything
* 🐛 Fix some integration tests
* 🔧 Add integration tests for tokens in external libs
* 🔧 Validate and repair missing tokens status
* 🎉 Make ui changes optional with config flag
* 🐛 Propagate tokens after synchronizing components in ext library
* 🐛 Propagate tokens after creating new instances
* 🐛 Propagate tokens after synchronizing tokens in ext library
* 🐛 Add a tokens source icon to libraries section (#11439)
* 🐛 Add a tokens source icon to libraries section
* 🐛 Fix ellipsis on library names
* ♻️ Remove code under flag on legacy component
* 🐛 Fix token theme name on inspect tab
* 🎉 Add changes notification (#11476)
* 🎉 Add changes notification
* ♻️ Change fn names
* 🐛 Fix tokens source label truncation and missing translations (#11533)
* 🐛 Fix tokens source label truncation and missing translations
The tokens source file name always showed, even for the current file,
and long names wrapped onto a second line instead of truncating
because the header used flex-wrap and overflow-wrap: break-word
instead of single-line ellipsis.
Show the source row unconditionally (it now displays "This file" when
the source is the current file, matching the connected-library case),
truncate the file name to one line with an ellipsis, and only attach a
tooltip with the full name when the text is actually truncated.
Replace the hardcoded UI strings with translated ones and add their
English and Spanish entries.
AI-assisted-by: claude-sonnet-5
* 🐛 Remove redundant effect dependency in tokens source
file-name-truncated? was listed as a dependency of the with-effect
that checks and observes label truncation, even though it isn't
read inside the effect body. Since the effect itself flips that
state via check-file-name-truncated, including it as a dependency
caused the ResizeObserver to be needlessly disconnected and
reconnected on every truncation change.
AI-assisted-by: claude-sonnet-5
* 🐛 Fix small visual error
* 🐛 Fix problem with plugins
* 🐛 Fix playwright tests
---------
Co-authored-by: Eva Marco <evamarcod@gmail.com>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ⚡ Improve performance and fix orphan detection in validate-file
- Add `*ref-shape-cache*` dynamic var to memoize `find-ref-shape`
lookups per page, avoiding repeated O(depth) ancestor walks.
- Add `*children-sets*` pre-computed maps for O(1) parent-child
containment checks, replacing linear `some` scans.
- Short-circuit `inside-component-main?` when the shape context
already implies a main component.
- Use single-pass reduce with early exit for duplicate detection
(children, swap slots) instead of count/distinct or frequencies.
- Guard `check-missing-slot` to skip expensive `find-near-match`
when the shape already has a swap slot.
- Refactor variant-set validation to use `run!` with direct `get`.
- Refactor `check-ref-cycles` to use a single `reduce-kv` pass.
- Fix `get-orphan-shapes`: the original `map` pipeline produced
nils so orphan shapes were never validated; rewrite with
`reduce-kv` for correct results.
- Add `validate-file-affected!` for change-scoped validation,
replacing full file validation in `process-changes-and-validate`
to only validate pages and components touched by the changes.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Improved validation
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Steps to reproduce: paste an SVG authored in Inkscape (or any editor
that follows the inkscape:label convention) into a penpot file. The
group/element names visible in the source editor are dropped — penpot
shows generic auto-ids like 'g1234' or 'path5678' instead.
Root cause: parse-svg-element in common/src/app/common/files/shapes_
builder.cljc derived the shape name from (or (:id attrs) (tag->name
tag)). Inkscape stores user-given element labels in the inkscape:label
and sodipodi:label namespaced attributes while id holds an auto-
generated technical id, so the operator's chosen name was always
overridden by the technical id when present.
tubax/xml->clj (the SVG parser the import pipeline already uses for
upload, paste, and library import) keeps namespaced attributes as
:prefix:name keywords — the same shape this file already reads
:xlink:href from on line 134, and that app.common.svg uses for the
xlink: namespace at lines 300-307.
Fix: extract the name-resolution logic into a public resolve-element-
name helper that prefers :inkscape:label, then :sodipodi:label, then
:id, then (tag->name tag). Existing SVGs that don't carry either label
namespace fall through the same chain as before, so the behaviour for
non-Inkscape-authored SVGs is unchanged.
This restores the behaviour dfelinto's penpot-icon-generator-plugin
relies on (linked from the issue body) — that plugin reads element
names from the imported SVG to map Blender icons to penpot components.
Tests: 6 deftest blocks in common/test/common_tests/files/shapes_
builder_test.cljc covering the priority order (inkscape > sodipodi >
id > tag), each fallback in isolation, and the empty-attrs case.
Registered in common-tests.runner.
Closes#7869
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🔧 Validate only after propagation in tests
* 💄 Enhance some component sync traces
* 🔧 Add fake uuid generator for debugging
* 🐛 Remove old feature of advancing references when reset changes
Since long time ago, we only allow to reset changes in the top copy
shape. In this case the near and the remote shapes are the same, so
the advance-ref has no effect.
* 🐛 Fix some bugs and add validations, repair and migrations
Also added several utilities to debug and to create scripts that
processes files
* 🐛 Fix misplaced parenthesis passing propagate-fn to wrong function
The :propagate-fn keyword argument was incorrectly placed inside the
ths/get-shape call instead of being passed to tho/reset-overrides.
This caused reset-overrides to never propagate component changes,
making the test not validate what it intended.
* 🐛 Accept and forward :include-deleted? in find-near-match
Callers were passing :include-deleted? true but the parameter was not
in the destructuring, so it was silently ignored and the function
always hardcoded true. This made the API misleading and would cause
incorrect behavior if called with :include-deleted? false.
* 💄 Use set/union alias instead of fully-qualified clojure.set/union
The namespace already requires [clojure.set :as set], so use the alias
for consistency.
* 🐛 Add tests for reset-overrides with and without propagate-fn
Add two focused tests to comp_reset_test to cover the propagate-fn
path in reset-overrides:
- test-reset-with-propagation-updates-copies: verifies that resetting
an override on a nested copy inside a main and supplying propagate-fn
causes the canonical color to appear in all downstream copies.
- test-reset-without-propagation-does-not-update-copies: regression
guard for the misplaced-parenthesis bug; confirms that omitting
propagate-fn leaves copies with the overridden value because the
component sync never runs.
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Return nil from get-prev-sibling when the shape is no longer present in
the parent ordering so delete undo generation falls back to index-based
restore instead of crashing on invalid vector access.
* 🎉 Add tokens to plugins API documentation
And add poc plugin example
* 📚 Document better the tokens value in plugins API
* 🔧 Refactor token validation schemas
* 🔧 Use automatic validation in token proxies
* 🔧 Use schemas to validate token creation
* 🔧 Use multi schema for token value
* 🔧 Use schema in token api methods
* 🐛 Fix review comments
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Update function implementation
* ✨ Add tests for a new function implementation
* ✨ Update function usages according to new signature
* ✨ Update docstrings
* ✨ Use simpler assertion
* 💄 Replace concat with cons on name-seq
* 🐛 Fix incorrect error handling on legacy workspace redirect
---------
Co-authored-by: Andrey Fedorov <oran9e.red@gmail.com>