24287 Commits

Author SHA1 Message Date
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
alonso.torres
45d6b112e4 ✨ Give each style of a copy text its own override
A copy text had three override subgroups: its letters, any style, and
its structure (the number of paragraphs or runs). One style override
kept every other style of the main out (F72), letters and a style
together kept everything out (F73), a structure change kept the main's
styles out unless both texts had one style (F74), which the editors'
content never had (F79), and alignment, direction and vertical
alignment overrides were lost at the next sync (F76).

Now a copy text has :text-content-text for its letters and line breaks
and one group per style unit: family, weight (with style and variant),
size, line height, spacing, decoration, case, fills, typography,
alignment, direction and vertical alignment (cttx/style-units). An edit
sets the groups of what it changes: a letter both texts share that
changes its style, or a style that was not in the text before, so typed
letters touch no style. A token in the content sets the groups of the
styles it sets, and token sync of a text follows them.

Sync and the keep-touched variant switch share one merge
(cttx/merge-overrides): the letters of one side, and each unit from its
side placed on them. A unit with one value applies everywhere; else
letters both texts share (stretches of three or more) take the source's
style, and the rest follow the letter before them. The switch carries
an override only when the old and new mains agree on it (F77, F81), and
the copy then holds the groups its result differs in. A typography
reference stays only when every unit it sets comes from its side. The
variant id goes with the weight; where the family comes from the other
side, the frontend has to pick the family's closest variant.

app.common.types.text reads a content as letters, each with its run and
paragraph, and builds the content back from them; it gives the style of
one unit for a letter, compares styles by what they show, and finds the
letters two texts share with a letter diff (Myers). The component
contract memory describes the model.

Files older than this keep :text-content-attribute and
:text-content-structure, read as every group they may cover until the
migration recomputes them.

Tests whose expected values the model changes: text-sync-test (the
copy's weight, letters or new run no longer keep the main's size out;
the letters read whole), text-touched-test (its edits went to the
paragraph node, which shows nothing; they now edit the run),
variants-switch-test (the copies with two runs keep their letters
across a switch, and the size override the mains agree on), the
editor output table, case Y and the notation test (new groups). The 1j
pins of F72-F79 and F81 go; F79's direct test of equal-attrs? becomes a
sync of editor content. A new check holds the production override rule
to the 1j reference rule. New tests: the letter view, and a detached,
duplicated or reset copy text keeping or clearing its override groups.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  36.6 / 1.04
  edit-through-a-chain-of-dependents   68.9 /  67.2 / 0.98
  library-update-into-copies           83.1 /  72.5 / 0.87
  main-edit-into-10-copies              1.8 /   1.5 / 0.82
  main-edit-into-100-copies            15.4 /  14.9 / 0.97
  main-edit-into-1000-copies          153.1 / 153.4 / 1.00
  variant-switch-into-copies           32.1 /  27.1 / 0.85
The worlds hold no text. A main text edit synced into 100 copy texts
that changed their letters takes 40 ms, against 29 ms before: the merge
aligns the letters of each copy with the main's.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
7ac68a551b 🐛 Keep library color and typography syncs from touching copies
A library color sync wrote a copy's fills through update-shapes, and a
library typography sync wrote a copy text's content with a plain :set,
so both marked the copy as overridden (:fill-group, or :content-group
with :text-content-attribute). A later edit of that fill or text style
in the main then never reached the copy. Both syncs are derived writes:
they now write with ignore-touched.

The typography sync also merged the whole typography into each text node
that uses it, so its :modified-at (and :path) ended in the content of
the main and of every copy. It now writes only the text styles of the
typography.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  34.7 / 0.98
  edit-through-a-chain-of-dependents   68.9 /  67.6 / 0.98
  library-update-into-copies           83.1 /  76.0 / 0.91
  main-edit-into-10-copies              1.8 /   1.5 / 0.86
  main-edit-into-100-copies            15.4 /  15.2 / 0.99
  main-edit-into-1000-copies          153.1 / 157.1 / 1.03
  variant-switch-into-copies           32.1 /  27.0 / 0.84

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
a1d8b06c09 🐛 Count the shared copy heads in get-nesting-level-delta
get-nesting-level-delta counted the copy heads that a shape and its new
parent share with a two-argument predicate over a seq of pairs, so it
threw an arity error whenever both had copy heads. Its callers,
duplicate and the advance of copied objects, only pass parents outside
every copy today, so nobody reached it. The predicate now takes the
pair and compares the heads by id.

The advance of copied objects passed uuid/zero where the function
expects a shape; it worked only because the id of a uuid is nil. It now
passes the root shape.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
280c34302f 🐛 Sync a reordered copy child into its main on update main
In an inverse sync, when a copy child paired with a main child further
along the main's children, compare-children asked whether the copy
child was the main of the main child, with the arguments of
ctk/is-main-of? reversed. The answer was always no, so the pair took
the swapped callback, which does nothing in an inverse sync, and only
the move ran: the overrides of that copy child never reached the main.
The UI does not let a copy reorder its children, so it only showed
when the order differed for another reason, such as the undo of a
sync reorder.

The inverse branch now asks synced-pair? with the main child first,
as the direct branch does.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  35.5 / 1.01
  edit-through-a-chain-of-dependents   68.9 /  66.1 / 0.96
  library-update-into-copies           83.1 /  77.0 / 0.93
  main-edit-into-10-copies              1.8 /   1.6 / 0.92
  main-edit-into-100-copies            15.4 /  16.2 / 1.05
  main-edit-into-1000-copies          153.1 / 156.4 / 1.02
  variant-switch-into-copies           32.1 /  28.3 / 0.88

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
db480ed1fd ✨ Pin that a deleted component keeps passing changes on
When a component nested in others is deleted (inner in mid in outer,
mid deleted), a change to inner still reaches mid's stored shapes,
mid's copies, outer and outer's copies, and restoring mid brings back
an up-to-date main. Keep this behavior: outer is a live component, and
its users cannot see a deleted mid between it and inner.

