mirror of
https://github.com/penpot/penpot.git
synced 2026-11-03 08:30:40 +00:00
24500 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c7ef7b0088 |
🐛 Fix how changes touch copies and mark components
Fixes in how a change touches a copy shape and marks its component modified
(Phase 3c).
- Give a copy shape back :remote-synced when its touch is undone.
Touching a copy shape clears its :remote-synced (the mark that it was last
synced from a remote main, which update main reads). The undo that
pcb/update-shapes writes set the touched groups back but not that mark, so
undoing an edit left the shape without it (F25).
The undo now sets :remote-synced back when the shape had it.
comp-touched-test: undoing an edit of a remote-synced copy shape gives the
mark back and leaves the shape untouched.
- Apply an update's touched groups before the attrs it changes.
When the function given to update-shapes set a copy shape's :touched and
changed an attribute that touches, the order of the two operations
followed the order of the shape's keys: a record field such as :name came
before :touched, so the :set of :touched ran last and dropped the group
the name had just touched (F24); keys in the shape's map came in hash
order.
The :set of :touched now always goes first, so the update's touched groups
are the base and each attribute it changes touches on top.
comp-touched-test: an update that sets the touched groups and changes the
fills, opacity, strokes, visibility, blocking, a radius or the name keeps
both.
- Count an :assign on a main as a change of its component.
components-changed, which decides which components a commit changed and so
need their copies synced, looked only at :set operations, so a :mod-obj
with an :assign of synced attributes on a main never started a sync (F28).
Nothing in the code writes :assign today, so no user hit it. An :assign
now counts when any of its attributes is one that component sync reads.
comp-sync-test: a :set and an :assign of opacity on a main child report
its component; an :assign of interactions reports none.
- Mark a component modified only by changes copies can show.
A change to a main marks its component modified, which tells the files
that use the library to offer an update. set-shape-attr left in the
shape's metadata whether its attribute is one that sync reads and changed,
and the change was judged by that flag (F21):
- :position-data, which a text re-measures from its content, counted, so
re-measuring a main text prompted every file to update; decision (e) of
the component contract says it never does;
- each :set overwrote the flag, so a change that set a synced attribute
and then the position data counted only the last one.
:position-data no longer counts, and the flag is cleared at the start of
each change and set by any of its :set operations.
comp-sync-test: a fill change marks the component, a re-measure does not,
and a fill change followed by a re-measure does.
- Compare transforms and paths of a copy with a tolerance.
A change to a copy touches the group of the attribute when the value
differs from the one it has. Numbers, selection rects, points and position
data are compared with a tolerance, but the transform, its inverse and the
content of a path were compared exactly, so float noise (a transform
recomputed, a path moved there and back) touched :geometry-group (F23).
The matrices are now compared term by term with the precision of the
matrix helpers, as their scale and rotation terms need, and the paths
segment by segment, with the precision the caller gives.
container-test: float noise in a transform, its inverse or a path touches
nothing; a rotation or a moved point still touches.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 19.0 / 0.54
edit-through-a-chain-of-dependents 68.9 / 34.6 / 0.50
library-update-into-copies 83.1 / 43.8 / 0.53
main-edit-among-many-shapes 4.8 / 2.5 / 0.51
main-edit-into-10-copies 1.8 / 0.9 / 0.48
main-edit-into-100-copies 15.4 / 9.7 / 0.63
main-edit-into-1000-copies 153.1 / 85.6 / 0.56
variant-switch-into-copies 32.1 / 20.5 / 0.64
AI-assisted-by: claude-opus-5-5
|
||
|
|
956df575f7 |
🐛 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
|
||
|
|
44c1cb89c1 |
🐛 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
|
||
|
|
b3a93655cf |
🐛 Fix the container helpers for copies and components
Fixes in the helpers that walk, instantiate, compare and absorb components
(Phase 3c).
- List each shape once in get-children-in-instance.
The recursion handed each child the vector built so far, ancestors
included, and the caller added what came back to its own copy of that
vector, so every ancestor came back once per child below it (F59). It now
threads one result through a reduce. Both callers, the repair of
:shape-ref-cycle and migration legacy-65, make a set of the result, so
they see the same shapes.
container-test counts the shapes of an instance.
- Walk every ancestor in has-any-copy-parent?.
It recursed with the parent's id where it expects a shape, so it only ever
looked at the direct parent (F63). It now walks up to the root, which is
its own parent. Users never saw it: every descendant of a copy is a copy,
so the direct parent gave the right answer.
container-test checks a shape two levels below the copy.
- Start a new copy without the remote-synced mark of its main.
Instantiating a component cleared the touched groups of the shapes it
copies from the main, but not :remote-synced, so a nested copy in the main
that had been synced from a remote main passed the mark on to every new
copy of the component, whose shapes refer to their near main (F18).
make-component-instance now clears it too.
container-test: a new copy of a component whose nested copy is touched and
remote-synced has neither mark.
- Compare the stored shapes of components by value.
mod-component marks a component modified when the change alters any of its
keys, which ctk/diff-components reports. For :objects, the shapes a
deleted component stores, it compared the ids found by walking :children,
a shape format older than the objects map, so it read nil on both sides
and never saw a change there (F96). It now compares every key, :objects
included, by value.
container-test: a change to a stored shape of a deleted component is a
difference; the same component is none.
- Keep nested copies and annotations of absorbed components.
When a file absorbs a library's components (the library is deleted, or a
binfile export merges libraries), each used component gets a main in the
file, a copy of the library main with the same ids (F89):
- every shape of that main lost its :shape-ref, so the copies nested in it
stopped being copies of anything;
- every head in it was pointed at the local file, even one whose component
lives in a third library;
- the component lost its annotation.
The main now keeps the refs of its nested copies, which with the same ids
point at the absorbed mains of the components they nest (a file that shows
a nested copy uses its component, so that one is absorbed too); only heads
of the absorbed library are pointed at the local file; the annotation is
copied. A variant still becomes a plain component.
absorb-assets-test: the nested copy of an absorbed main keeps its ref and
is a copy of the local component; the annotation stays.
- Say what the component placement rule checks.
invalid-structure-for-component? said it kept a shape out of its own
subtree, but its check compares components only: a plain shape moved into
its own child passes, and the move itself refuses it (F58). The comments
now say what it checks; the behavior stays, as the layers panel relies on
it to do nothing for such a drop (decision (s)).
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 17.0 / 0.48
edit-through-a-chain-of-dependents 68.9 / 33.5 / 0.49
library-update-into-copies 83.1 / 40.1 / 0.48
main-edit-among-many-shapes 4.8 / 2.1 / 0.43
main-edit-into-10-copies 1.8 / 0.8 / 0.47
main-edit-into-100-copies 15.4 / 8.3 / 0.54
main-edit-into-1000-copies 153.1 / 82.3 / 0.54
variant-switch-into-copies 32.1 / 18.6 / 0.58
AI-assisted-by: claude-opus-5-5
|
||
|
|
bef4b2f387 |
📚 Update the memories for the second simplify pass
- common/core: the variant logic namespaces now build transform into a variant, combine as variants and the layers-panel rename of a member, and the sync walk is two plain functions whose change pairs go through pcb/add-changes. - frontend/composable-component-tests: renaming a member in the layers panel, removing empty properties and setting a member's error are variant property edits both runners run; case AW joins the JVM set. AI-assisted-by: claude-opus-5-5 |
||
|
|
a54124536c |
♻️ Look up head components once and pair switch shapes first
- find-ref-shape, find-content-ref-shape, find-ref-component and the walk that finds the container of a main each found a head's file data and component the same way: the local file or a library, then the component, deleted or not. ctf/head-component gives both, or nil when they are not there. - ctf/get-component-shape, the child of a component by id, shared its name with ctn/get-component-shape, the top copy root of a shape; it is get-component-child. - generate-keep-touched found, in one reduce, the shape in the new copy that each touched child of the old copy relates to, carried the child's overrides into it or kept a swapped child in its place, and collected the parents of the related shapes in a binding named parents-of-swapped, though it held the parent of every related shape. It now pairs the shapes first (the old child, the main shape it refers to, the related new shape, and whether the old child was swapped), then carries each pair in a reduce, and gives the parents from the pairs. The pairing reads the page as it was before the switch, as the reduce did, so the result is the same. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 16.5 / 0.47 edit-through-a-chain-of-dependents 68.9 / 34.4 / 0.50 library-update-into-copies 83.1 / 37.7 / 0.45 main-edit-among-many-shapes 4.8 / 2.6 / 0.53 main-edit-into-10-copies 1.8 / 0.7 / 0.42 main-edit-into-100-copies 15.4 / 8.4 / 0.55 main-edit-into-1000-copies 153.1 / 77.9 / 0.51 variant-switch-into-copies 32.1 / 18.2 / 0.57 AI-assisted-by: claude-opus-5-5 |
||
|
|
fa193a07f6 |
🐛 Walk a ref chain to its target and across containers
get-ref-chain-until-target-ref, which the keep-touched variant switch and the paste of a nested copy use to collect the touched groups along a copy's ref chain, had two faults: - it compared each shape with the target's id, so it never stopped at the target and merged the touched groups of the whole chain (F62): a copy pasted out of its main could take overrides its new near main does not have; - it looked each next link up in the container it started in, so a chain whose next main lives on another page, or in a library, stopped after one link and missed the groups beyond it (F31). It now compares ids and takes each link's own container. The golden master and the composable cases are unchanged: none of them reaches a chain past its target or across containers with an override. file-test checks both: the chain stops at a target in its middle, and it follows a copy on one page through mains on another. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 16.0 / 0.45 edit-through-a-chain-of-dependents 68.9 / 33.7 / 0.49 library-update-into-copies 83.1 / 38.6 / 0.46 main-edit-among-many-shapes 4.8 / 2.2 / 0.46 main-edit-into-10-copies 1.8 / 0.7 / 0.42 main-edit-into-100-copies 15.4 / 7.1 / 0.46 main-edit-into-1000-copies 153.1 / 77.2 / 0.50 variant-switch-into-copies 32.1 / 17.1 / 0.53 AI-assisted-by: claude-opus-5-5 |
||
|
|
e6d5f1fe35 |
🐛 Check and commit plugin component calls as the UI does
- The UI offers swap and reset only on copy heads, and detach only on copies that are not inside another copy. The plugin's swapComponent and resetOverrides accepted any shape inside a copy (F92), and its detach any shape at all (F93), and sent the same events, whose changes expect a head. swapComponent and resetOverrides report "The shape is not a component copy head" for anything else; detach reports a shape that is not a copy, or a copy inside another copy, by ctn/detachable?, which the detach event checks too. swapComponent looked up the target file before checking its argument, so a value that is not a component failed an assert instead of reporting "Component not valid". - The plugin's switchVariant did not check whether the switch would nest a component in itself. The switch event checks that and calls the :on-error of its params' metadata; the plugin passed none, so the default, rx/throw, built an error observable that nothing used, and the call did nothing and reported nothing (F53). The plugin passes an :on-error that reports "The switch would create a component nesting loop", and the event's default does nothing. - Choosing a property value in the design tab with several members selected sent, for each member, an event that set the value and another that cleared its error, each its own commit and undo entry. The plugin's setVariantProperty set the value and left the error as it was (F94). dwv/update-property-value takes the components, and sets the value and clears the error of each in one commit; the design tab passes the selected members, the plugin its component, and the composable interpreter the one member of its edit. shape-bugfixes-test checks each plugin case on the proxy, and workspace-variants-test sets a value on two members with errors. AI-assisted-by: claude-opus-5-5 |
||
|
|
2a210bddc8 |
✨ Build variant actions and update main changes in common
Several actions built their changes in frontend events, step by step, each step its own commit, so the pure runner could not run them and one undo could revert half an action. - Renaming a variant member in the layers panel sent three events, each in its own undo transaction: update the properties from the typed formula, remove the properties no member gives a value, and set or clear the member's error. One undo reverted only the last. clvp/generate-rename-member builds the three steps in one changes (with generate-update-properties and generate-remove-empty-properties), and dwv/rename-member commits it once with the same analytics events. update-properties-names-and-values goes; remove-empty-properties and update-error stay for the design tab. - Transforming a component into a variant sent about fourteen events, and combining components as variants that transform and eight more. clv/generate-transform-in-variant and clv/generate-combine-as-variants build those steps as one changes, from the generators common already had, and the events commit them once. The flex layout initializer moves to common as cls/layout-initializer. Combine still sizes the board with two frontend dimension updates inside its undo transaction: the frontend applies them through modifiers, which common has no step for. - update-component split the changes of an update main (the copy's, marked :local-change?, from the main's, which may belong to a library) with two transducers; cll/split-local-changes does it. Its undo-group argument, never passed, goes, and the main's library is the copy head's :component-file, which it looked up through the file data only to read its id back. - dwv/valid-variant-switch? read the libraries from the state and repeated the lookup of clv/find-switch-target. clv/valid-switch? takes the libraries and asks find-switch-target; the plugin's switchVariant and the frontend test call it. Behavior changes: - One undo after a rename of a member, a transform or a combine reverts all of it. After a transform, it reverted only the sync of the copies' names (F6 in case AT); its pins go. - The transform no longer sends the "create-board" analytics event of the board it creates around the main. In the composable suites, renaming a member in the layers panel, removing the empty properties and setting a member's error are variant property edits both runners run, and case AW joins the JVM set. Its rename node drives the real layers-panel rename, which removes the empty properties too, so the step that removed them goes. The golden masters are recorded again for AW (the JVM one gains the case, the frontend one loses that step) and for AT (its final states no longer list the F6 undo failures). AI-assisted-by: claude-opus-5-5 |
||
|
|
29c49b0fe0 |
🐛 Remove the right empty variant properties when there are many
remove-empty-properties, run after a rename in the layers panel, found the position of each property by grouping all the properties of the variant's components by name and numbering the groups. A map of more than eight keys does not keep their order, so with nine or more properties the numbers did not match the positions and the wrong property was removed: with ten properties and the sixth empty in every component, it removed the first (F86). The positions now come from the properties themselves: a property is removed when its value is empty in every component. The work moves to common as clvp/generate-remove-empty-properties, and the event only commits what it builds. workspace-variants-test checks the case with ten properties. AI-assisted-by: claude-opus-5-5 |
||
|
|
f2b252f842 |
♻️ Share code that the component events repeated
- component-changed marked the component modified and sent launch-component-sync, its only caller, which opened an undo transaction around the syncs of the copies. component-changed sends those events itself, in the same order. - The library update notification found the libraries that would change with a lazy filter, so the check ran up to the first one when the notification was built and the rest when the user clicked "Update". It is a filterv, so the whole check runs when the notification is built, as the comment above it says. - The check that a new name is valid (trimmed, not blank, no trailing separator) was written in the shape rename and the component rename; cpn/clean-valid-name gives the cleaned name, or nil. - Paste and the move of shapes into a parent each tested whether they put a component head inside a variant; ctn/head-into-variant? does. - Going to the local main of a component and combining components into variants each opened another page and ran their work once it was initialized, with the same stream code; dwl/go-to-page-then does it. - Making components from the selection mapped the check over the shapes and then tested every result; every? takes the check. - generate-components-menu-entries reads the current file and page with mf/use-ctx, so it is a hook, and called conditionally it would break the order of its caller's hooks. It is use-components-menu-entries, and its docstring says why. The analytics events that paste and duplicate send for each new shape are built the same way but look up the parent's layout in different objects, so they stay apart. No behavior change: the suites are unchanged. AI-assisted-by: claude-opus-5-5 |
||
|
|
2351a0c8a2 |
📎 Count only a thumbnail test's own RPC calls
The clear-thumbnail-batch tests mock the RPC and count every call recorded while the mock is installed. A thumbnail deletion batch left over by an earlier test of the suite (a component sync clears the thumbnails of the frames it changes) can call it in that window, so the tests failed in the full frontend run from time to time and passed alone. - The batch tests count the calls about their own object ids. - The test that clears an empty queue expected no call at all; a call made by the event under test could only carry no object ids, so it checks that none of the recorded calls is such a call. AI-assisted-by: claude-opus-5-5 |
||
|
|
4a0b826ddc |
♻️ Build the changes of the variant property events once
- Removing, reordering and setting the value of a variant property each wrote the component's new properties and then the variant name of its main, built from them. set-variant-properties does both. - ctv had two functions that find the position of a property by name; the private find-index-by-name goes. - generate-reorder-variant-poperties and the frontend event reorder-variant-poperties are spelled reorder-variant-properties. - Nine events of workspace/variants (set the value, the name, the error, the variant id, add, remove, remove the empty ones, reorder, and update after a rename in the layers) each looked up the page, its objects and the file data, built the same empty changes, and committed them inside an undo transaction. variant-changes builds those changes, and the transactions go: each one held a single commit, which already makes one undo entry on its own, or joins the transaction a caller has open. No behavior change: the golden masters and the suites are unchanged. AI-assisted-by: claude-opus-5-5 |
||
|
|
f5e74cbd0d |
🔥 Remove dead code from the component helpers
- generate-relocate set :component-root on the moved shapes twice. The second pass looked at the same copy heads again and wrote the values the first had written, and update-shapes had already applied them, so it emitted nothing. It goes. Delete and relocate found the masked groups whose mask leaves them with the same reduce; groups-losing-mask finds them for both. The comment "Remove the hide in viewer flag" sat above code that sets it. - generate-duplicate-shape-change took unames, update-unames! and ids only to pass them to its own recursive call, assigned each shape the name it already had, and had a 13-argument arity that nothing called. It takes the values that stay the same along the subtree in one map, and it is private. - make-component-instance collected the names used in the page and the new ids in two volatiles: the names were never read, and the ids only fed a second remap of the grid cells, which clone-shape already does. - ctk/in-component-copy-not-root? had no caller, and ctn/get-container-root and ctk/valid-touched-group? only their tests. - ctf/swapped-subhead? tells whether a copy head refers to a main shape that is not a child of the main shape its parent refers to; its docstring still spoke of swap slots. It is ref-outside-near-main?. - dump-shape-component-info built the name of a nested head's component in a branch whose condition ended in `true`, so it never ran. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 16.1 / 0.46 edit-through-a-chain-of-dependents 68.9 / 30.1 / 0.44 library-update-into-copies 83.1 / 39.4 / 0.47 main-edit-among-many-shapes 4.8 / 2.2 / 0.45 main-edit-into-10-copies 1.8 / 0.8 / 0.44 main-edit-into-100-copies 15.4 / 7.7 / 0.50 main-edit-into-1000-copies 153.1 / 78.1 / 0.51 variant-switch-into-copies 32.1 / 18.5 / 0.58 AI-assisted-by: claude-opus-5-5 |
||
|
|
d8ba23d2af |
♻️ 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 |
||
|
|
bc5d6bc4fe |
♻️ Simplify the component sync walk and its changes
- The sync walk took its direction as a map of seven closures (direct-sync, inverse-sync). The two directions are fixed, so each is now a plain recursive function: sync-copy-recursive (main to copy, a sync or a reset) and update-main-recursive (copy to main), with sync-copy-shape and update-main-shape for the pair itself. Both share compare-children, which takes the children, a pair? predicate and four callbacks instead of 13 arguments; the inverse branch's call of the swapped callback, which could never run, goes. - The container of the main shapes is found once, at the start of the walk. The walk holds the :remote-synced a reset or an update main gives the copy shapes, instead of initial-root? and two branches. add-shape-to-instance and add-shape-to-main take the parent from the pair being walked instead of searching the whole subtree for it. - pcb/add-changes takes a container and the redo and undo changes, and applies them locally. Eight sync functions and the library typography sync each wrote that block; make-change goes from both namespaces. One list holds the attributes that link a copy shape with its main, and one shape-label prints shapes in the log lines. - change-touched chose what to write from three flags. It is now set-touched, which takes the value each caller computes. The swap's restore of the kept properties passes ignore-touched instead of calling it afterwards to put the groups back. - generate-sync-shape was a multimethod over the three asset types; it is a case in generate-sync-container. generate-sync-file loses its unused arity. The 100-line comment that opened the sync helpers described an older model; a short note points at the component sync contract. text-content-with-overrides only called txt/merge-overrides, which sync and switch now call. - update-flex-child-copy-attrs and update-flex-child-main-attrs are one function, update-flex-children, and the omit-touched? argument that was always false goes. - In the variant switch, sized-geometry-value builds the resized :selrect and :points that two geometry policies each built, and a binding of skip? shadowed by a second one is skip-attr?. add-shape-to-main keeps two changes that look wrong, now marked F102: its :reg-objects names the component, so it only reaches a deleted component's objects, and the undo of its new shapes deletes them from the copy's page. No behavior change: the golden masters and the suites are unchanged. libraries.cljc: 1816 -> 1284 lines. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 15.2 / 0.43 edit-through-a-chain-of-dependents 68.9 / 35.7 / 0.52 library-update-into-copies 83.1 / 40.5 / 0.49 main-edit-among-many-shapes 4.8 / 2.4 / 0.51 main-edit-into-10-copies 1.8 / 0.8 / 0.46 main-edit-into-100-copies 15.4 / 7.8 / 0.51 main-edit-into-1000-copies 153.1 / 97.4 / 0.64 variant-switch-into-copies 32.1 / 17.6 / 0.55 (The machine was busy for the 1000-copies world: its mean was 119 ms, against about 83 in the runs of the earlier steps, at 0.49-0.52.) AI-assisted-by: claude-opus-5-5 |
||
|
|
d1104c608b |
⚡ Make component sync about twice as fast
A profile of the benchmark worlds showed where a sync spends its time. - The token sync of shapes that apply no tokens took 38% of a sync in the 1000-copy world: for every synced shape, generate-update-tokens walked every sync attribute, resolved its group and its token attributes, and only then found that neither the main shape nor the copy applies a token. It now returns at once, and the token attributes of each sync attribute are computed once, at load. - A sync looks through every object of each page and component to find the copies it updates, so in a file with many shapes and few copies the search costs more than the sync itself. The new benchmark world main-edit-among-many-shapes syncs a main edit into 10 copies in a page with 20,000 plain rects; its baseline, measured before the change below, joins the recorded one with a note, and its result joins the golden master. generate-sync-container called the uses-assets? multimethod on every object through a lazy filter; the predicate is now chosen once per container (asset-user?) and the objects walked with reduce-kv into a vector, in the same order. - compare-children searched the remaining children (d/seek) for every child out of place, and dropped a child taken out of order with a lazy remove, so a reorder took quadratic time and nested one lazy sequence per child taken. It now looks children up by their reference or id (index-positions) and skips those taken out of order when the walk reaches them, with the same callbacks in the same order. The sync of a reorder stays slow: when a main with 1,000 children moves its first one to the end, each copy still gets 999 moves (F85), and applying them is what costs (4.75 s to 4.26 s for 10 copies). Fewer moves change what a sync writes, so that waits for Phase 4c. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 18.6 / 0.53 edit-through-a-chain-of-dependents 68.9 / 36.4 / 0.53 library-update-into-copies 83.1 / 43.0 / 0.52 main-edit-among-many-shapes 4.8 / 2.3 / 0.48 main-edit-into-10-copies 1.8 / 0.9 / 0.48 main-edit-into-100-copies 15.4 / 8.4 / 0.54 main-edit-into-1000-copies 153.1 / 90.1 / 0.59 variant-switch-into-copies 32.1 / 20.0 / 0.63 (main-edit-among-many-shapes: baseline measured before the search change.) AI-assisted-by: claude-opus-5-5 |
||
|
|
613f126414 |
🔥 Remove dead and deprecated code from component sync
- The :shapes-group reads. Sync skipped adding, removing and moving the children of a copy whose touched groups held :shapes-group. Nothing has set that group since 2024 (it was removed then, with a script that cleaned it from files), and the component contract makes structure follow the main, so the guards never applied. They go, with the omit-touched? argument they alone used in add-shape-to-instance, remove-shape and move-shape. - :fill-color, :fill-opacity and :stroke-width in ctk/sync-attrs, marked deprecated. Shapes keep fills and strokes in :fills and :strokes, migrations moved the old shape-level attributes there, the shape schema no longer has them, and no test syncs them any more, so sync no longer checks them on every shape. The TODO about concat-changes in generate-sync-file goes too: concat-changes no longer nests lazy sequences. - The inverse sync's swapped callback. An inverse sync pairs no swapped child, and every pair it makes counts as synced, so compare-children never calls it; the inverse direction now has no :swapped and passes nil. - The detach in generate-relocate of a copy shape moved out of its component. It only looked at the copy heads among the moved shapes (get-child-heads), and a head is never a non-head copy shape. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 38.2 / 1.08 edit-through-a-chain-of-dependents 68.9 / 65.0 / 0.94 library-update-into-copies 83.1 / 77.3 / 0.93 main-edit-into-10-copies 1.8 / 1.5 / 0.86 main-edit-into-100-copies 15.4 / 15.1 / 0.98 main-edit-into-1000-copies 153.1 / 154.8 / 1.01 variant-switch-into-copies 32.1 / 27.5 / 0.86 AI-assisted-by: claude-opus-5-5 |
||
|
|
e9c00f5bc0 |
🐛 Keep the undo of a large component sync from overflowing
Undoing a main edit synced into thousands of copies overflowed the stack, on the JVM and in JS (F84). Each synced shape added the undo of its parents' update to the end of the undo list with a lazy concat, so the list nested one lazy sequence per synced shape, and reading it recursed through all of them. concat-changes and the inverse sync's marking of local changes (a map over the whole list at every level of the walk) added more. - A shape's :reg-objects undo now goes right after the shape's own. Each parent is still updated after all its children are undone, and a synced parent gets back its stored values instead of recomputed ones; the undo round trip of every composable case is unchanged. - concat-changes builds the joined undo list at once, at a cost that grows with the list it adds. - The inverse sync collects the copy shapes it syncs and marks their changes as local once, at the end, instead of at every level. large-sync-undo-test builds 5,000 copies in one step, syncs a main edit into them and reads the undo list. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 37.3 / 1.06 edit-through-a-chain-of-dependents 68.9 / 69.8 / 1.01 library-update-into-copies 83.1 / 76.7 / 0.92 main-edit-into-10-copies 1.8 / 1.6 / 0.88 main-edit-into-100-copies 15.4 / 15.2 / 0.99 main-edit-into-1000-copies 153.1 / 152.9 / 1.00 variant-switch-into-copies 32.1 / 27.3 / 0.85 (Measured with the dead :shapes-group reads already removed; run side by side, this commit and that state give the same ratios.) AI-assisted-by: claude-opus-5-5 |
||
|
|
988b0c0759 |
♻️ Move the placement step of a transform to common
Which shapes of a copy a transform leaves untouched (the ignore tree) was frontend code, written twice: calculate-ignore-tree for the modifiers of the SVG renderer and calculate-ignore-tree-wasm for the matrices of the WASM renderer. The second is the single-pass version of the first: each shape still compares itself with the copy root of the latest transformed shape above it, as the repeated walks did when the last one won. app.common.logic.placement now holds it once: calculate-ignore-tree takes the transformed ids in order and a function that transforms a shape, built with modifiers-transform (SVG) or matrices-transform (WASM), both cached per shape. check-delta and transform-attrs moved with it. The frontend calls the common version, and its tests moved to common-tests.logic.placement-test, which runs on the JVM and in JS, with a rotation through modifiers added. No behavior change: the golden masters and the suites are unchanged. The benchmark does not run this code. AI-assisted-by: claude-opus-5-5 |
||
|
|
a063557376 |
♻️ Build the component sync events in one place
update-component-sync and launch-component-sync both emitted the same pair of sync-file events: the current file, and the library file when it is another one. component-sync-events builds them for both, each event keeping its own undo transaction. The two-argument arity of launch-component-sync had no caller and is gone. No behavior change: the frontend suite and its golden master are unchanged. No common code changed, so the benchmark does not apply. AI-assisted-by: claude-opus-5-5 |
||
|
|
7a2bd29a19 |
♻️ Split app.common.logic.libraries by concern
The namespace held 3,300 lines: component sync, instance creation and detach, swap and the variant switch's attribute carry, duplication, and the sync of library colors and typographies. Each now has its own namespace: - app.common.logic.libraries (cll): component sync, reset, update main, attribute and placement sync, and the sync of a file with its libraries; - app.common.logic.instances (cli): create, instantiate and restore a component, detach a copy; - app.common.logic.swap (clsw): swap a copy head, carry overrides across a variant switch; - app.common.logic.duplicate (cld): duplicate components and shapes; - app.common.logic.library-assets (clla): sync of library colors and typographies, which the asset multimethods delegate to. Each new namespace may require libraries, never the reverse; the helpers they share became public there (attr-operations, reposition-shape, orient-like, swap-content-root-id, ...). Callers use the new aliases. The functions move as they were. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 37.3 / 1.06 edit-through-a-chain-of-dependents 68.9 / 69.7 / 1.01 library-update-into-copies 83.1 / 79.8 / 0.96 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 / 156.0 / 1.02 variant-switch-into-copies 32.1 / 27.0 / 0.84 AI-assisted-by: claude-opus-5-5 |
||
|
|
ed7736916a |
♻️ Share one walk and one attribute loop in component sync
Sync and the variant switch each copied attributes with a loop of their own (update-attrs, update-attrs-on-switch), and direct and inverse sync each walked the shape tree with a function of their own. - One attribute loop, attr-operations, builds the redo and undo :set operations. It takes a policy per attribute, sync-attr-operations or switch-attr-operations, which gives the [attr value] pairs to set: the attribute, after the derived ones it resets (position data before a text content, the transforms before a rotation). The two callers keep only what differs: which attributes they carry and how they end (the switch sets the copy's new touched groups). text-change-value and switch-text-change-value are one function, text-content-with-overrides; each caller passes the override groups it carries. - One walk, sync-shape-recursive: sync the pair, pair the children with compare-children, recurse, finish. Two direction maps hold what differs: direct-sync (main to copy: a normal sync or a reset) and inverse-sync (copy to main, update main). Each gives the pair's sync, the four compare-children callbacks, the work after the children, and the container of the main shape. What stays the same along a walk travels in one context map instead of eleven arguments. No behavior change: the golden master and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 36.0 / 1.02 edit-through-a-chain-of-dependents 68.9 / 66.4 / 0.96 library-update-into-copies 83.1 / 74.9 / 0.90 main-edit-into-10-copies 1.8 / 1.5 / 0.82 main-edit-into-100-copies 15.4 / 14.7 / 0.95 main-edit-into-1000-copies 153.1 / 144.8 / 0.95 variant-switch-into-copies 32.1 / 26.7 / 0.83 AI-assisted-by: claude-opus-5-5 |
||
|
|
97abc1ea18 |
✨ Record a golden master of the component code
Before refactoring the component sync code, record the exact files the component tests produce, so a refactor that changes any of them fails. The JVM test common-tests.logic.golden-master-test runs every variant of the composable cases the pure runner runs and every benchmark world. It records the file after each user, undo and redo step (the runner's new :observe option), the final files with the signatures of the undo/redo round trip failures, and each world's file after its timed work. The frontend interpreter records the store's files of every variant of its cases and of the completion contract the same way. app.common.test-helpers.golden-master writes a state as the normalized files in sorted "path value" lines: ids become test labels, or numbers in tree order when unlabelled; grid cells are named after their shape and position; timestamps are left out. So a state is the same in every run whatever ids it draws. Only a digest per state is committed; the full lines go to tmp/golden-master/ and tmp/golden-master-frontend/ (recorded/, and actual/ for a state that differs), so diff -r shows a change. PENPOT_GOLDEN_MASTER=record records them again. Both sides repeat exactly across runs. Dropping :opacity from the attributes sync copies fails the case C variants that edit it. The interpreter's fixture also turns cts/wasm-enabled? off for each composable test and restores it after, as it already restores the store. A test that starts the WASM renderer left the flag on, so in a full frontend run the cases ran on WASM shape proxies, which leave out the attributes that are nil or false, and the frontend golden master differed from a run of the cases alone (:flip-x and :flip-y). AI-assisted-by: claude-opus-5-5 |
||
|
|
355e61fec0 |
✨ Give a synced copy text variants of its own font families
Sync and the variant switch can take a copy text's family from one side and its weight from the other. The variant id goes with the weight, so it can name a variant of the other family: the WASM renderer then picks the closest variant, but the SVG renderer falls back to the regular one. The frontend now gives each text content that a sync or a component swap commits variants of its own families: a run whose variant is not one of its family's takes the family's closest to its weight and style (cttx/update-font-variants, fonts/find-closest-variant). The pure runner has no fonts, so case BA leaves variant ids out of its comparison. The merge also leaves alone a run that already shows the placed style, so a main edit no longer rewrites the variant ids of a copy whose family is its own; with a new family, the run still takes the placed variant id. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 35.7 / 1.01 edit-through-a-chain-of-dependents 68.9 / 65.2 / 0.95 library-update-into-copies 83.1 / 75.1 / 0.90 main-edit-into-10-copies 1.8 / 1.5 / 0.86 main-edit-into-100-copies 15.4 / 15.1 / 0.98 main-edit-into-1000-copies 153.1 / 151.4 / 0.99 variant-switch-into-copies 32.1 / 30.4 / 0.95 AI-assisted-by: claude-opus-5-5 |
||
|
|
f0d24314d7 |
✨ 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 |
||
|
|
98107414fb |
✨ Give each style of a copy text its own override
A copy text had three override subgroups: its letters, any style, and its structure (the number of paragraphs or runs). One style override kept every other style of the main out (F72), letters and a style together kept everything out (F73), a structure change kept the main's styles out unless both texts had one style (F74), which the editors' content never had (F79), and alignment, direction and vertical alignment overrides were lost at the next sync (F76). Now a copy text has :text-content-text for its letters and line breaks and one group per style unit: family, weight (with style and variant), size, line height, spacing, decoration, case, fills, typography, alignment, direction and vertical alignment (cttx/style-units). An edit sets the groups of what it changes: a letter both texts share that changes its style, or a style that was not in the text before, so typed letters touch no style. A token in the content sets the groups of the styles it sets, and token sync of a text follows them. Sync and the keep-touched variant switch share one merge (cttx/merge-overrides): the letters of one side, and each unit from its side placed on them. A unit with one value applies everywhere; else letters both texts share (stretches of three or more) take the source's style, and the rest follow the letter before them. The switch carries an override only when the old and new mains agree on it (F77, F81), and the copy then holds the groups its result differs in. A typography reference stays only when every unit it sets comes from its side. The variant id goes with the weight; where the family comes from the other side, the frontend has to pick the family's closest variant. app.common.types.text reads a content as letters, each with its run and paragraph, and builds the content back from them; it gives the style of one unit for a letter, compares styles by what they show, and finds the letters two texts share with a letter diff (Myers). The component contract memory describes the model. Files older than this keep :text-content-attribute and :text-content-structure, read as every group they may cover until the migration recomputes them. Tests whose expected values the model changes: text-sync-test (the copy's weight, letters or new run no longer keep the main's size out; the letters read whole), text-touched-test (its edits went to the paragraph node, which shows nothing; they now edit the run), variants-switch-test (the copies with two runs keep their letters across a switch, and the size override the mains agree on), the editor output table, case Y and the notation test (new groups). The 1j pins of F72-F79 and F81 go; F79's direct test of equal-attrs? becomes a sync of editor content. A new check holds the production override rule to the 1j reference rule. New tests: the letter view, and a detached, duplicated or reset copy text keeping or clearing its override groups. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 36.6 / 1.04 edit-through-a-chain-of-dependents 68.9 / 67.2 / 0.98 library-update-into-copies 83.1 / 72.5 / 0.87 main-edit-into-10-copies 1.8 / 1.5 / 0.82 main-edit-into-100-copies 15.4 / 14.9 / 0.97 main-edit-into-1000-copies 153.1 / 153.4 / 1.00 variant-switch-into-copies 32.1 / 27.1 / 0.85 The worlds hold no text. A main text edit synced into 100 copy texts that changed their letters takes 40 ms, against 29 ms before: the merge aligns the letters of each copy with the main's. AI-assisted-by: claude-opus-5-5 |
||
|
|
c66928d555 |
🐛 Keep library color and typography syncs from touching copies
A library color sync wrote a copy's fills through update-shapes, and a library typography sync wrote a copy text's content with a plain :set, so both marked the copy as overridden (:fill-group, or :content-group with :text-content-attribute). A later edit of that fill or text style in the main then never reached the copy. Both syncs are derived writes: they now write with ignore-touched. The typography sync also merged the whole typography into each text node that uses it, so its :modified-at (and :path) ended in the content of the main and of every copy. It now writes only the text styles of the typography. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 34.7 / 0.98 edit-through-a-chain-of-dependents 68.9 / 67.6 / 0.98 library-update-into-copies 83.1 / 76.0 / 0.91 main-edit-into-10-copies 1.8 / 1.5 / 0.86 main-edit-into-100-copies 15.4 / 15.2 / 0.99 main-edit-into-1000-copies 153.1 / 157.1 / 1.03 variant-switch-into-copies 32.1 / 27.0 / 0.84 AI-assisted-by: claude-opus-5-5 |
||
|
|
51e9cdbad7 |
🐛 Count the shared copy heads in get-nesting-level-delta
get-nesting-level-delta counted the copy heads that a shape and its new parent share with a two-argument predicate over a seq of pairs, so it threw an arity error whenever both had copy heads. Its callers, duplicate and the advance of copied objects, only pass parents outside every copy today, so nobody reached it. The predicate now takes the pair and compares the heads by id. The advance of copied objects passed uuid/zero where the function expects a shape; it worked only because the id of a uuid is nil. It now passes the root shape. AI-assisted-by: claude-opus-5-5 |
||
|
|
4e5fd2aef6 |
🐛 Sync a reordered copy child into its main on update main
In an inverse sync, when a copy child paired with a main child further along the main's children, compare-children asked whether the copy child was the main of the main child, with the arguments of ctk/is-main-of? reversed. The answer was always no, so the pair took the swapped callback, which does nothing in an inverse sync, and only the move ran: the overrides of that copy child never reached the main. The UI does not let a copy reorder its children, so it only showed when the order differed for another reason, such as the undo of a sync reorder. The inverse branch now asks synced-pair? with the main child first, as the direct branch does. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 35.5 / 1.01 edit-through-a-chain-of-dependents 68.9 / 66.1 / 0.96 library-update-into-copies 83.1 / 77.0 / 0.93 main-edit-into-10-copies 1.8 / 1.6 / 0.92 main-edit-into-100-copies 15.4 / 16.2 / 1.05 main-edit-into-1000-copies 153.1 / 156.4 / 1.02 variant-switch-into-copies 32.1 / 28.3 / 0.88 AI-assisted-by: claude-opus-5-5 |
||
|
|
3b7061f8dc |
✨ Pin that a deleted component keeps passing changes on
When a component nested in others is deleted (inner in mid in outer, mid deleted), a change to inner still reaches mid's stored shapes, mid's copies, outer and outer's copies, and restoring mid brings back an up-to-date main. Keep this behavior: outer is a live component, and its users cannot see a deleted mid between it and inner. Extend the deleted-component propagation test with a copy of the deleted component and its restore, and reword the contract, which said copies of a deleted component do not sync. AI-assisted-by: claude-opus-5-5 |
||
|
|
f7a88d7f9a |
🐛 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 |
||
|
|
433d164074 |
✨ Build library files with current features and migrations
The file builder behind @penpot/library pinned the features and migrations it writes to the ones Penpot 2.7 accepted, so its files could open there. It now writes the current defaults: the features a new file gets and every available migration. Generated files were already in the current format (paths, for one, are stored as path data), so they now declare fdata/path-data and components/swap-group as they should, and the import runs no migration on them. Files built this way need the Penpot version the library is built with, or a later one. AI-assisted-by: claude-opus-5-5 |
||
|
|
570c1f3d29 |
✨ Turn a swapped copy head with the child it replaces
A swapped copy head followed the position, constraints and layout item attributes of the main child it replaces, but not its rotation or flips. When the main rotated or flipped that child, the copies that kept it turned and the swapped ones did not. Sync, and a variant switch that carries a swap, now give the head and its subtree the flips and then the rotation of that child, around the head's own center, unless the user changed the head's geometry. The swap itself uses the same step. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 34.1 / 0.97 edit-through-a-chain-of-dependents 68.9 / 75.5 / 1.10 library-update-into-copies 83.1 / 81.3 / 0.98 main-edit-into-10-copies 1.8 / 1.6 / 0.88 main-edit-into-100-copies 15.4 / 15.6 / 1.01 main-edit-into-1000-copies 153.1 / 153.2 / 1.00 variant-switch-into-copies 32.1 / 31.4 / 0.98 AI-assisted-by: claude-opus-5-5 |
||
|
|
fb75fcb84b |
🐛 Keep the rotation and flips of a swapped shape
A swap placed the new copy at the position of the shape it replaces, but not with its rotation or flips. When that shape was rotated or flipped with its parent, the new copy came out straight. The new copy and its subtree now take the flips and then the rotation of the shape it replaces, around their own center, as geometry that comes from the parent, not as an override. A variant switch goes through the same swap. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 38.3 / 1.09 edit-through-a-chain-of-dependents 68.9 / 70.7 / 1.03 library-update-into-copies 83.1 / 82.2 / 0.99 main-edit-into-10-copies 1.8 / 1.7 / 0.95 main-edit-into-100-copies 15.4 / 16.7 / 1.08 main-edit-into-1000-copies 153.1 / 167.4 / 1.09 variant-switch-into-copies 32.1 / 29.6 / 0.92 AI-assisted-by: claude-opus-5-5 |
||
|
|
eb09633ae5 |
🐛 Keep a swapped child when a variant switch is undone
A variant switch with keep-touched moves a swapped child out of the old copy to the page root, then into the new copy, which takes the same id as the old one. The undo of the second move took the child back to that id, so it landed in the new copy and went away with it when the undo deleted the new copy. The undo now takes it back to the root, and from there into the old copy. AI-assisted-by: claude-opus-5-5 |
||
|
|
b21e2775c1 |
✨ Place a swapped child where the new variant has it
A variant switch with keep-touched carries a swapped child into the new variant, but left it where it was in the old one. The next sync then moved it to the place of the child it replaces, at a moment unrelated to the switch. The switch now gives it that placement at once, through the same step sync uses: position, constraints and layout item attributes the user did not change. Benchmark, against the same code without this change under the same load (lower quantile, ms; without / with): deepest-edit-through-three-levels 36.0 / 36.0 edit-through-a-chain-of-dependents 65.2 / 70.2 library-update-into-copies 74.3 / 75.9 main-edit-into-10-copies 1.6 / 1.5 main-edit-into-100-copies 16.3 / 15.7 main-edit-into-1000-copies 162.6 / 165.8 variant-switch-into-copies 27.2 / 27.8 AI-assisted-by: claude-opus-5-5 |
||
|
|
03312bfef4 |
✨ 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 |
||
|
|
122f64db3d |
✨ 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 |
||
|
|
07ab13d6bc |
🔥 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 |
||
|
|
86c0c76a86 |
✨ Reject imported components v1 files that have components
Both binfile importers stored a file without the components/v2 feature as a v2 file, though its component data was never migrated. Such a file is now rejected with :file-in-components-v1, the code that opening a v1 file already raises, when it has components or shapes linked to them (:component-id, :shape-ref or :main-instance). A file without component data takes the feature and imports as before. Since every imported file now has the feature before the pending migrations are registered, the no-op components/v2 migration goes. The import dialog explains the rejection: the entry says that the file uses an old type of components that Penpot no longer supports, with its own text, as errors.deprecated talks about opening a file. AI-assisted-by: claude-opus-5-5 |
||
|
|
85f663d000 |
✨ Test what copy texts keep through sync and switches
Add a battery of tests for text overrides in copies. - A short notation for text content in tests (thtx): paragraphs of runs with short style names, read back with equal runs joined, edits in the notation, set-text and set-content through the change path, and the override rule for texts (override-units), which says which overrides a copy must hold against its main. - Sync matrix: every whole-text copy edit against every main edit, plus the cards of the worked examples, the "Hello world" table, a token on a copy, typographies and a library color sync. Where the code differs from decisions (k) to (o), the result is pinned against F72 to F80. - Switch matrix: copy edits against variants that differ in letters, size, color, a new line or a bolded word; F76, F77, F79 and F81 pinned. - Composable case BA: EditText also edits fills, font family and a whole text; the copy and the main change different styles, then reset or update main, in both runners; F72 pinned. - Editor output: the content the WASM and v1 editors commit when a copy text is typed into, retyped, split with Enter and has a word bolded, recorded in the workspace and replayed on a copy. Typing sets only the text groups and a style change no text override; F79 pinned on both editors' content. |
||
|
|
270d0ac35d |
✨ 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. |
||
|
|
c0de54ac84 |
✨ Test the file and container helpers and the library events
The component helpers of types/file and types/container and the library events of the workspace had the lowest coverage left. - common-tests.types.file-test: the export detach, the reuse of the library page, the original ref of a swap, deleted components and used-in?. Pinned: F52 (a library copy nested in a local component loses its ref in every copy), F56 (the original ref of a swap is nil when the mains are on different pages) and F57 (library color refs inside :fills survive the export detach). - common-tests.types.container-test: the placement rules for moves and pastes (copies, mains, variant containers, deleted components, component loops) and small helper branches. A plain shape moved into its own child passes the rules (F58); relocate-shapes drops it first. - frontend-tests.data.workspace-libraries-test: the events on several shapes at once, each one undo entry; update of a library main; a blank rename; placing with an id ref. - workspace-library-updates-test: how a file learns that a library changed, and the sync dialog with its update and dismiss, with the RPC mocked. - workspace-library-links-test: linking, unlinking and sharing. |
||
|
|
0fe4a43009 |
✨ Test variant events, token sync and switch overrides
The variant events had almost no frontend tests, and several switch, paste, token and restore paths never ran. - Variant property edits (AR) and adding a variant (AS), in both runners, through the common generators and the dwv events. - A frontend-only node for the variant management events: transform (AT), combine (AU), rename (AV), rename in layers, removal of empty properties and member errors (AW), and the shortcuts (AX). Each case has copies of the members; direct tests cover the remaining branches. AV and AT confirm F6: the copies' name sync lands in an undo entry of its own (pinned). - Tests for valid-components-for-variants? and valid-variant-switch?. - The WASM variant switch test is registered in the runner; it never ran. - Token apply and remove nodes and case AY. Its undo confirms F55, an empty :applied-tokens map after undo (pinned). The interpreter waits for the token, since apply-token opens no pending work (F54). - A paste-to-root node and case AZ: F17 does not show through paste. The frontend copy helper merges touched groups as the clipboard does. - Switches that carry real geometry, path and text-structure overrides, resized through a transform. - Restoring a component whose page was deleted. |
||
|
|
356890a182 |
✨ Test the component sync paths no test reached
A coverage run found sync paths that no test ran. New cases, in both runners: - AK: update main into a grid main. With a swapped child the main's cells keep the copy child's id (F49, pinned). - AL: update main after renaming the copy root and its child. - AM: update main of a swap inside a group. The copy child keeps a swap slot that names a removed main child (F16, pinned). - AN: a copy flex child's margin override through a main edit, a reset and update main. The frontend round trip of the main edit shows F43 with real sizes (pinned). - AO: a copy dropped into a grid cell of a main. Its undo shows F41 (pinned). - AP: a library main deletes a middle child. - AQ: a main change reaches a copy nested in a sub-board. Also: the frontend interpreter sends the real reset event instead of installing a file; a test undoes and redoes an undo entry without a group, which only release builds store; a test confirms F50, a copy text touched only in :content-group losing its override (pinned). |
||
|
|
eee28e36bb |
♻️ Tidy the composable suite and make its cases stricter
A review of the suite after the new operations: - Dead helpers and duplicate nodes go; the lineage reset and swap nodes are constructors of reset and swap-shape. - Repeated helpers are shared: grid boards (tho/add-grid-frame, tho/place-in-grid-columns), n/nested-head-under, n/child-shape, r/sync-cascade (the benchmark uses it too), the frontend fixture and fill helpers, and the plugin getMainComponent. - Runner tests that repeated cases B, C and D go. - Comments say what the code does, and the wrong ones are fixed. - Case K's remote edit painted red on red and its unedited variants asserted nothing; it starts grey and every variant asserts. - F43 is pinned in one case, AJ, by the value one undo leaves. L, M, Q and K get real frame sizes, since their pins depended on when the 100 ms layout buffer flushed. - make-nested-component takes a level count and depth-sweep gives each depth once, so K, Q, R, T and W stop running depth 1 twice. - The completion-contract tests no longer depend on fixed pauses or on how the watcher batches its events. Benchmark against the baseline (lower quantile): ratios 0.92 to 1.03, no world flagged. |
||
|
|
c020a55d85 |
✨ Add a component sync benchmark with a recorded baseline
The coming refactors and fixes change sync code, and a slowdown must be seen and explained in the commit that causes it. A JVM benchmark now times component sync on fixed-size worlds: - one main into 10, 100 and 1000 copies; - a change in the deepest of three nesting levels; - a chain of dependents (a in b in c, with copies of each); - a variant switch in a main with 100 copies; - a library update into a file with 500 copies. Each world is built once; only the sync an edit causes is timed, run to a fixpoint as the frontend watcher does. Each checks its result, so a world that synced nothing fails instead of timing nothing. `scripts/bench-components record` writes the baseline, recorded here on the code before this commit; `scripts/bench-components` compares against it and exits 1 when a world is more than 10% slower. It compares the lower quantile: with the devenv running, the mean of the same code moved from 0.41 to 1.13 of the baseline, the lower quantile by under 12%. |
||
|
|
7400ada8b4 |
✨ 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. |
||
|
|
2cae81f588 |
✨ Add operations and cases to the composable component suites
The composable runners could edit, add, remove and move children, swap and switch, but not run the other component operations. New nodes run each one through the production generator in the pure runner and the real event in the frontend interpreter: - Detach, UpdateMain, DeleteComponent and RestoreComponent; - Duplicate, and NestCopy (a copy moved into a main); - EditText, with the automatic rename the text editor applies; - MoveShape (frontend only, through the sidebar) and MoveToCell; - swap-shape and reset on any copy head; - Undo with a step count, and an edit undone before its sync (frontend only). New cases, all with the undo/redo round trip: - Q, R, S, T, U: detach, update main, delete and restore, duplicate and nest, at nesting depths 0 to 2; - V, W, X: a new edit after undo, three undos over nested cascades, an undo before the edit's sync; - Y: text overrides against main text edits; - Z: a main child move; - AA to AF: swap outcomes on values and structure, and the grid cell of a swapped child; - AG, AH, AI: the plugin suite's slot alignment cases. The plugin suite gets OpDetach, OpSwap and OpReset, names that keep the roles of two nestable components apart, Assert.knownFailure, and ports of cases Q, AA, AB and K's reset. Supporting changes: nodes can record what they created (IAppliedResult); create-component takes a size, since with 0.01 frames the frontend reflows split undo entries at points that depend on timing (F43); the interpreter ends the text measurement headless, so text steps settle; tm/known-failure pins the wrong value a known bug gives in a case assertion; the interpreter no longer moves its undo baseline after a skipped optional, which cut the end-of-variant round trip short. Confirmed and pinned: F12 (resetting a nested swap clears the top copy's overrides), F27 (the automatic text rename touches :name-group), F29 (a second switch loses an override) and F44 (a swapped grid child misses the main's cell changes). F43 also shows through a plain sync at depth 2 (case Q). |