32 Commits

Author SHA1 Message Date
alonso.torres
a5beebcea2 🐛 Drop the uniform structure flag of older copy texts
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
2026-10-30 09:50:07 +01:00
alonso.torres
6b26e810c8 🐛 Fix export detach and the migrations of copies
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
2026-10-30 09:49:28 +01:00
alonso.torres
9678d4dbae 🐛 Fix component validation and repair
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
2026-10-30 09:49:28 +01:00
alonso.torres
75f9a9f04f ♻️ Simplify component validation and repair
- 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
2026-10-30 09:48:55 +01:00
alonso.torres
c8d373eca1 ✨ Migrate copy texts to the per-style text overrides
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
2026-10-30 09:47:49 +01:00
alonso.torres
b94092bb10 🐛 Make a copy head that takes a position refer to its children
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
2026-10-30 09:47:19 +01:00
alonso.torres
37aaae8af9 ✨ Migrate swap slots to override groups and remove them
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
2026-10-30 09:47:18 +01:00
alonso.torres
90feb3b502 ✨ Store a swap inside a copy as an override group
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
2026-10-30 09:47:18 +01:00
alonso.torres
a6ae24d68e 🔥 Remove the components v1 leftovers
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
2026-10-30 09:47:18 +01:00
alonso.torres
dbc890fd24 ✨ Make the component test suites fail when their claims break
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.
2026-10-30 09:47:18 +01:00
alonso.torres
81af925bba ✨ Test component validation, repair and library asset sync
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.
2026-10-30 09:47:18 +01:00
Pablo Alba
3cd073fc6a
✨ Add auto-repair for integrity error files (#12524)
* ✨ Add auto-repair for integrity error files

* ✨ MR changes
2026-10-26 11:04:06 +01:00
Andrés Moya
a5f75c7394
🐛 Fix repair bad variants (#11983)
* 🐛 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
2026-10-16 10:59:57 +02:00
Andrés Moya
feb73f0fd8
✨ Handle tokens libraries when exporting .penpot files (#12077)
* ✨ 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>
2026-10-15 16:53:30 +02:00
Eva Marco
317a731dda
🐛 Detect and repair children missing from their parent's list (#12216)
* 🐛 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
2026-10-13 11:17:01 +02:00
Eva Marco
31a079a7a2
🐛 Allow creating tokens when the file is its own tokens source (#12182)
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
2026-10-08 09:55:18 +02:00
Andrey Antukh
10ccfd2218
🐛 Gate pastes on page load and harden base-shape lookup (#11674)
* 🐛 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
2026-09-30 07:24:03 +02:00
Andrés Moya
3cd9bfa9de
✨ Add sync with design tokens in external libraries (#10293)
* ✨ 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>
2026-09-21 15:37:45 +02:00
Andrey Antukh
81c3b3cd56
📎 Update copyright name on file header (#11346) 2026-08-25 11:55:10 +02:00
Andrés Moya
8e713df5f0
🎉 Add repair functions for variant validation errors (#10768)
* 🎉 Add repair functions for variant validation errors

* 📚 Fix copyright notice
2026-08-04 16:53:36 +02:00
Alonso Torres
b573a71017
🐛 Fix numeric values for tokens (#10270) 2026-06-18 11:05:14 +02:00
Alonso Torres
ba9d225c2b
🐛 Fix stroke-cap-start/end stored at wrong level in SVG imports (#9982) 2026-06-02 17:42:35 +02:00
Andrey Antukh
c5de4c27b0 Merge remote-tracking branch 'origin/main' into staging 2026-06-01 12:57:39 +02:00
Yamila Moreno
ddba2ffa75
📎 Update Kaleidos Copyright (#9929) 2026-05-29 11:24:58 +02:00
Andrey Antukh
40ce360c99
⚡ Improve performance and fix orphan detection in validate-file (#9789)
* ⚡ 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>
2026-05-27 12:36:21 +02:00
Belén Albeza
3512a57df7
🐛 Fix referential integrity data in old files (#9771) 2026-05-21 11:18:13 +02:00
Jeff
d09985edee
🐛 Preserve Inkscape labels when pasting SVGs (#9252)
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>
2026-04-30 16:17:34 +02:00
Andrés Moya
a3ea9fbecb
🔧 Add more validations for components, to avoid some crashes (#7820)
* 🔧 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>
2026-04-15 08:42:42 +02:00
Andrey Antukh
0337607a1b
🐛 Guard delete undo against missing sibling order (#8858)
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.
2026-04-01 11:49:17 +02:00
Andrés Moya
5b5f22a8c6
🎉 Add tokens to Penpot Plugins API (#7756)
* 🎉 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>
2026-02-09 14:18:31 +01:00
Andrés Moya
37cf829188 🔧 Move token helpers to common.files 2025-04-29 10:11:40 +02:00
Andrey Antukh
315b389a66
🐛 Fix name generation and handling on creating objects (files, tokens, ...) (#5745)
* ✨ 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>
2025-02-03 12:49:56 +01:00