Extend the deleted-component propagation test with a copy of the
deleted component and its restore, and reword the contract, which said
copies of a deleted component do not sync.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00
alonso.torres
0c423d7490 🐛 Make a copy head that takes a position refer to its children
When a copy head took the place of a main shape and its component was
the one of that place, it stopped being a swap, but its children kept
referring to the main of its own component instead of to the children
of that place. Validation did not see it, and the next sync deleted
those children and added them again, losing their overrides, or the
head did not follow later changes to that place. It happened in:
- a variant switch that carries a swapped child into a variant whose
  child there has the same component;
- update main of a copy that swapped a nested copy back to the
  component a deeper main puts in that place, after the main in between
  swapped it for another one; the clone in the main also kept
  :swap-group;
- the repair of :ref-not-in-near-main, which also never cleared
  :swap-group.

The swap in a copy, the switch, update main and the repair now share
ctf/take-position: the head refers to the main shape of its position,
is swapped while its component differs from that shape's, and else its
descendants refer to the descendants of that shape.

The switch also read the swapped child from the objects of the swap,
where it was already gone with its old parent. It now reads it once it
is moved into the new copy, so its placement turns its children too,
and the undo takes it back to the root as the move to the root left it.
The swap finds the new shapes in its own objects, not in its changes.
The component memory says how a head takes a position.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  37.1 / 1.05
  edit-through-a-chain-of-dependents   68.9 /  70.5 / 1.02
  library-update-into-copies           83.1 /  77.3 / 0.93
  main-edit-into-10-copies              1.8 /   1.7 / 0.94
  main-edit-into-100-copies            15.4 /  16.0 / 1.04
  main-edit-into-1000-copies          153.1 / 160.6 / 1.05
  variant-switch-into-copies           32.1 /  28.7 / 0.89

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
48e32f65bb ✨ Build library files with current features and migrations
The file builder behind @penpot/library pinned the features and
migrations it writes to the ones Penpot 2.7 accepted, so its files
could open there. It now writes the current defaults: the features a
new file gets and every available migration. Generated files were
already in the current format (paths, for one, are stored as path
data), so they now declare fdata/path-data and components/swap-group
as they should, and the import runs no migration on them.

Files built this way need the Penpot version the library is built with,
or a later one.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
a7c5cd304a ✨ Turn a swapped copy head with the child it replaces
A swapped copy head followed the position, constraints and layout item
attributes of the main child it replaces, but not its rotation or
flips. When the main rotated or flipped that child, the copies that
kept it turned and the swapped ones did not. Sync, and a variant switch
that carries a swap, now give the head and its subtree the flips and
then the rotation of that child, around the head's own center, unless
the user changed the head's geometry. The swap itself uses the same
step.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  34.1 / 0.97
  edit-through-a-chain-of-dependents   68.9 /  75.5 / 1.10
  library-update-into-copies           83.1 /  81.3 / 0.98
  main-edit-into-10-copies              1.8 /   1.6 / 0.88
  main-edit-into-100-copies            15.4 /  15.6 / 1.01
  main-edit-into-1000-copies          153.1 / 153.2 / 1.00
  variant-switch-into-copies           32.1 /  31.4 / 0.98

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
2793c0a6d4 🐛 Keep the rotation and flips of a swapped shape
A swap placed the new copy at the position of the shape it replaces,
but not with its rotation or flips. When that shape was rotated or
flipped with its parent, the new copy came out straight. The new copy
and its subtree now take the flips and then the rotation of the shape
it replaces, around their own center, as geometry that comes from the
parent, not as an override. A variant switch goes through the same
swap.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  38.3 / 1.09
  edit-through-a-chain-of-dependents   68.9 /  70.7 / 1.03
  library-update-into-copies           83.1 /  82.2 / 0.99
  main-edit-into-10-copies              1.8 /   1.7 / 0.95
  main-edit-into-100-copies            15.4 /  16.7 / 1.08
  main-edit-into-1000-copies          153.1 / 167.4 / 1.09
  variant-switch-into-copies           32.1 /  29.6 / 0.92

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
1ed5700cf5 🐛 Keep a swapped child when a variant switch is undone
A variant switch with keep-touched moves a swapped child out of the old
copy to the page root, then into the new copy, which takes the same id
as the old one. The undo of the second move took the child back to
that id, so it landed in the new copy and went away with it when the
undo deleted the new copy. The undo now takes it back to the root,
and from there into the old copy.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
dd4973851f ✨ Place a swapped child where the new variant has it
A variant switch with keep-touched carries a swapped child into the
new variant, but left it where it was in the old one. The next sync
then moved it to the place of the child it replaces, at a moment
unrelated to the switch. The switch now gives it that placement at
once, through the same step sync uses: position, constraints and
layout item attributes the user did not change.

