24328 Commits

Author SHA1 Message Date
alonso.torres
8ff5f78b72 🐛 Cancel WASM thumbnails when leaving the workspace
Cancel the full thumbnail upload/render stream on page or workspace
teardown and on matching thumbnail clearing. Deferred library thumbnails
can otherwise query a main missing from the next WASM scene and abort
the renderer (F112).

Complete the upload observable after its callback so successful requests
also release their subscriptions.

Add async regressions for pending current and library frames, late upload
callbacks, clearing, and successful rendering. They fail before the fix.
Document the thumbnail lifecycle in the frontend memory.

Frontend CI passes lint, 1499 tests / 7272 assertions, and formatting.
Live WASM update-main followed by navigation no longer crashes; an
uninterrupted library request refreshes its PNG thumbnail too.

AI-assisted-by: GPT-6
2026-10-22 15:26:51 +02:00
alonso.torres
3f00c53597 🐛 Preserve file destinations through undo and redo
Keep a namespaced file destination on each history change. Replay changes
in order in their target files, including stacked entries and transactions.
Keep payload fields named file-id intact, such as set-tokens-source (F111).

Snapshot the current file and every library in both composable runners.
Name the file in failure signatures; normalize the server synced-at status
stamp alongside modified-at. Update golden cases H and AP for this exclusion.
The library update regression checks both files through undo and redo and
failed against the old code.

Common JVM: 1705 tests; JS: 1633; frontend: 1495; backend: 1610; exporter: 14.
All pass, with lint and formatting checks for both changed modules.

Benchmark vs Phase 1e (lower quantile, ms):
world                 baseline     now   ratio
main-10                  1.790   0.993    0.55
main-100                15.415   9.038    0.59
main-1000              153.086  90.145    0.59
three-levels            35.215  18.989    0.54
chain                   68.931  35.045    0.51
library-500             83.083  85.830    1.03
many-shapes              4.837   3.344    0.69
variant-100             32.052  33.586    1.05
All worlds pass the 10% regression threshold.

AI-assisted-by: GPT-6
2026-10-22 14:37:48 +02:00
alonso.torres
91983bc07f 🐛 Keep component sync with each user state transition
Emit changes-applied with the commit's prior file data. Sync each commit
before a later edit can replace its state or separate its undo group.
Allow later commits through while completion barriers finish (F110).

Process layout batches after their buffer closes, so reentrant component
sync cannot lose a pending layout task. Cover three user edits without an
intermediate wait, then every undo and redo. Token and tiny-layout cases
also pass. Re-record golden cases X and AJ for the settled timing.

Frontend: 1495 tests, 7255 assertions pass; lint and format checks pass.
The three-edit regression failed before the fix.

Benchmark vs Phase 1e (lower quantile, ms):
world                 baseline     now   ratio
main-10                  1.790   0.993    0.55
main-100                15.415   9.038    0.59
main-1000              153.086  90.145    0.59
three-levels            35.215  18.989    0.54
chain                   68.931  35.045    0.51
library-500             83.083  85.830    1.03
many-shapes              4.837   3.344    0.69
variant-100             32.052  33.586    1.05
All worlds pass the 10% regression threshold.

AI-assisted-by: GPT-6
2026-10-22 14:37:05 +02:00
alonso.torres
8fea2a5c56 🐛 Preserve scaled content styles through component sync
Store cumulative content scale as an optional shape attribute, default 1.
Sync scales the main's styles by the copy/main ratio separately from
constraint resizing. Keep descendants untouched and allow later main style
edits to propagate (F109). Both renderer modifier paths use this field.

Cover a later sync, repeated scaling, main radius edits, reset and its undo.
The original stroke regression failed before the fix. Common JVM/JS and
frontend suites, backend and exporter tests, lint and format checks pass.
Historical scale gestures cannot be reconstructed from old file data.

Benchmark vs Phase 1e (lower quantile, ms):
world                 baseline     now   ratio
main-10                  1.790   0.993    0.55
main-100                15.415   9.038    0.59
main-1000              153.086  90.145    0.59
three-levels            35.215  18.989    0.54
chain                   68.931  35.045    0.51
library-500             83.083  85.830    1.03
many-shapes              4.837   3.344    0.69
variant-100             32.052  33.586    1.05
All worlds pass the 10% regression threshold.

AI-assisted-by: GPT-6
2026-10-22 14:36:25 +02:00
alonso.torres
032a097764 🐛 Bound component sync retries per component
Count repeated processing of each component instead of total graph width.
A valid dependency graph with 102 outer components now settles and updates
every copy (F108). The non-settling cascade guard remains bounded.

The regression failed at 100 passes before the fix, then passed with 103
components. Common JVM/JS, frontend, backend and exporter suites pass.

Benchmark vs Phase 1e (lower quantile, ms):
world                 baseline     now   ratio
main-10                  1.790   0.993    0.55
main-100                15.415   9.038    0.59
main-1000              153.086  90.145    0.59
three-levels            35.215  18.989    0.54
chain                   68.931  35.045    0.51
library-500             83.083  85.830    1.03
many-shapes              4.837   3.344    0.69
variant-100             32.052  33.586    1.05
All worlds pass the 10% regression threshold.

