28 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
Andrey Antukh
81c3b3cd56
📎 Update copyright name on file header (#11346) 2026-08-25 11:55:10 +02:00
Andrey Antukh
83a3d099f6 🐛 Mock RPC and timer in composable test interpreter to fix network errors
The `SyncFromLibrary` op dispatches a real `sync-file` event that
schedules `rx/timer 3000` + an RPC call to
`update-file-library-sync-status`. In the headless test runner
(no backend), this produces a network error that leaks into test
output.

Wrap the `check` function in `mock/with-mocks` to mock `rp/cmd!`
(returning success) and `rx/timer` (firing instantly). This
eliminates the 3200ms grace period in `op-grace-ms` and prevents
the network error from appearing in test output.

AI-assisted-by: mimo-v2.5
2026-08-04 15:55:47 +00:00
Dr. Dominik Jain
4ea56e0b89
🐛 Fix propagation of geometric changes to rotated component copies (#10574)
* 🐛 Fix geometry sync between mains and rotated component copies

Rotating a copy instance as a whole marked every shape inside it as
touched for geometry, so later geometric changes in the main (e.g. a
resize) were no longer propagated to that copy, while non-geometric
ones (e.g. fills) still were. And on paths where geometry did get
written to a rotated copy (e.g. resetting overrides), the sync engine
compensated only the roots' position delta, so the written values wiped
the copy's rotation back to 0.

Model the instance root's transformation as inherited, overridable
content, asymmetric to position (which remains free per-instance
placement):

- An untouched copy follows the main's transformation verbatim,
  including rotation and flips (preserving the BUG #13267 semantics
  that rotating a main propagates to its copies).

- Transforming a copy as a whole overrides only its ROOT: check-delta
  compares the root's rotation/flips absolutely, but the descendants
  relative to their root, so they merely follow and stay untouched.

- When a copy root's geometry is overridden, update-attrs expresses the
  main's geometry in the copy's own frame: reposition-shape applies the
  roots' relative transformation (rotation/flips) around the dest root
  center in addition to the position delta. Geometric changes from the
  main then keep propagating to the rotated copy, landing correctly in
  its rotated frame instead of destroying its placement.

Covered by the new composable test case
case-n-geometry-sync-with-rotated-instances: an 8-variant sweep over
optional copy rotation, optional main rotation, and one of a fills or
height edit on the main child, asserting the whole model through the
real workspace events (the new rotate operation dispatches
dwt/increase-rotation, whose apply-modifiers step runs the check-delta
classification under test; change-height dispatches
dwt/update-dimensions and implements IPropertyCheck so one-of sweeps
can mix property and geometry edits). Verified by temporarily reverting
the fix: the case then fails with 6 assertion failures and passes again
with the fix restored.

Fixes #10109

AI-assisted-by: claude-fable-5

* 🐛 Fix synchronization problems

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-07-21 10:05:31 +02:00
Dr. Dominik Jain
2c15dcdb84
✨ Add systematic component tests via a composable test model (#10529)
* ✨ Add systematic component tests via a composable test model

Introduce a framework for systematically testing Penpot component behaviour
(synchronisation/propagation, swaps, variant switches, nesting), plus a first
suite of cases built on it.

A test is expressed as a COMPOSITION OF OPERATIONS over a "situation" (an
in-memory file value plus named role bindings). Operations are reified as data
and composed by two combinators — `in-sequence` (threads the situation) and
`one-of`/`optional` (alternatives, enumerated into concrete variants). So one
written case stands for a whole matrix of variants, and coverage grows by
composition rather than by copying tests. Operations drive the REAL production
change pipeline, and event-operations dispatch the REAL workspace events and
await settlement, so the production watcher's automatic propagation is what is
exercised — the tests reflect genuine app behaviour, not a reimplementation.

Structure (frontend/test/frontend_tests/composable_tests/):
  - core            — the domain-agnostic engine: situation, the operation and
                      enumeration protocols, the combinators, and the runners.
  - comp/nodes      — the component operations (create/instantiate/reset, nesting,
                      swap, the variant ops, child add/remove/move, change, undo,
                      library sync).
  - comp/setups     — component-shaped starting configurations.
  - interpreter     — runs a case against the real frontend store: sync-ops apply
                      directly, event-ops dispatch real events and await
                      settlement (absorbing sync-file's delayed status RPC, which
                      would otherwise leak an error into subsequent tests).
  - comp/sync-test  — the cases (B-F, H, I, K, L, M).

This is test-only code with a single consumer — the frontend test suite (the
layer that runs the real app) — so it lives entirely under the frontend test
tree as .cljs, not under app/common.

The framework and its cases are documented in the project memory
frontend/composable-component-tests, added alongside.

Co-authored-by: Claude <noreply@anthropic.com>

* 🐛 Guard WASM mock teardown against an empty snapshot

`teardown-wasm-mocks!` unconditionally restored from the `originals` atom.
When run without a matching setup (double teardown, or `with-wasm-mocks*`
misused around an async test body), the snapshot is empty and every WASM API
function was `set!` to nil — permanently, for the remainder of the test run.
Any later code calling one of them (e.g. a leaked debounced resize-wasm-text
event firing during a subsequent test namespace) then crashed with
"initialized? is not a function".

Make the restore a no-op when there is nothing to restore.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 12:17:43 +02:00