Benchmark, against the same code without this change under the same
load (lower quantile, ms; without / with):
  deepest-edit-through-three-levels    36.0 /  36.0
  edit-through-a-chain-of-dependents   65.2 /  70.2
  library-update-into-copies           74.3 /  75.9
  main-edit-into-10-copies              1.6 /   1.5
  main-edit-into-100-copies            16.3 /  15.7
  main-edit-into-1000-copies          162.6 / 165.8
  variant-switch-into-copies           27.2 /  27.8

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
217d87a438 ✨ Migrate swap slots to override groups and remove them
The migration 0033 stores every swap of a file as an override group.
A swap made in a copy is paired with the child of the near main that
holds the same position, as sync paired it; any other slot is dropped.
All pairings are found on the data as it was, so swaps nested in swaps
pair against the content of the outer one, and libraries pair the same
whether they are migrated or not. A head that refers to its own slot
only inherited it, and drops it even when its near main cannot be
reached. Any other slot that matches no child, or whose near main is in
a library that is not loaded, keeps the slot as its ref, so validation
reports it. An import, which migrates its files before it links their
libraries, leaves those slots and converts them once the libraries are
linked. A migrated head that sits elsewhere than the child it replaces
would place it gets its geometry marked as touched, so the first sync
does not move it. Migrated and new files carry the feature
components/swap-group, so a client that still writes slots cannot open
them.

Validation checks the new form: :misplaced-slot reports any slot,
:misplaced-swap-group the group anywhere but on a nested copy head in a
copy, :ref-not-in-near-main a nested copy head whose ref is not a child
of the near main of its parent, and :duplicate-ref two children of a
copy that refer to the same main shape. Each has a repair.

Nothing but the migrations reads slots any more: the slot branches go
from sync, swap, switch, reset, detach, paste, duplicate and move. The
helpers that read and write a slot on a shape move to files/swap-slots,
and the ones that follow refs to files/comp-processors. The graph
projection drops the FillsSwapSlot edge, the components debugger shows
the component a swapped head takes its content from, and the plugin API
documents componentRefShape for a swapped copy. The swap tests are
named after what they check.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  34.6 / 0.98
  edit-through-a-chain-of-dependents   68.9 /  63.5 / 0.92
  library-update-into-copies           83.1 /  76.4 / 0.92
  main-edit-into-10-copies              1.8 /   1.6 / 0.87
  main-edit-into-100-copies            15.4 /  15.1 / 0.98
  main-edit-into-1000-copies          153.1 / 148.8 / 0.97
  variant-switch-into-copies           32.1 /  26.4 / 0.82

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
7d5b37dbdd ✨ Store a swap inside a copy as an override group
A swapped copy sub-head now keeps the shape-ref of the main child it
replaces (its position), takes its content from its own component, and
carries the touched group :swap-group. It used to point at the root of
the new component and store a swap slot naming the replaced child.

Every path reads and writes that form:
- sync pairs a swapped head with the child it replaces and syncs it
  from its own component; it takes from the replaced child only its
  placement: position, constraints and layout item attributes;
- reset undoes a swap in place, keeping the head's id and the other
  overrides of the copy;
- detach, paste and duplicate make a head that leaves every copy level
  a plain copy of its own component, and advance a deeper one by
  position; the children of a swapped head never advance;
- a variant switch keeps a swap stored this way, and counts only the
  copy's own swap, not one made in a main of the chain;
- update main replaces the main child with a clone of the swapped one,
  and from a swapped head updates its own component;
- a swap inside a copy keeps the old shape-ref and adds :swap-group;
  swapping back to the component of that position gives a plain nested
  copy whose children refer to the position's children; a swap inside
  a main is a plain nested copy.
Files that hold swap slots keep working until a migration converts
them. The component memories describe the model.

This fixes what came from the slot: reset of a swapped child cleared
the other overrides of the copy (F12) and gave copies of a main new
ids (F66); update main left a swap mark (F16); a second variant switch
lost the overrides (F29); grid cells did not pair a swapped child
(F44); a swapped child did not follow its position (F65). F49 keeps
failing, in another form.

The tests that asserted slots now assert the override group; the tests
of the slot code build the old form with :as-slot?.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  35.4 / 1.01
  edit-through-a-chain-of-dependents   68.9 /  68.7 / 1.00
  library-update-into-copies           83.1 /  77.5 / 0.93
  main-edit-into-10-copies              1.8 /   1.6 / 0.92
  main-edit-into-100-copies            15.4 /  15.4 / 1.00
  main-edit-into-1000-copies          153.1 / 151.1 / 0.99
  variant-switch-into-copies           32.1 /  32.4 / 1.01

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
0959e5b44b 🔥 Remove the components v1 leftovers
No components v1 file reaches the code any more: opening one fails the
feature check, and imports reject it or give it the feature. What only
served v1 goes:

- validate-file and validate-file-affected no longer skip a file
  without components/v2;
- the root of a deleted component is found by its main instance id
  only, in the type helper and the component sprite;
- generate-sync-library, which synced shapes inside live components,
  and the live components in containers-seq, so asset usages walk the
  pages only (deleted components keep their objects and still sync);
- the unused :components-v2 file option, from the schema, the file
  data and the test fixtures; stored files that carry it still
  validate;
- the team migration-in-progress check and its frontend alert, which
  leaves ephimeral features out of the team comparison;
- the components v2 introduction dialog, its styles and icons, and the
  :v2-info-shown profile prop;
- the graphic asset events and builders and the reads of graphics in
  the selection (the :add-media, :mod-media and :del-media changes stay,
  so stored changes still apply), and the uncalled render-page and
  render-components helpers.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
a41a846195 ✨ Reject imported components v1 files that have components
Both binfile importers stored a file without the components/v2 feature
as a v2 file, though its component data was never migrated. Such a file
is now rejected with :file-in-components-v1, the code that opening a v1
file already raises, when it has components or shapes linked to them
(:component-id, :shape-ref or :main-instance). A file without component
data takes the feature and imports as before.