AI-assisted-by: GPT-6
2026-10-22 14:36:01 +02:00
alonso.torres
60ec9d7953 🐛 Preserve nested overrides when absorbing a component
Keep touched groups and remote sync state when cloning an absorbed main.
New component copies still start clean. Extend the nested absorption test
with head and descendant overrides, then sync the inner component (F107).

Validation: red regression before the fix; common JVM 1705 tests and JS
1633 tests, backend 1610 tests, exporter 14 tests pass.

AI-assisted-by: GPT-6
2026-10-22 14:32:04 +02:00
alonso.torres
3870b69bc0 🐛 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-16 12:25:28 +02:00
alonso.torres
c1bd705e64 🐛 Absorb the libraries a move to another team leaves behind
Moving files or a project to another team unlinked every library that
ended up in another team than the file using it, but absorbed nothing:
the copies kept pointing at a library their file could no longer
reach. A move now does what deleting the library does (PENPOT-1540):
it absorbs each such library into the files using it, then unlinks
them; as on deletion, a failed absorption is logged and does not stop
the move. That covers a moved library and the files left behind using
it, a moved file and the libraries it used, and a moved project.

The links checked are those that start or end at a moved file. They
were those of the files in the source and destination projects, so a
file in another project of the old team kept its link to a moved
library across teams.

rpc-management-test: a moved library is absorbed into a file in its
project and into one in a project the move does not touch; a moved
file absorbs its library; a moved project absorbs the library it left
behind.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
0bffa600d8 🐛 Absorb a deleted library into the files that used it
Deleting a shared library removes its links to the files using it at
once, so they stop listing it, and queues the delete-object job, which
absorbs the library into the files it finds through those links. By
then there were none, so nothing was absorbed: the files lost the
library and their copies kept pointing at the deleted file
(PENPOT-1092). Unpublishing absorbs before it unlinks, so it was not
affected.

The delete reads the files using the library before unlinking them
and gives them to the job (:absorb-file-ids), which absorbs into them;
without them, as from the project and team deletions, the job still
finds them through the links.

rpc-file-test/delete-shared-library-absorbs-it-into-the-files-using-it
deletes the library through the RPC and runs the job it queued.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
68bafacc9d 🐛 Store the fragments a library absorption changes
Absorbing a library into a file that uses it (unpublishing or deleting
the library) saved the file without its features. Saving stores the
changed pointer-map fragments only when the features name
fdata/pointer-map, so with that feature the file pointed at a fragment
of components that was never written, and loading it failed with
:fragment-not-found. The file's features now go with the save.

rpc-file-test/unpublish-library-absorbs-it-into-the-files-using-it
unpublishes a library with a copy in another file, then loads that
file. The existing absorption test turns fdata/pointer-map off, so it
missed this.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
086362be93 🐛 Keep a committed property name from being sent again
When the variant property panel went away (selecting another layer),
input-with-meta* sent a blur from the last input it had rendered, to
commit an edit still open. After an edit was already committed that
input is gone and keeps the text it had: reordering the properties
and then selecting a variant renamed the property now in that row
after the old one, "Property 1" becoming "Property 2 (1)"
(PENPOT-2588). The input is forgotten once its edit is committed, so
the blur is sent only for an edit still open.

ds-input-with-meta-test mounts the component in a jsdom document: a
committed edit is not sent again when the value changes and the input
goes away; an edit still open is committed.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
37e233f6e1 🐛 Take the first equally near variant on a switch
A switch picks the variant whose properties are nearest the wanted
ones; between equally near ones it took the last child of the variant
container. Since combine as variants keeps the order of the components
it combines, that is the last of them: switching an empty variant to
"primary" gave primary/icon+test where the QA case (PENPOT-2446)
expects primary/hover, the first primary variant. It now takes the
first child among the equally near ones.

variants-switch-target-test: two variants one value away, in both
orders of the container's children.

Benchmark against the 1e baseline (lower quantile, ms):

  world                               baseline     now  ratio
  deepest-edit-through-three-levels     35.215  16.810   0.48
  edit-through-a-chain-of-dependents    68.931  32.765   0.48
  library-update-into-copies            83.083  39.232   0.47
  main-edit-among-many-shapes            4.837   2.277   0.47
  main-edit-into-10-copies               1.790   0.807   0.45
  main-edit-into-100-copies             15.415   8.054   0.52
  main-edit-into-1000-copies           153.086  81.186   0.53
  variant-switch-into-copies            32.052  17.933   0.56

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
86e622ed44 📎 Count only the plugin calls in the plugin registry tests
The registry tests mock the RPC and check every call recorded while the
mock is installed. A component sync deletes the thumbnails of the
frames it changes 200 ms later, so a sync at the end of an earlier test
of the suite can call the mock in that window: under the coverage run,
the frontend suite failed in register-test, a different test each time,
with an extra :delete-file-object-thumbnails call. The mock now records
and answers only the plugin profile commands; any other call gets an
empty answer.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
3b790cdbe9 📚 Update the component memories for the audit and fix phase
The contract memory says how the code now holds it: the eager cascade
and where it lives, the automatic text name, the children of a
transformed copy shape, measured text sizes on both renderers, repairs
as derived writes, the tolerance of "differs" and the :set-touched
operation. The debugging recipes add the override scanner; the data
model and the composable test notes follow the changes.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
69bf2da97f ✨ Add a scanner of copies that differ from their mains
A copy keeps a value of its own only in a touched group, so a value
that differs from its main's in an untouched group is lost
propagation. app.common.logic.override-scan runs the sync of every
component a file uses in memory and lists the copy shapes it would
change, with the attributes and groups, or :structure for children
that differ. The size of a text that grows with its content and the
place of a layout child are left out: they are not the main's to
give. Copies of components a predicate names are skipped.