Since every imported file now has the feature before the pending
migrations are registered, the no-op components/v2 migration goes.

The import dialog explains the rejection: the entry says that the file
uses an old type of components that Penpot no longer supports, with its
own text, as errors.deprecated talks about opening a file.

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:20 +02:00
alonso.torres
6c1afe825f ✨ Test what copy texts keep through sync and switches
Add a battery of tests for text overrides in copies.
- A short notation for text content in tests (thtx): paragraphs of
  runs with short style names, read back with equal runs joined,
  edits in the notation, set-text and set-content through the change
  path, and the override rule for texts (override-units), which says
  which overrides a copy must hold against its main.
- Sync matrix: every whole-text copy edit against every main edit,
  plus the cards of the worked examples, the "Hello world" table, a
  token on a copy, typographies and a library color sync. Where the
  code differs from decisions (k) to (o), the result is pinned
  against F72 to F80.
- Switch matrix: copy edits against variants that differ in letters,
  size, color, a new line or a bolded word; F76, F77, F79 and F81
  pinned.
- Composable case BA: EditText also edits fills, font family and a
  whole text; the copy and the main change different styles, then
  reset or update main, in both runners; F72 pinned.
- Editor output: the content the WASM and v1 editors commit when a
  copy text is typed into, retyped, split with Enter and has a word
  bolded, recorded in the workspace and replayed on a copy. Typing
  sets only the text groups and a style change no text override;
  F79 pinned on both editors' content.
2026-10-16 11:05:20 +02:00
alonso.torres
d4ac987705 ✨ Make the component test suites fail when their claims break
A review found tests that would pass with what they test broken.
- Runners: both judge one variant at a time, fail a variant with no
  assertion and a known-failure mark that applies nowhere, and give
  the asserter the state the operations produced. The pure runner
  validates the final file and rejects operations of unknown kind.
  The frontend interpreter fails a step when the store reports an
  event error (Potok only logs it), reports asserter errors against
  their case, and stops the watchers that install! starts.
- Nodes drive the real code: a main root rename goes through
  end-rename-shape, and the clipboard's advance of nested copies is
  one common function, cll/advance-copied-objects, that copy-selected
  and both test paths call.
- Cases N, AH, AI, AK, AD, AE, K, C, H, S, AY, I, Y, AX and F check
  what they claim; O, R and T get real sizes.
- Stricter common unit tests found F60 (a repair detaches healthy
  copies), F61 (a switch drops a fixed-sizing size), F62 (the ref
  chain never stops at its target) and F63 (latent); all pinned.
- Frontend library tests: the RPC double records on subscribe, the
  dropped commits check that nothing syncs, and the link and share
  tests check the events sent.
- Plugin suite: OpSwap and OpDetach throw when the API call did
  nothing, and case AB checks that the reset head is an instance of
  the original component.
- The composable suite memory records the new rules.
2026-10-16 11:05:20 +02:00
alonso.torres
2b16c03b98 ✨ Test the file and container helpers and the library events
The component helpers of types/file and types/container and the
library events of the workspace had the lowest coverage left.
- common-tests.types.file-test: the export detach, the reuse of the
  library page, the original ref of a swap, deleted components and
  used-in?. Pinned: F52 (a library copy nested in a local component
  loses its ref in every copy), F56 (the original ref of a swap is nil
  when the mains are on different pages) and F57 (library color refs
  inside :fills survive the export detach).
- common-tests.types.container-test: the placement rules for moves
  and pastes (copies, mains, variant containers, deleted components,
  component loops) and small helper branches. A plain shape moved
  into its own child passes the rules (F58); relocate-shapes drops it
  first.
- frontend-tests.data.workspace-libraries-test: the events on several
  shapes at once, each one undo entry; update of a library main; a
  blank rename; placing with an id ref.
- workspace-library-updates-test: how a file learns that a library
  changed, and the sync dialog with its update and dismiss, with the
  RPC mocked.
- workspace-library-links-test: linking, unlinking and sharing.
2026-10-16 11:05:20 +02:00
alonso.torres
9c8a93e660 ✨ Test variant events, token sync and switch overrides
The variant events had almost no frontend tests, and several switch,
paste, token and restore paths never ran.
- Variant property edits (AR) and adding a variant (AS), in both
  runners, through the common generators and the dwv events.
- A frontend-only node for the variant management events: transform
  (AT), combine (AU), rename (AV), rename in layers, removal of empty
  properties and member errors (AW), and the shortcuts (AX). Each
  case has copies of the members; direct tests cover the remaining
  branches. AV and AT confirm F6: the copies' name sync lands in an
  undo entry of its own (pinned).
- Tests for valid-components-for-variants? and valid-variant-switch?.
- The WASM variant switch test is registered in the runner; it never
  ran.
- Token apply and remove nodes and case AY. Its undo confirms F55, an
  empty :applied-tokens map after undo (pinned). The interpreter waits
  for the token, since apply-token opens no pending work (F54).
- A paste-to-root node and case AZ: F17 does not show through paste.
  The frontend copy helper merges touched groups as the clipboard
  does.
- Switches that carry real geometry, path and text-structure
  overrides, resized through a transform.
- Restoring a component whose page was deleted.
2026-10-16 11:05:20 +02:00
alonso.torres
694ef1e3e1 ✨ Test the component sync paths no test reached
A coverage run found sync paths that no test ran. New cases, in both
runners:
- AK: update main into a grid main. With a swapped child the main's
  cells keep the copy child's id (F49, pinned).
- AL: update main after renaming the copy root and its child.
- AM: update main of a swap inside a group. The copy child keeps a
  swap slot that names a removed main child (F16, pinned).
- AN: a copy flex child's margin override through a main edit, a
  reset and update main. The frontend round trip of the main edit
  shows F43 with real sizes (pinned).
- AO: a copy dropped into a grid cell of a main. Its undo shows F41
  (pinned).
- AP: a library main deletes a middle child.
- AQ: a main change reaches a copy nested in a sub-board.

Also: the frontend interpreter sends the real reset event instead of
installing a file; a test undoes and redoes an undo entry without a
group, which only release builds store; a test confirms F50, a copy
text touched only in :content-group losing its override (pinned).
2026-10-16 11:05:20 +02:00
alonso.torres
158a8e1dba ♻️ Tidy the composable suite and make its cases stricter
A review of the suite after the new operations:
- Dead helpers and duplicate nodes go; the lineage reset and swap
  nodes are constructors of reset and swap-shape.
- Repeated helpers are shared: grid boards (tho/add-grid-frame,
  tho/place-in-grid-columns), n/nested-head-under, n/child-shape,
  r/sync-cascade (the benchmark uses it too), the frontend fixture and
  fill helpers, and the plugin getMainComponent.
- Runner tests that repeated cases B, C and D go.
- Comments say what the code does, and the wrong ones are fixed.
- Case K's remote edit painted red on red and its unedited variants
  asserted nothing; it starts grey and every variant asserts.
- F43 is pinned in one case, AJ, by the value one undo leaves. L, M, Q
  and K get real frame sizes, since their pins depended on when the
  100 ms layout buffer flushed.
- make-nested-component takes a level count and depth-sweep gives each
  depth once, so K, Q, R, T and W stop running depth 1 twice.
- The completion-contract tests no longer depend on fixed pauses or
  on how the watcher batches its events.

Benchmark against the baseline (lower quantile): ratios 0.92 to 1.03,
no world flagged.
2026-10-16 11:05:20 +02:00
alonso.torres
124b799a63 ✨ Add a component sync benchmark with a recorded baseline
The coming refactors and fixes change sync code, and a slowdown must be
seen and explained in the commit that causes it. A JVM benchmark now
times component sync on fixed-size worlds:
- one main into 10, 100 and 1000 copies;
- a change in the deepest of three nesting levels;
- a chain of dependents (a in b in c, with copies of each);
- a variant switch in a main with 100 copies;
- a library update into a file with 500 copies.

Each world is built once; only the sync an edit causes is timed, run
to a fixpoint as the frontend watcher does. Each checks its result, so
a world that synced nothing fails instead of timing nothing.

`scripts/bench-components record` writes the baseline, recorded here on
the code before this commit; `scripts/bench-components` compares against
it and exits 1 when a world is more than 10% slower. It compares the lower
quantile: with the devenv running, the mean of the same code moved
from 0.41 to 1.13 of the baseline, the lower quantile by under 12%.
2026-10-16 11:05:19 +02:00
alonso.torres
b0b2044b57 ✨ Test component validation, repair and library asset sync
Fill the unit test gaps in common and frontend component code:
- One test per component error code: break a file in one way, check
  the codes validation reports, repair it as the backend does (until
  valid, at most ten passes) and check what the repair changed.
  Pinned: F46 (a dangling ref in a copy child is never reported), F47
  and F48 (two repairs that never converge). :component-not-main,
  :should-be-component-root and :not-component-not-allowed have no
  test, as no file can produce them.
- Library color and typography sync must not touch copies. Both do
  (F3): the color sync touches :fill-group, the typography sync
  :content-group and :text-content-attribute, and a later main edit of
  that attribute then no longer reaches the copy. Pinned.
- Twenty live thf/dump-file calls in common tests go; they printed
  whole file trees on every run and asserted nothing.
- The disabled copy-nested-3 frontend test runs again: a stray
  backquote quoted its run-store call, so it never called done.
2026-10-16 11:05:19 +02:00
alonso.torres
a9f27816e5 ✨ Add operations and cases to the composable component suites
The composable runners could edit, add, remove and move children, swap
and switch, but not run the other component operations. New nodes run
each one through the production generator in the pure runner and the
real event in the frontend interpreter:
- Detach, UpdateMain, DeleteComponent and RestoreComponent;
- Duplicate, and NestCopy (a copy moved into a main);
- EditText, with the automatic rename the text editor applies;
- MoveShape (frontend only, through the sidebar) and MoveToCell;
- swap-shape and reset on any copy head;
- Undo with a step count, and an edit undone before its sync (frontend
  only).

New cases, all with the undo/redo round trip:
- Q, R, S, T, U: detach, update main, delete and restore, duplicate
  and nest, at nesting depths 0 to 2;
- V, W, X: a new edit after undo, three undos over nested cascades, an
  undo before the edit's sync;
- Y: text overrides against main text edits;
- Z: a main child move;
- AA to AF: swap outcomes on values and structure, and the grid cell
  of a swapped child;
- AG, AH, AI: the plugin suite's slot alignment cases.

The plugin suite gets OpDetach, OpSwap and OpReset, names that keep
the roles of two nestable components apart, Assert.knownFailure, and
ports of cases Q, AA, AB and K's reset.

Supporting changes: nodes can record what they created
(IAppliedResult); create-component takes a size, since with 0.01
frames the frontend reflows split undo entries at points that depend
on timing (F43); the interpreter ends the text measurement headless,
so text steps settle; tm/known-failure pins the wrong value a known
bug gives in a case assertion; the interpreter no longer moves its
undo baseline after a skipped optional, which cut the end-of-variant
round trip short.