debug.scan_overrides() lists them for the current file in the browser
console, skipping copies of library components changed since the file
last synced with the library.

override-scan-test: a synced copy and an override give nothing; an
untouched difference, in a local or a library copy, is found; a
skipped component is not scanned.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
5da4793d2a ♻️ Write the touched groups with the :set-touched operation
Two operations set the touched groups of a shape: a :set of :touched,
which assigns the value as given, and :set-touched, which keeps them
only on a copy and drops an empty set. update-shapes and the undo of
add-component wrote the first while their own undo and sync wrote the
second. The contract prefers :set-touched; both builders now write it,
and an update with only its touched groups changed still records their
undo. A :set of :touched is still applied, for changes already stored.

Case AE leaves no empty touched set on the shape a detach turned plain;
both golden masters record the state again for it.

comp-touched-test: an update that sets the touched groups of a copy
shape writes one :set-touched operation, and its undo restores them.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  16.6 / 0.47
  edit-through-a-chain-of-dependents   68.9 /  32.8 / 0.48
  library-update-into-copies           83.1 /  42.8 / 0.51
  main-edit-among-many-shapes           4.8 /   2.3 / 0.48
  main-edit-into-10-copies              1.8 /   1.0 / 0.54
  main-edit-into-100-copies            15.4 /   8.9 / 0.58
  main-edit-into-1000-copies          153.1 /  82.0 / 0.54
  variant-switch-into-copies           32.1 /  17.7 / 0.55

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
52e1ddfd1f ✨ Sync the components a sync changes in the same commit
Decision (c) of the component contract, after a fix that made the sync of a
library file reach that file at all (Phase 3c).

- Commit the sync of a library file to that file.

  An update of the main of a library component, made from a copy in the
  file, syncs the copies of the component in the file and in the library.
  The changes of a sync carry no file id, so the commit took the current
  file's: the library's copies, among them those its other mains nest, were
  written to pages the current file does not have, and never changed (F19).
  A sync now commits to the file it syncs.

  workspace-remote-cascade-test: an update of the main of a library
  component reaches the copy of it that another library main nests.

- Sync the components a sync changes in the same commit.

  Decision (c) of the component contract: a sync reaches the copies of the
  components it changes, until none is left, in one commit. A sync of a file
  changed the mains of the components that nest the synced copies, and the
  component watcher synced those in a commit of its own, one level per pass,
  so the file stayed partly synced in between; the watcher only watches the
  current file, so a sync of a library file stopped at its first level
  (F19).

  common: generate-sync-file-cascade syncs a file as
  generate-sync-file-changes does, then the copies of each component of the
  file that the sync changed, one per pass, applying each pass to the file
  data, until none is left (generate-sync-components-cascade). The
  composable runner, the benchmark and the golden master use it.

  frontend: sync-file commits the whole cascade at once. When it syncs a
  library file, it then syncs the copies in the current file of the library
  components the cascade changed.

  workspace-remote-cascade-test: an update of the main of a library
  component, made from a copy in the file, reaches the file's copy of a
  library component whose main nests it. The common golden master records
  fewer changes undone in case W: a cascade is one change set.

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.2 / 0.48
  library-update-into-copies           83.1 /  43.6 / 0.52
  main-edit-among-many-shapes           4.8 /   3.5 / 0.73
  main-edit-into-10-copies              1.8 /   0.9 / 0.49
  main-edit-into-100-copies            15.4 /   8.1 / 0.52
  main-edit-into-1000-copies          153.1 /  83.9 / 0.55
  variant-switch-into-copies           32.1 /  19.6 / 0.61

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
e0bbc67605 ✨ Keep the children of a transformed copy shape derived
Decision (f) of the component contract, with a fix it needed and a check of
decision (g) (Phase 3c).

- Return empty modifiers for a scale constraint axis.

  The constraint of each axis of a child gives the modifiers that the
  parent's resize adds to the child; :scale has no method of its own and
  took the default, which returned an empty vector. Adding a vector to
  modifiers reads its :last-order as nil: JavaScript adds nil as 0, the JVM
  throws, so a child scaled on one axis and constrained on the other (scale
  and top, say) could not be resized on the JVM. The default now returns
  empty modifiers.

  geom-shapes-constraints-test checks the default is empty and adds to
  modifiers.