Confirmed and pinned: F12 (resetting a nested swap clears the top
copy's overrides), F27 (the automatic text rename touches
:name-group), F29 (a second switch loses an override) and F44 (a
swapped grid child misses the main's cell changes). F43 also shows
through a plain sync at depth 2 (case Q).
2026-10-16 11:05:19 +02:00
alonso.torres
49aaa697fa ✨ Check undo and redo of every step in the composable cases
The composable cases tested undo only where a case asked for it, so a
change generator with a wrong inverse, or a step that splits into
several undo entries, went unnoticed.

Both runners now run an undo/redo round trip on every variant, unless
the case turns it off with a reason. After each user step that adds an
undo group, undo must give back the file before the step and redo the
file after it; a user step that adds no group must change nothing. At
the end of the variant, undoing back to the last assembly step and
redoing everything must give back both states. Files compare through
a shared normalizer that drops :modified-at and, on request,
:position-data, and keeps :touched and :remote-synced.

- The pure runner gets a redo stack and a Redo node; a user operation
  that pushes a group empties it. Case P undoes and redoes a main edit
  in both suites.
- The frontend interpreter runs the round trip through the real undo
  and redo events, and checks that each step moves the workspace undo
  index by one entry. Each variant starts with an empty undo stack.

A case can mark a failure that a known bug causes. The mark pins, per
phase, the signature of the failure (the shape, page and component
attributes or the undo index that differ). It covers no other failure,
and it fails the case once the bug stops happening.

The check found two bugs, both pinned:
- F41: when sync moves a copy child to an earlier position, its undo
  change puts the child back at the same index, so undoing a reorder
  in a main leaves the copies reordered (case F).
- F43: the reflow after a swap or variant switch loses the step's undo
  group, so its commits land in undo entries of their own and one undo
  leaves the step half reverted (cases L and M).
2026-10-16 11:05:19 +02:00
alonso.torres
ded39ee2df 🐛 Keep page-less changes from adding a nil page
Applying a commit locally rebuilds the object indices of every page its
changes name. Changes that touch no page, such as :mod-component, name
none, and their nil page id was rebuilt too: the file data in the store
gained a page under a nil key holding {:objects nil}. Every component
touch after an edit to a main added it, and undo never removed it.

Rebuild the indices only for the page ids the changes carry.
2026-10-16 11:05:19 +02:00
alonso.torres
a4d42c72db ✨ Make composable interpreter steps wait for pending work
The composable interpreter treated a step as done after a 60 ms gap
with no commits, and treated its 2 s timeout as done too. Slow sync or
reflow work could finish after the step, so a case could assert on a
half-synced file.

Each event step now waits, as the plugin waitForLayoutUpdate does,
until every pending work tracked by the reflow registry has drained:
the watcher's sync wait, the sync it starts, and layout and text
reflow. A step that does not drain within 2 s fails the test and names
the work still pending, and the next variant starts with nothing
pending.

The tests cover an edit with a deliberately slow sync, undo and redo
steps that must wait for the watcher's touch-component, translation
and derived commits, a sync that never finishes, and a restart of the
edit watchers, which must keep the undo entries of an edit the same.
2026-10-16 11:05:19 +02:00
alonso.torres
54339d0940 🐛 Hold the sync wait for every local commit
The component watcher opened its :sync-file wait only for commits saved
to the undo stack. Undo and redo commit without it, so a wait such as
the plugin waitForLayoutUpdate returned before the watcher had handled
them. Translation and derived commits were filtered out before the wait
opened.

Open the wait for every local commit of a file and close it once the
watcher has decided what to do: sync, only touch the component, or
drop the commit. Dropped commits close it right away.

Add wrf/pending, a snapshot of the pending work, for tests and
diagnostics.
2026-10-16 11:05:19 +02:00
alonso.torres
03b3203071 ♻️ Share the workspace edit watchers with the test harness
The composable test interpreter wired its own copy of the undo stack
subscription and started only the component watcher, so it reacted to
edits differently from the app, and a missing subscription failed
silently.

Gather the subscriptions that react to edits while a file is open
(undo stack, layout updates, text reflow, WASM text measurement) in
initialize-edit-watchers, stopped by finalize-edit-watchers, and the
per-page component and token watchers in watch-library-changes. The
workspace and the interpreter both start them.

Starting the edit watchers again replaces the running ones. A file
reload starts the workspace again without finalizing it, which left two
undo stack watchers running, so an edit added an extra undo entry.
2026-10-16 11:05:19 +02:00
alonso.torres
439f44131b ✨ Run the composable component cases on the JVM
The composable test engine, nodes, setups and cases lived in the
frontend test tree, so only the frontend interpreter could run them.

Move them to CLJC test helpers under app.common.test-helpers.composable
and add a pure runner (comp.runner) that does what the workspace does
around each operation, without the frontend store:
- after a user operation it syncs the changed components until none is
  left, as the frontend watcher does with one pass per sync commit;
- the operation and its syncs form one undo group, and the undo node
  reverts the latest one;
- it validates the file once the sync or the undo has settled.

Each node declares its kind: assembly, user, undo or frontend-only.
User nodes record the production changes they apply, and the runner
throws when a node breaks that. The swap and switch nodes build their
changes with the production clv/generate-component-swap-in-place.
Rotate and ChangeHeight need the frontend placement step, so the pure
runner rejects them.

The cases are zero-arg functions in comp.cases, shared by the frontend
interpreter and common-tests.logic.composable-sync-test. The latter
runs cases B to M and the new case O, a switch of an overridden copy,
on the JVM and in JS. Case N and the rotation case stay frontend-only.
2026-10-16 11:05:19 +02:00
alonso.torres
bb5aeaa7a0 ♻️ Move variant switch and swap setup to common
The variant switch event picked its target variant, and the component
swap event worked out where the new head goes, inside the frontend
events. Code that runs without the frontend store, such as the pure
composable runner, could not do the same switch or swap. The swap and
the switch also checked for nesting loops with two different
functions.

Move them to app.common.logic.variants:
- find-switch-target picks the nearest variant with the requested
  value.
- generate-component-swap-in-place builds the swap changes keeping the
  old head's index, grid cell and swap-keep-attrs, plus the
  keep-touched step when asked.
- swap-nesting-loop? checks that a swap or switch does not nest a
  component inside itself. The swap menu, the plugin swapComponent and
  the variants-switch event all use it.

The frontend events behave as before, with two exceptions. Switching
to a value no other variant has does nothing, where it threw in
min-key. A swap also updates the layout of the shapes the keep-touched
step adds.
2026-10-16 11:05:19 +02:00
alonso.torres
6f8e18d66d 📚 Add the component touched/sync contract memory
Write down the intended semantics of copies, :touched and component
sync: what counts as an authored or a derived write, the override
rule, and what each operation (edit, rename, sync, reset, update
main, detach, swap, switch, undo) does to touched groups. It also
records the settled decisions on keep-touched switches, deleted main
children, the eager sync cascade, automatic renames, derived changes
on mains, resized copy roots and layout-owned geometry.

Link it from common/core and common/component-data-model.
2026-10-16 11:05:19 +02:00
Andrey Antukh
122d4ebcd3 🔥 Remove unused requires flagged by the Clojure linter
The dashboard required app.common.exceptions without ever using it,
and the plugin shape namespace still referred to
token-attr-plugin->token-attr after the per-side stroke width work
replaced its only use with expand-token-attrs. Both are leftovers of
earlier refactors; dropping them leaves clj-kondo with no warnings.

AI-assisted-by: deepseek-v4.1-flash
2026-10-15 20:23:25 +02:00
Eva Marco
ab7a994d6c
🐛 Fix libraries modal visual test (#12367)
The libraries modal now opens on the "This file" tab, so the visual test
timed out looking for the shared library. Open the "Libraries" tab first,
as the functional test does, mock the file with the library linked, and
check the "Added" label instead of the old "File library" text.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:08:48 +02:00
Eva Marco
539983db2f
♻️ Use a DS icon button for flatten in the boolean options (#12368)
* ♻️ Use a DS icon button for flatten in the boolean options

Replace the raw flatten button and its deprecated icon with icon-button*
and the DS boolean-flatten icon. The title text moves to aria-label, so
the DS tooltip shows it. The grid column goes on the tooltip wrapper
through tooltip-class.

Add a visual test that selects two shapes so the boolean options show.

AI-assisted-by: claude-opus-5-5

* ♻️ Remove deprecated button styles from the boolean options

The flatten button is a DS icon button now, so its placeholders, sizes
and icon colors go away; only the grid column stays, on the tooltip
wrapper. Use block-size and drop the refactor/ import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:07:04 +02:00
Eva Marco
d26a401637
♻️ Use DS buttons for more colors in the selected colors (#12369)
* ♻️ Use DS buttons for more colors in the selected colors

Replace the three raw "more colors" buttons (library colors, colors and
color tokens) with the DS secondary button.

Add a visual test that selects a board with more than three colors so
the button shows.

AI-assisted-by: claude-opus-5-5

* ♻️ Move selected colors styles to DS tokens

The "more colors" buttons are DS buttons now, so their placeholder and
typography mixin go away; the class only centers the label, since the
column stretches the button. Delete the unused .add-fill rule, move
spacing to DS tokens and logical properties, and drop the refactor/
import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:06:29 +02:00
Eva Marco
47f0e15744
♻️ Use DS icon buttons in the align options (#12366)
* ♻️ Use DS icon buttons in the align options

Replace the eight raw align and distribute buttons with icon-button*
and DS icons. The title text moves to aria-label, so the DS tooltip
shows the same text and shortcut. Disabled distribute buttons now use
the DS disabled color.

Add a visual test that selects two shapes so the align options show.

AI-assisted-by: claude-opus-5-5

* ♻️ Remove deprecated button styles from the align options

The align buttons are DS icon buttons now, so the .align-button rules
and their placeholders go away. Move the row height to a DS size token
and drop the refactor/ import.

AI-assisted-by: claude-opus-5-5
2026-10-15 20:05:58 +02:00
Andrey Antukh
57fa280209
🐛 Validate and decode a job result with its declared schema (#12350)
* 🐛 Decode job results with the schema of the job-def

A job result is stored as plain JSON, so a uuid comes back as the text
it is on disk and a uuid used as a map key comes back as a keyword. The
import resolution is a map keyed by file id, and the wizard lost that
identity: choosing a library in the link step created no link at all,
with no error and with the modal closing as if it had worked.

A job-def can now declare a result schema and its precompiled decoder,
the pair the params already have, and the substrate applies them when the
row is read. A def that declares neither answers with the JSON the row
holds, as before; a row whose job-def is gone is read untouched instead
of raising.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Type the import result so its resolution keeps uuid keys

The resolution of a version 3 import is a map keyed by the new file id,
and the result column is plain JSON: without a schema of its own every
key came back as a keyword while the :id of the entry came back as text,
so the dashboard wizard could not match the file it was linking and
created no library link at all.

Declare the shape of the resolution (files, done and pending entries and
their candidates) and give the job-def its result schema and decoder.
The check before the result is stored keeps validating the same schema.

Closes #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Take the imported file id from the entry, not the map key

The wizard looked a file up in the selection with the key of the
resolution map while it had indexed the selection by the :id of the
entry. The key is keywordized on its way through the job result, so the
lookup missed and the confirm button created no library link at all,
with no error and with the modal closing as if it had worked.

Take the id from the entry, the source the rest of the wizard already
uses, and move the decision to a pure function covered by tests of both
shapes of the resolution.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Test that an import result keeps its ids as uuids

The regression this branch fixes is only visible through the read path
of the substrate: the resolution of an import is a map keyed by the new
file id, and a test that works with the value in memory never sees the
keys coming back as keywords from the plain JSON column.

Import a package whose library needs a replacement chosen by the user,
read the result with `jobs/get-job` as the client does, and check the
keys, the file ids and the ids of the candidates. Without the result
decoder of the import job-def the test answers with text ids.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Document the result schema of a job-def

The memories and the docstring of the substrate said a job result has no
schema of its own and comes back as the JSON the row holds. A job-def can
now declare its result schema and decoder, and the substrate applies them
when the row is read.

Keep the rule next to it: a result map keyed by uuid needs its schema
declared, or every key comes back as a keyword, and the link-later step
of the import wizard takes the file id from the entry and never from the
key of the resolution map.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Fix the documented contract of a job result

The docstring of get-job said a result has no schema of its own and comes
back as the JSON the row holds, which is what this branch changes: the
result is decoded with the schema its job-def declares, and a result that
does not conform never reaches the column because the write validates it.

Say the same in the management command that stores the result of an
external worker, the other writer of the column.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Check the result of a job against its schema before storing it

The result of a job crosses a plain JSON column, so a uuid comes back as
text and a uuid used as a map key as a keyword. The job-def now declares
the schema of its result and the substrate compiles the decoder from it,
which is what the read path uses to get the types back.

The same schema is the contract of the write path: complete refuses a
result that does not conform, with the malli explanation, so nothing
invalid reaches the column, and the answer is the same for the runner and
for the external worker that completes a job through the management RPC.
A job that is no longer completable is skipped without checking, and the
read path decodes only: a row written by another version stays readable.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Check the result of an import inside its transaction

The check ran after the transaction of the import had committed, so a
result the schema does not accept failed the job with the files already
created in the destination project: a half imported project and an error.
The core decides whether the whole import is kept or rolled back, so the
check belongs inside its transaction, which is what turns a contract
violation into a rollback.

The substrate checks the result again when the job is completed, which is
the only net for a job that does not validate its own result.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 🐛 Cover the edges of an import result read by the client

The shape of a result with no libraries to resolve, and the one of a
version 1 import, is the most common in production and neither was covered
by the tests that read a result the way the client does.

Cover both through jobs/get-job, and the case of a registry that no longer
knows the kind of the row: it answers with the JSON of the column,
keywordized keys and uuid as text, instead of raising. Say in the two tests
that depend on the decoder what implementation without it makes them fail.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📚 Fix the prose and the fixtures of the result contract

The result schema of a job-def is what the read decodes with and what the
write checks: say that in the memories, with the rule that any uuid of a
result needs it, whether a value or a map key.

The motive of the wizard helper was a fact this branch makes false: what a
key of the resolution map holds depends on the result schema of the job-def
that wrote it, so the rule is to read the id from the entry. Same
correction in its memory and in the test that pins the shape.

The candidates of a pending entry match by slugified name, not by name, and
every test row now states the kind of its job, because the name is what
resolves its job-def when the result is read or checked.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Keep the wizard link helper private and cover its shape

The helper that decides which links the wizard creates belongs to its
namespace, as its neighbours do: reach it from the test with @#', the
pattern the frontend tests already use for a private helper.

Cover the shapes it had no test for: a file with two libraries pending
resolution, a file whose libraries were auto-linked, and a selection where
only one of two entries was resolved.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Share one job-row helper across the job tests

Three test namespaces had their own copy of the same insert, differing only
in the kind of the job and in whether the row holds the resource it
consumes. Move it to the test helpers as create-job*, with the kind of the
job as its first argument: it is what resolves the job-def of the row when
its result is read or checked, so every test states it.

The bang of the name goes away with it: the design rules keep it for the
mutating API of app.db, and this is a fixture of the tests. The three
namespaces lose the requires that only their removed copy used.

Relates to #12339

AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-10-15 19:18:04 +02:00
andrés gonzález
7383e08683
✨ Focus the search field when opening the color tokens tab (#12345)
The color tokens tab of the color picker did not focus its search
field, so users had to click it before typing a token name. The token
dropdown of numeric inputs already focuses its search on open.

Closes #12344

AI-assisted-by: claude-opus-5-5
2026-10-15 10:10:11 +02:00
Alonso Torres
e3b8b006db
🐛 Fix font family space in tokens (#11750)
* 🐛 Fix font family space in tokens

* 🐛 Resolve token merge conflicts

Keep the font-family token fix and the newer
background-plugin change together in the
unreleased changelog and plugin API test suite.

AI-assisted-by: gpt-6-luna
---------

Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com>
2026-10-15 10:05:44 +02:00
Andrey Antukh
9dc61b1c18 Merge remote-tracking branch 'origin/staging' into develop 2026-10-14 18:36:25 +02:00