- Keep the children of a transformed copy shape derived.

  Decision (f) of the component contract: transforming a copy shape touches
  that shape only; its children change as a consequence and stay untouched,
  and sync gives them their expected geometry, their main's placed against
  their parent and resized through their constraints.

  Touch. The placement step decided per shape whether a transform touched it
  by comparing it with its copy root, so the children of a resized root that
  stretched (scale, left and right) were touched (F10), a derived width or
  height always touched (F22), and a resize that scales the content touched
  the strokes, radii and the like of every child (F39). The editor now tells
  the placement step which shapes the user transformed (the keys of the
  modifiers given, or those the stored modifiers were set for); any other
  shape of the transform changes only as a consequence of theirs (a child
  through its constraints, a board that hugs them) and touches nothing.

  Sync. Sync placed every main shape by the offset between the roots only,
  so the children of a resized copy root that a resize leaves untouched
  (center) went back to their main's place at the next sync of the main
  (F10). Now, before it walks the children of a copy shape that keeps its
  own geometry, sync resizes, turns and moves the main shape's subtree to
  match it, its children following through their constraints and layouts,
  and walks those: each child gets that geometry unless it has its own.

  comp-expected-geometry-test: a copy root resized as the editor does places
  a child constrained left, right, center, scale and left-right, leaves it
  untouched and keeps it there through a sync; a resize that scales the
  content scales the child's stroke and touches only the root. The frontend
  golden master records, in cases AI and the touched child under a main
  rotation, the children the resize or rotation of a root no longer touches.

- Check a copy child resize leaves its hug board untouched.

  Decision (g) of the component contract: the size of a board that hugs its
  content is the layout's, never an override. The layout reflow writes it as
  derived, and a transform touches only the shapes the user transformed
  (decision (f)), so a resize of a copy child that grows the board it sits
  in touches the child only.

  workspace-layout-owned-sizes-test pins it.

Benchmark (lower quantile, ms; baseline / now / ratio); the machine
was loaded, and the code before this change, run in between, gave the
same spread (0.44 to 0.59):
  deepest-edit-through-three-levels    35.2 /  16.7 / 0.47
  edit-through-a-chain-of-dependents   68.9 /  33.4 / 0.48
  library-update-into-copies           83.1 /  40.3 / 0.49
  main-edit-among-many-shapes           4.8 /   2.4 / 0.49
  main-edit-into-10-copies              1.8 /   1.0 / 0.54
  main-edit-into-100-copies            15.4 /   8.8 / 0.57
  main-edit-into-1000-copies          153.1 /  92.7 / 0.61
  variant-switch-into-copies           32.1 /  19.6 / 0.61

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
b57674b33c 🐛 Keep text names and measured sizes derived
The name a text takes from its content and the size it is measured to are
derived: they never touch a copy (decisions (d) and (g), Phase 3c).

- Let a copy text's automatic name follow its own letters.

  The editors name a text after its letters on every edit. In a copy that
  rename went through the same write as the content, so it touched
  :name-group (F27), against decision (d) of the component contract: only an
  explicit rename is an override.

  - update-shapes takes :ignore-touched as a set of attributes too, and the
    editors' commits (v1 and v3, finalize and keystrokes) and the replace of
    text pass #{:name}: the name follows the content, so its write is
    derived. Token unapply still runs for such an update.
  - Sync gives an untouched copy text the name generated from the letters it
    shows after the sync when its main's name is the one generated from the
    main's letters (decision (d), refined), and the main's name otherwise.
    So a copy that retyped its text keeps that name, and one that did not
    follows the main's.

  The pure runner's text edit makes the same commit. Case Y no longer pins
  F27 and checks the copy's name follows its letters; both golden masters
  record the copy without :name-group.

  text-sync-test: an untouched copy takes the main's letters and name, a
  copy that typed its letters keeps the name they give, and a main named by
  hand passes its name on.

- Name a text with several paragraphs without line breaks.

  The editors name a text after its letters, with the line breaks of its
  paragraphs included, so a text "Hello world" + Enter + "Ends Sunday" was
  named "Hello world\nEnds Sunday"; the layers panel showed a space there
  (F82). generate-shape-name now turns each line break, with the spaces
  around it, into one space.

  text-test: the name of a two-paragraph text joins them with a space.

- Keep the size a WASM text grows to from touching the copy.

  A text that grows with its content (auto width, auto height) gets its size
  from the WASM renderer after a font loads, a style changes, a paste or an
  edit ends. That size is derived from the content, yet the resize applied
  it as a user change, so a copy's text took :geometry-group each time and
  kept that size against its main (F1). The resize and the debounced resize,
  the size an edit ends with and its geometry undo now pass ignore-touched.

  These paths measure the text in WASM, so no headless test reaches them.
  Checked in the browser with the WASM renderer: an edit of a copy's text
  touches :content-group only, and a resize after a font size change of 24
  grows the text with no :geometry-group.

- Keep the finalize resize of a text out of the undo stack.

  When an edit of an existing text ends, the WASM renderer gives the size it
  grows to, and the finalize commit stores that size along with its undo.
  The resize that shows it on the canvas recorded an undo of its own with no
  undo group: a release build kept a second undo entry for one edit, and a
  dev build failed the undo entry check (F36). It now records none; a new
  text still joins the undo group of its creation.

  Checked in the browser with the WASM renderer: an edit of an existing text
  adds one undo entry.

- Keep the size a text edit ends with from touching the copy.

  When an edit of a text ends, the editor stores its content together with
  the size measured for it (the SVG renderer's text modifier, or the WASM
  measure in the v1 editor). Only the name was written as derived, so an
  edit of a copy's text touched :geometry-group besides :content-group, and
  the text kept its size against its main (F1, in the SVG renderer). These
  writes now take the size, the position data and the name as derived; the
  content still touches.

  workspace-text-modifier-test: an edit that ends with a measured width
  touches the copy text's content only. Checked in the browser with the SVG
  renderer: an edit of a copy's text touches :content-group and
  :text-content-text only.

- Measure again on WASM the texts a sync writes.

  The size of a text that grows with its content is derived (decision (g) of
  the component contract): sync writes the main's, and the renderer measures
  the text again. The SVG renderer does so when it draws the text; the WASM
  renderer did not, so a copy text with content of its own kept its main's
  width and height after a sync (F35). Now, with the WASM renderer, a sync
  of the current file measures again the auto-grow texts of the current page
  it writes, without a component sync of its own, in the undo group of the
  sync; a sync with no undo group gets one for both.

  Texts on other pages keep the size sync wrote until they are measured
  again.

  workspace-sync-wasm-text-test: a sync that writes a copy text with content
  of its own measures it again, in the undo group of the sync. Checked in
  the browser with the WASM renderer: after the main text grows or shrinks,
  two copy texts with content of their own get their own widths and no
  :geometry-group.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  18.1 / 0.51
  edit-through-a-chain-of-dependents   68.9 /  34.0 / 0.49
  library-update-into-copies           83.1 /  40.2 / 0.48
  main-edit-among-many-shapes           4.8 /   2.5 / 0.51
  main-edit-into-10-copies              1.8 /   0.8 / 0.44
  main-edit-into-100-copies            15.4 /   8.5 / 0.55
  main-edit-into-1000-copies          153.1 /  83.5 / 0.55
  variant-switch-into-copies           32.1 /  18.1 / 0.56

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
10f8dc9770 🐛 Fix the component watcher and library updates
Fixes in when the workspace syncs copies and propagates tokens, and in what
a layout wait covers (Phase 3c).

- Sync the copies when a shape is dragged inside a main.

  The component watcher dropped every commit flagged :translation? (the WASM
  drag commits only moves) so dragging whole mains around the canvas stays
  fast. A drag of a shape inside a main is flagged the same, so its copies
  never followed until some other edit of that main made them jump (F70).

  The watcher now drops a translation commit only when it moves shapes
  outside mains or whole mains: every shape it moves inside a main moves
  with the main's root (decision (q)). Otherwise the commit goes through
  component sync like any edit.

  workspace-sync-barrier-test: a translation of a whole main is dropped; one
  of a main's child syncs the copy. fill-commit in the interpreter takes
  several labels.

- Propagate the file's tokens after any library update.

  Updating a library from the notification synced the file and then
  propagated its tokens, so the copies take the token values of the file's
  token status, not the library's. The update button of the libraries
  dialog's Updates tab only synced the file (F83).

  dwl/update-libraries syncs the file from the given libraries and then
  propagates its tokens; the notification and the Updates tab both use it.

  workspace-library-updates-test: updating a library syncs it and then
  propagates the tokens.

- Sync copies whether or not component thumbnails are on.

  The component watcher's whole pipeline, the sync of the copies included,
  ran only when the :component-thumbnails flag was on or the WASM renderer
  was active. The flag is on by default, but an install that turned it off
  never synced copies on the SVG renderer: editing a main left its copies as
  they were (F8). The flag now gates only the thumbnail streams.

  workspace-sync-barrier-test: with the flag off, a main edit reaches the
  copy.

- Hold a token's shapes pending until the token is applied.

  A plugin's waitForLayoutUpdate waits until no shape has layout work
  pending. Applying a token resolves the tokens first, asynchronously, and
  opened no pending work meanwhile, so the wait found nothing pending and
  returned before the token reached the shapes or their copies (F54). The
  shapes are now pending, as a :token task, from the start of the resolution
  until the update is emitted, and the layout, text and sync work it starts
  takes over from there.

  The composable interpreter no longer waits on the token itself: case AY
  applies a token and checks its copies after the same wait a plugin uses,
  and fails without this change.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
1e61322dc5 🐛 Keep guides and reflows in the undo group of their step
Two derived commits that left the copies' sync in an undo entry of its own
(Phase 3c).

- Move frame guides in the undo group of their transform.

  A transform of a board sends the move of its guides before the shapes'
  update. The guide move committed every guide of the board, moved or not,
  each in a commit with an undo group of its own, so when the board was a
  main with a guide, its transform and the copies' sync landed in different
  undo entries: one undo reverted only the copies (F6).

  Both transform paths now give the shapes' update an undo group up front
  and pass it to the guide move, which commits only the guides that move,
  all in one commit in that group.

  workspace-frame-guides-undo-test: one undo reverts a rotation of a main
  with a guide, its copy included, and a move of a main together with its
  guide.

- Reflow layouts in the undo group of the step that asked for it.

  A layout reflow runs a moment after the commit that needs it: the
  :layout/update events are buffered for 100 ms and reflowed per page. The
  reflow lost the undo group of the step (F43):
  - the buffer passed no undo group to the reflow;
  - update-shapes asked for a reflow without the group of its commit;
  - the reflow's transform opened an undo transaction, so it made an entry
    of its own instead of stacking on the latest one, and the sync that a
    reflow of a main starts took that entry's fresh group.

  So one undo after a sync that reflows (a flex main's margin edit, a nested
  sync with minimal frames, a swap) reverted only the reflow, or only part
  of the step.

  update-shapes now gives a commit that needs a reflow an undo group up
  front and passes it on; the buffer reflows each page in the group an
  update of that buffer names (those that name none come from the same step,
  like update-layout-child's); the reflow stacks on the latest entry with no
  transaction of its own.

  Cases AJ and AN no longer pin F43: one undo after a nested sync reverts
  the remote change and the float noise of the reflow, and one undo after a
  flex main's margin edit reverts the margin, the sync and the reflow. The
  frontend golden master records that.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:22 +02:00
alonso.torres
ed524d8243 🐛 Fix the variant events and the plugin component calls
Fixes in the variant events of the workspace and in the component calls of
the plugin API (Phase 3c).

- Rename a variant in one commit.

  Renaming a variant renamed its board, then each of its components and
  mains, as separate commits inside an undo transaction. The transaction
  kept the undo group of its first commit, the board's, while the sync that
  the renamed mains started took the group of their commits and made an
  entry of its own: one undo reverted only the copies' names (F6, case AV).

  clv/generate-rename-variant builds the board, component and main renames
  in common, and the event commits them once, so the rename and its sync are
  one undo entry, like transform into a variant.

  Case AV no longer marks its round trip as a known failure; the frontend
  golden master records it without failures.

- Keep overrides in a plugin swap only within a variant set.

  The plugin's swapComponent swapped with keep-touched for any component,
  while the UI keeps a copy's overrides only on a variant switch: the same
  swap kept different overrides by route (F33). It now keeps them only when
  the copy's component and the target are variants of one set (decision
  (p)), as swap-keeps-touched? says.

  shape-bugfixes-test: two variants of one set keep the overrides; of
  different sets, or plain components, do not.

- Look up a plugin variant head's component in its own file.

  The plugin's isVariantHead looked the shape's component up in the file of
  the shape, so it was false for copies of a library's variants; and it
  looked it up before checking the shape is a head, so on a shape with no
  component it failed the lookup's assert (F91). It now checks the head
  first and looks in the shape's :component-file.

  shape-bugfixes-test: a copy of a library variant is a variant head, and a
  plain rect is not.

- Add a variant with the shortcut when a variant set is selected.

  The context menu of a variant container offers "Add variant" and shows the
  create-component shortcut for it, but the shortcut's event did not know
  containers: with one selected it fell through to creating a component,
  which the container's own rules refuse (F95). With a single variant
  container selected, the shortcut now adds a variant to it, as the menu
  entry does.

  workspace-variants-test: the shortcut on a variant container gives the set
  a third member.

- Refuse plugin variant edits whose variant is on another page.

  The variant events the plugin API calls (add a variant, add, remove or
  rename a property, set a property value) build their changes on the
  objects of the page the workspace shows. For a variant whose mains sit on
  another page, they updated the components but not their mains, so the
  mains kept their old :variant-name and the file failed validation (F94).
  The plugin now refuses these edits, as it refuses edits of shapes on
  another page, unless the variant is in the current file and on the current
  page.

  library-test: each edit goes through with the variant on the current page
  and is refused, with nothing emitted, with it elsewhere.

- Check every component of an assets group before combining it.

  The assets panel offers to combine a group of components as variants when
  they can be: more than one, none a variant, all with their main on one
  page. It counted every component of the group, subgroups included, but
  checked the other two rules only on the components directly in it, while
  the combine takes them all; with none directly in it, those rules always
  held (F98). can-combine-group? now checks every component of the group.

  assets-components-test: a group whose only components are in a subgroup
  can be combined; a variant or a main on another page in a subgroup blocks
  it; one component cannot.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
36507f666c 🐛 Fix what a variant switch keeps
A fix and a check in the variant switch (Phase 3c).

- Keep a fixed-sizing size on a switch when both variants agree.

  A keep-touched variant switch carries a copy's resized size when the old
  and new variants agree on it (decision (a)). A shape whose
  :layout-item-h-sizing or :layout-item-v-sizing is :fix took the new
  variant's size on that axis even when both variants had the same one, so a
  resize the user made was lost (F61). The fixed axes now follow the same
  rule as the others: the copy's size when the variants agree, the new
  variant's when they differ, which is what the tests of a switch between
  variants of different sizes already expect.

  variants-switch-test: a fixed-width rect keeps its resized size.

- Leave the shape out of child-of-swapped? by id.

  A keep-touched switch skips the touched shapes below a swapped child,
  which move with it. child-of-swapped? looked for a swap among the heads
  between the switched root and the shape, and left the shape out by
  dropping the last head, which is the shape only when it is a head; below a
  swapped child, a shape that is not a head saw that child dropped instead
  and was not skipped (F100). The switch still did the right thing, as such
  a shape finds nothing to pair with in the new variant. The shape is now
  left out by its id. The golden master is unchanged.

  variants-switch-test: an override inside a swapped child survives a
  keep-touched switch.

- Check a switched resized copy text keeps only its geometry.

  A keep-touched variant switch of a copy whose text was resized, between
  variants whose texts are measured differently, carries the size and
  touches only :geometry-group: it adds no :content-group for the position
  data it leaves to be measured again (F101 does not happen).

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  16.7 / 0.47
  edit-through-a-chain-of-dependents   68.9 /  33.1 / 0.48
  library-update-into-copies           83.1 /  65.1 / 0.78
  main-edit-among-many-shapes           4.8 /   3.6 / 0.73
  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 /  86.6 / 0.57
  variant-switch-into-copies           32.1 /  20.1 / 0.63

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
37c6fb0e36 🐛 Fix component sync, reset and update main
Fixes in the sync of copies from their mains, in reset and in update main
(Phase 3c).

- Undo a token sync to the tokens a copy shape had.

  The token sync of a copy shape wrote, as its undo, the shape's tokens read
  with an empty map as default, so undoing the sync of a main's token gave a
  copy shape that had none an empty :applied-tokens map instead of no map
  (F55). The undo now writes the value the shape had.

  Case AY no longer marks its round trip as a known failure; both golden
  masters record the round trip without failures in the two variants that
  hit it.

- Reorder a copy's children with the fewest moves and exact undo.

  When a main reordered its children, sync wrote a move for every copy child
  the walk found out of place, from its position in the copy's original list
  to its main's index:
  - once one child was out of place, every following pair was too, so a main
    with 1,000 children that moved its first one to the end wrote 999 moves
    per copy, quadratic in the children (F85);
  - :mov-objects inserts before the index of the list that still holds the
    shape, so a move to a later place landed one short, and the undo, which
    used the original position, did not put the child back: undoing a
    reorder in a main left its copies reordered (F41).

  The walk now syncs the pairs, removals and additions first, and then moves
  the copy's children, as those changes left them, into their main shapes'
  order: the longest run already in order stays, and each other child moves
  right after the one before it in the main's order. Each move is computed
  on the order the moves before it left, so its index is exact, and its undo
  puts the child back after its old neighbour.

  The update main walk keeps its moves: the copy's order differs from the
  main's only after F41's undo, as the UI never reorders a copy.

  comp-sync-test: a reorder in a six-child main moves each displaced copy
  child once, gives the main's order, and undoes exactly. Cases F, AG and AO
  no longer mark their round trip as a known failure; both golden masters
  record it without failures.

- Remap a main's grid cells after the update main pairs children.

  An update main from a copy of a grid main pushes the copy's cells into the
  main, with each copy child mapped to its main shape. The cells were
  remapped before the children walk, when a swapped copy child still
  referred to the main child it replaces; the walk then removed that child
  and added a new main child for the swapped one. The main's cell kept the
  removed child's id and the new child was in no cell (F49); the next sync
  of the copy took that broken cell back.

  The cells are now remapped after the walk: each copy child goes with the
  main shape the walk added for it, or else with the one it refers to.

  Case AK no longer pins F49: with a swap, the main's third cell holds the
  new main child, the main takes the copy's cells as they are (the swap had
  made the first one manual), and the copy keeps its child in the third
  cell. Both golden masters record that.

- Add update main's new main shapes and their undo in the main.

  When update main pushes a copy child the main lacks (a swapped child), it
  adds a copy of it to the main (F102):
  - the refit of the groups around it was a :reg-objects that named the
    component, which reaches only a deleted component's stored shapes, so a
    group in the main kept its old bounds;
  - the undo deleted the new shapes from the copy's page, so when the main
    sat on another page the undo left them in the main.

  The new shapes, the refit of their parents and the undo of both now go to
  the main's container; undoing deletes the new shapes and refits the
  parents again.

  The case AM setup and the new test fit their groups to their children, as
  the editor does; an unfitted group's size is one the refit cannot give
  back on undo. Both golden masters record case AM with the fitted groups.

  comp-sync-test: an update main from a copy on one page into a main on
  another fits the group around the pushed copy, and undo gives the main
  back.

- Sync flips with the rest of the geometry.

  A flip changes a shape's transform, points and selrect, which sync and
  touch as geometry, and toggles :flip-x or :flip-y, which were not in
  sync-attrs: when a main flipped a child, its copies took the flipped
  transform but kept their old flip flags, which the renderers and the
  placement of swapped shapes read (F38). A flip on a copy touched geometry
  through the transform all the same.

  :flip-x and :flip-y now sync and touch as :geometry-group.

  comp-sync-test: a copy follows its main's flip, flags and transform,
  untouched.

- Reset a nested copy of a flex board keeping its children.

  Sync gives each child of a copy of a flex board the flex child attributes
  (margins, sizing, z-index, ...) of the main child it refers to. It looked
  that child up in the copy head's own component; for a nested copy that is
  not where its children's mains are (they are the children of the nested
  copy in the outer main), so the lookup gave nil, and a reset, which copies
  every attribute, wrote nil into all of them (F103). A child with no main
  was written nil the same way.

  The lookup now uses the container the walk reads the main shapes from,
  where every child's :shape-ref points, and a child with no main shape is
  left as it is.

  comp-reset-test: resetting a nested copy of a flex board keeps its child's
  margin (with a grid board too, whose children F15 leaves).

- Sync layout child attributes of the children of grid mains.

  Sync and update main copy the layout child attributes of a board's
  children (margins, sizing limits, z-index, absolute position, align-self)
  from the shapes they pair with, but only when the main was a flex board:
  the children of a grid main never got them, so a margin set on a grid
  main's child never reached its copies (F15). Grid boards now go through
  the same step, renamed update-layout-children.

  comp-sync-test: a copy follows the margin its grid main gives a child.
  comp-reset-test checks the reset of a nested grid copy keeps it too.

- Clear remote-synced when a nested copy is reset.

  A reset makes a copy equal to its near main, but the reset of a nested
  head marked every shape below the copy root :remote-synced, the mark of a
  shape last synced from a remote main. Update main reads that mark: it
  gives the main the copy's overrides only for shapes without it, and
  otherwise clears the main's touched groups. So after resetting a nested
  copy, an update main from the top copy cleared the overrides of the nested
  copy in the main, which the next sync from its own component then
  overwrote (F13).

  A reset now clears the mark, as the shapes follow their near main.

  comp-reset-test: after a reset of a nested copy, an update main from the
  top copy keeps the fill override of the nested copy in the main. Both
  golden masters record case AB without the marks.

- Check that a nested copy keeps its constraints.

  A copy of one component dropped into another's main, whose constraints the
  outer main then sets, keeps them, in that main and in its copies, when the
  inner component's main is edited: the setting is an override of the nested
  copy. The test guards it; the reset of the constraints a drop writes,
  which looked like a loss (F45), changes nothing a user sees, as nil means
  the same left/top.

- Undo a library typography sync to the text's measured layout.

  The library color and typography syncs rewrite a text's content and clear
  its :position-data, so the text is measured again. Their undo put back the
  old content but cleared the position data too, so an undone sync left the
  text unmeasured (F90). The undo now writes back the position data the text
  had.

  library-asset-sync-test: undoing a typography sync gives back the font
  size and the position data.

- File the copy's relinks of an update main with the copy's file.

  Update main from a copy of a library component writes some changes in the
  library (the main) and some in the copy's file; the frontend commits each
  part to its file, telling them apart by the changes of the copy shapes the
  walk visited (F9). A swapped copy child is not visited: update main adds a
  main shape for it and relinks the copy shapes to it, and those relinks,
  changes of the copy, went with the library's changes. The shapes it
  relinks now count as the copy's.

  comp-sync-test: pushing a swap from a copy of a library component keeps
  every change of the copy's shapes in the local part.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  16.9 / 0.48
  edit-through-a-chain-of-dependents   68.9 /  33.0 / 0.48
  library-update-into-copies           83.1 /  40.9 / 0.49
  main-edit-among-many-shapes           4.8 /   2.3 / 0.48
  main-edit-into-10-copies              1.8 /   0.8 / 0.45
  main-edit-into-100-copies            15.4 /   7.8 / 0.51
  main-edit-into-1000-copies          153.1 /  82.2 / 0.54
  variant-switch-into-copies           32.1 /  18.2 / 0.57

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
57e80061ad 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
b4c2240a60 🐛 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-16 11:05:21 +02:00
alonso.torres
b7638d2fc4 🐛 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-16 11:05:21 +02:00
alonso.torres
6cf5f6088e 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
13f3ec6be5 📚 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
2026-10-16 11:05:21 +02:00
alonso.torres
ab26a5bde3 ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
de12c4ea06 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
127d3ddfac 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
24ec63b659 ✨ 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
2026-10-16 11:05:21 +02:00
alonso.torres
cd6efaf804 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
ae3153fcf8 ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
26217da855 📎 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
2026-10-16 11:05:21 +02:00
alonso.torres
d0ae12719a ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
b278b41ccf 🔥 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
2026-10-16 11:05:21 +02:00
alonso.torres
81abdf13a6 ♻️ 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-16 11:05:21 +02:00
alonso.torres
a0dcb42c4c ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
677a1d5c63 ⚡ 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
2026-10-16 11:05:21 +02:00
alonso.torres
99cb0123a3 🔥 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
2026-10-16 11:05:21 +02:00
alonso.torres
97ad0e065f 🐛 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
2026-10-16 11:05:21 +02:00
alonso.torres
5f06c289ce ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
da79a92c4d ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
c6b6572ae4 ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
d684fd16d1 ♻️ 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
2026-10-16 11:05:21 +02:00
alonso.torres
90a64592c1 ✨ 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
2026-10-16 11:05:21 +02:00
alonso.torres
1dcae42878 ✨ 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
2026-10-16 11:05:21 +02:00
alonso.torres
5586625233 ✨ 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-16 11:05:21 +02:00