Retain geometry touched when a plain swap keeps authored orientation,
so a later sync preserves it. Size-only and content overrides still clear.
Red regression reproduced; swap suite passes 39 tests / 154 assertions.
Benchmark vs Phase 1e: main 10/100/1000 0.51/0.64/0.65; levels 0.50;
chain 0.57; library 0.53; many shapes 0.47; switch 0.59. No flags.
AI-assisted-by: GPT-6
Keep snapshots exact and compare only shape geometry within an absolute
1e-10 tolerance. Reflow recomputed 0.01 as 0.010000000000005116 after undo.
Opacity, touched groups and other authored fields still compare exactly.
Fix the false positive F130 and document the narrow exception.
The regression failed before the fix. Focused comparison and structural
undo checks pass 2 tests with 9 assertions, including negative controls.
AI-assisted-by: GPT-6
Keep the roots' relative flip state when placing the source subtree.
Several small rotations must give the same child flags after a full sync;
recomputing one large turn could toggle both axes. Fix F139.
The direct regression failed nil/nil to true/true before the fix. Focused
geometry and oracle suites pass 20 tests, 88 assertions.
Benchmark ratios against Phase 1e: main 10/100/1000 0.57/0.64/0.60;
three levels 0.67; chain 0.56; library 0.60; many shapes 0.50;
variant switch 0.64. No regression flag.
AI-assisted-by: GPT-6
Hand applied transitions to the component watcher only after the workspace
records their undo entry. Keep the handoff synchronous with the captured
prior file data. This preserves transaction order when nesting triggers
several component syncs. Fix F137.
The minimal replay failed both undo and redo before the fix; its 4 checks
now pass. Existing sync scenarios pass their behavior and undo checks;
60 golden digests need review for the accumulated Phase 4 fixes.
Common benchmark ratios against Phase 1e: main 10/100/1000
0.46/0.52/0.53; levels 0.47; chain 0.51; library 0.51; many 0.49;
switch 0.61. This handoff changes no common benchmark code.
AI-assisted-by: GPT-6
A copy moved from a rotated parent can need its own component's sync even
when that main did not change. Include the moved heads among the sync
inputs so the new parent's copies receive the settled geometry. Fix F135.
The minimal regression failed before the fix. Geometry suite: 6 tests,
55 assertions, green; the shrunk seed 712 case also passes.
Benchmark ratios against Phase 1e: main 10/100/1000 0.46/0.52/0.53;
three levels 0.47; chain 0.51; library 0.51; many shapes 0.49;
variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Use each paired child's shape-ref directly. Resolving it through its own
component can select a different nesting level and move the child into an
earlier auto cell. Fix F134.
The regression moved column 2 to column 1 before the fix; all 3 assertions
now pass. Benchmark ratios against Phase 1e: main 10/100/1000
0.46/0.52/0.53; three levels 0.47; chain 0.51; library 0.51;
many shapes 0.49; variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Express the source subtree in its ordinary parent's orientation before
syncing a surviving nested copy. Detaching a rotated outer instance no
longer makes a later main edit turn the nested copy back. Fix F131.
The focused regression failed on points and rotation before the fix.
Geometry/reset suites: 19 tests, 111 assertions, all green.
Benchmark ratios against Phase 1e: main 10/100/1000 0.46/0.52/0.53;
three levels 0.47; chain 0.51; library 0.51; many shapes 0.49;
variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Assign positions after adding overflow cells so a second assignment leaves
the grid unchanged. This keeps main and copy cell metadata in agreement.
Fix F129. The red regression now passes; layout suite: 60 tests, 344 asserts.
Benchmark ratios against Phase 1e: main 10/100/1000 0.46/0.52/0.53;
three levels 0.47; chain 0.51; library 0.51; many shapes 0.49;
variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Restore the original cell metadata after replacing a child in place, and
ignore touched while assigning the new instance. Later main cell moves
can then reach the swapped copy. Fix F128.
The regression failed before the fix. Swap suite: 37 tests, 137 assertions.
Benchmark ratios against Phase 1e: main 10/100/1000 0.46/0.52/0.53;
three levels 0.47; chain 0.51; library 0.51; many shapes 0.49;
variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Recalculate cell positions in the previous grids after moving children.
The destination update alone left cell references to shapes that moved.
Validation: reset and move suites, 21 tests / 92 assertions. The grid
move regression failed before the fix and checks exact undo.
Benchmark ratios against Phase 1e: main 10/100/1000 copies
0.54/0.56/0.60; three levels 0.52; chain 0.52; library 0.55;
many shapes 0.58; variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Include cached deleted mains when resolving the position of a nested
copy. Reset previously passed nil to container lookup and threw.
Validation: reset and move suites, 21 tests / 92 assertions. The new
reset regression failed before the fix and checks undo and redo.
Benchmark ratios against Phase 1e: main 10/100/1000 copies
0.54/0.56/0.60; three levels 0.52; chain 0.52; library 0.55;
many shapes 0.58; variant switch 0.61. No regression flag.
AI-assisted-by: GPT-6
Save Stage 2 structure, text, geometry and layout generation, independent
oracle checks, frontend seeded replay and plugin visible-value checks.
Include pending grid sync and deletion fixes with focused regressions.
WIP validation: component sync 21 tests / 106 assertions; move shapes
6 tests / 33 assertions. Earlier plugin checks passed 68 full cases,
11 unit tests and types. The final generated replay, golden check,
5000-case gate, full suites and live WASM check remain unfinished.
Stages 3 (swaps/variants) and 4 (library files) have not started.
F124 benchmark ratios against Phase 1e: main 10/100/1000 copies
0.48/0.52/0.54; three levels 0.53; chain 0.56; library 0.52;
many shapes 0.42; variant switch 0.64. No regression flag.
The F125 deletion change still needs its final benchmark.
Save the detailed resume checklist and logs in the local stabilization
plan. Keep that workflow document out of the commit.
AI-assisted-by: GPT-6
Apply the relative orientation matrix directly. Large rotations also
change flip flags, so applying flips before rotation can add 180 degrees.
The text regression fails before this fix at 135 and 225 degrees.
Validation: geometry suite, 4 tests / 50 assertions; existing golden
cases and worlds, 1098 assertions. No existing golden digest changed.
Benchmark lower-quantile ratios against Phase 1e:
main 10/100/1000 copies 0.51/0.59/0.58; three levels 0.53;
chain 0.61; library 0.53; many shapes 0.54; variant switch 0.70.
No regression flag.
AI-assisted-by: GPT-6
Require an integer seed while retaining positive counts. Seed zero is a
valid test.check replay seed and must behave like the explicit option.
Validation: PENPOT_GEN_SEED=0 schema and runner suites pass,
44 tests / 119 assertions. The regression failed before the fix.
AI-assisted-by: GPT-6
Sort labels by their printed value so keyword and fallback string labels
can appear in the same diagnostic. The failing regression now passes.
Validation: schema and runner suites, 44 tests / 119 assertions.
AI-assisted-by: GPT-6
Duplicating a nested copy in a main also adds subtrees to that main's
copies. The frontend result lookup counted all of them and threw instead
of returning the user's duplicate. Exclude roots inside component copies.
The regression checks the lookup after the sync cascade creates three
subtrees. Focused common suites pass: 66 tests, 11263 assertions.
AI-assisted-by: GPT-6
Add, delete and move changes inside a deleted main carry a component
container ID. The change scanner ignored that destination, so it never
synced the deleted component's surviving copies after a structural edit.
Return the container's component ID for all three change kinds.
The integration regression failed with dangling refs before the fix.
It now removes the child from both the stored main and its copies and
checks undo/redo. Focused common suites pass: 66 tests, 11263 assertions.
Common lint passes.
Sync benchmark ratios against the 1e baseline (lower quantile):
main 10/100/1000 copies 0.55/0.65/0.61; three levels 0.52; chain 0.62;
library 0.58; many shapes 0.69; variant switch 0.79. No regression flags.
AI-assisted-by: GPT-6
Relocate assigned grid cells only when the caller named a target cell.
A layers-panel move left the new child unassigned in the main, while sync
assigned it in copies. Assign all unplaced children after either route.
The regression failed with an empty second cell before the fix. It now
places the moved shape there and checks exact undo restoration. Existing
move tests and common/frontend lint pass.
Sync benchmark ratios against the 1e baseline (lower quantile):
main 10/100/1000 copies 0.53/0.58/0.59; three levels 0.52; chain 0.54;
library 0.67; many shapes 0.65; variant switch 0.68. No regression flags.
AI-assisted-by: GPT-6
An absolute determinant threshold classified short perpendicular lines
as parallel. Constraint propagation then displaced text in a rotated
nested frame by hundreds of pixels. Compare the determinant relative to
both line lengths, retaining the existing parallel-line fallback.
The component regression failed on points and x/y before the fix. It now
passes, as do direct intersections across sizes and positions, the
geometry tests, and existing composable scenarios.
Sync benchmark ratios against the 1e baseline (lower quantile):
main 10/100/1000 copies 0.47/0.63/0.80; three levels 0.56; chain 0.48;
library 0.50; many shapes 0.45; variant switch 0.56. No regression flags.
AI-assisted-by: GPT-6
The first layout line starts as nil. JavaScript arithmetic treated its
missing count and bounds as zero, while the JVM threw. Give those three
values zero defaults before calculating gaps and the next line bounds.
A regression failed on the JVM before the fix. Six combinations of row
and column layout with start, around and even spacing now pass. Existing
flex tests pass too; the layout oracle fixtures pass on the JVM.
AI-assisted-by: GPT-6
Reset used the copy's geometry override to rotate the main before copying
its values, then cleared the override. The copy and its children kept the
old rotation until the next sync. Reset now uses the main's orientation.
The regression failed at 45 degrees before the fix and now passes, with
exact undo restoration. Shared scenarios and golden masters pass:
55 tests, 1866 assertions. No existing golden digest changed.
Sync benchmark ratios against the 1e baseline (lower quantile):
main 10/100/1000 copies 0.54/0.58/0.58; three levels 0.51; chain 0.62;
library 0.51; many shapes 0.47; variant switch 0.61. No regression flags.
AI-assisted-by: GPT-6
Add a shared generator, authored override shadow and independent oracle.
Resolve tuples against current trees, log skips and stable replay paths,
and check propagation, sync equivalence, values and exact undo/redo.
Keep partial sync in the preceding user's undo group.
Validation: 5,000 seeded scenarios passed; common JVM/JS, golden masters,
lint and format passed. Focused oracle and sequence checks also pass.
Production sync is unchanged by this test harness commit.
AI-assisted-by: GPT-6
F114: updating a nested copy's main cleared the groups transferred by a
previous update whenever the source still carried remote-synced. A later
edit of the inner component could then overwrite those authored values.
Union the main's and copy's groups on every update.
The focused nested regression failed before the fix and now passes.
Common lint, JVM/JS suites, golden masters and format checks pass.
Seeded frontend replay passes 2 tests and 253 assertions.
Benchmark lower-quantile ratios against Phase 1e:
main 10/100/1000 copies: 0.61/0.63/0.67; three levels: 0.61;
chain: 0.56; library: 0.62; many shapes: 0.63; switch: 0.76.
No regression flag.
AI-assisted-by: GPT-6
The Phase 4 random harness found F113: undo of detaching an overridden
copy restored its touched groups before shape-ref. The copy-only setter
therefore dropped those groups. Append the touched and remote-sync undo
operations after restoring the shape's attributes.
The focused regression failed first and now passes. Common JVM/JS and
all 1499 frontend tests pass. Common lint and targeted format checks pass.
The frontend test build used shadow-cljs directly because Cargo's cache
permissions blocked the wrapper's unrelated WASM rebuild.
Benchmark lower-quantile ratios against Phase 1e:
main 10/100/1000 copies: 0.46/0.52/0.53; three levels: 0.54;
chain: 0.47; library: 0.58; many shapes: 0.68; switch: 0.64.
No regression flag.
AI-assisted-by: GPT-6
Preserve explicit quick-check options and read PENPOT_GEN_NUM and
PENPOT_GEN_SEED when callers leave them unset. Print the final shrunk
counterexample in full so failures can be replayed.
The new regressions failed before the fix and now pass on the JVM.
Common lint and the full JVM/JS suites pass. Format checks pass for
these files; the in-progress Phase 4 harness remains outside this commit.
AI-assisted-by: GPT-6
Cancel the full thumbnail upload/render stream on page or workspace
teardown and on matching thumbnail clearing. Deferred library thumbnails
can otherwise query a main missing from the next WASM scene and abort
the renderer (F112).
Complete the upload observable after its callback so successful requests
also release their subscriptions.
Add async regressions for pending current and library frames, late upload
callbacks, clearing, and successful rendering. They fail before the fix.
Document the thumbnail lifecycle in the frontend memory.
Frontend CI passes lint, 1499 tests / 7272 assertions, and formatting.
Live WASM update-main followed by navigation no longer crashes; an
uninterrupted library request refreshes its PNG thumbnail too.
AI-assisted-by: GPT-6
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
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
Store cumulative content scale as an optional shape attribute, default 1.
Sync scales the main's styles by the copy/main ratio separately from
constraint resizing. Keep descendants untouched and allow later main style
edits to propagate (F109). Both renderer modifier paths use this field.
Cover a later sync, repeated scaling, main radius edits, reset and its undo.
The original stroke regression failed before the fix. Common JVM/JS and
frontend suites, backend and exporter tests, lint and format checks pass.
Historical scale gestures cannot be reconstructed from old file data.
Benchmark vs Phase 1e (lower quantile, ms):
world baseline now ratio
main-10 1.790 0.993 0.55
main-100 15.415 9.038 0.59
main-1000 153.086 90.145 0.59
three-levels 35.215 18.989 0.54
chain 68.931 35.045 0.51
library-500 83.083 85.830 1.03
many-shapes 4.837 3.344 0.69
variant-100 32.052 33.586 1.05
All worlds pass the 10% regression threshold.
AI-assisted-by: GPT-6
Count repeated processing of each component instead of total graph width.
A valid dependency graph with 102 outer components now settles and updates
every copy (F108). The non-settling cascade guard remains bounded.
The regression failed at 100 passes before the fix, then passed with 103
components. Common JVM/JS, frontend, backend and exporter suites pass.
Benchmark vs Phase 1e (lower quantile, ms):
world baseline now ratio
main-10 1.790 0.993 0.55
main-100 15.415 9.038 0.59
main-1000 153.086 90.145 0.59
three-levels 35.215 18.989 0.54
chain 68.931 35.045 0.51
library-500 83.083 85.830 1.03
many-shapes 4.837 3.344 0.69
variant-100 32.052 33.586 1.05
All worlds pass the 10% regression threshold.
AI-assisted-by: GPT-6
Keep touched groups and remote sync state when cloning an absorbed main.
New component copies still start clean. Extend the nested absorption test
with head and descendant overrides, then sync the inner component (F107).
Validation: red regression before the fix; common JVM 1705 tests and JS
1633 tests, backend 1610 tests, exporter 14 tests pass.
AI-assisted-by: GPT-6
Copy texts in files older than mid-2025 can hold
:text-content-structure-same-attrs next to :text-content-structure,
which marked a structure change of a text with one style. The text
override migration recomputed the groups and dropped the structure flag
but left this one in :touched, where nothing reads it, and validation
did not report it. It is now one of the legacy text groups, so the
migration and the :legacy-text-touched repair drop it too. Found in
the copy A2 of the QA fixture text-components-propagation.penpot while
re-running PENPOT-2261.
convert-text-overrides-test: the flag goes with the structure flag and
without it. repair-components-test/legacy-text-touched: a copy holding
it is reported and repaired.
AI-assisted-by: claude-opus-5-5
Moving files or a project to another team unlinked every library that
ended up in another team than the file using it, but absorbed nothing:
the copies kept pointing at a library their file could no longer
reach. A move now does what deleting the library does (PENPOT-1540):
it absorbs each such library into the files using it, then unlinks
them; as on deletion, a failed absorption is logged and does not stop
the move. That covers a moved library and the files left behind using
it, a moved file and the libraries it used, and a moved project.
The links checked are those that start or end at a moved file. They
were those of the files in the source and destination projects, so a
file in another project of the old team kept its link to a moved
library across teams.
rpc-management-test: a moved library is absorbed into a file in its
project and into one in a project the move does not touch; a moved
file absorbs its library; a moved project absorbs the library it left
behind.
AI-assisted-by: claude-opus-5-5
Deleting a shared library removes its links to the files using it at
once, so they stop listing it, and queues the delete-object job, which
absorbs the library into the files it finds through those links. By
then there were none, so nothing was absorbed: the files lost the
library and their copies kept pointing at the deleted file
(PENPOT-1092). Unpublishing absorbs before it unlinks, so it was not
affected.
The delete reads the files using the library before unlinking them
and gives them to the job (:absorb-file-ids), which absorbs into them;
without them, as from the project and team deletions, the job still
finds them through the links.
rpc-file-test/delete-shared-library-absorbs-it-into-the-files-using-it
deletes the library through the RPC and runs the job it queued.
AI-assisted-by: claude-opus-5-5
Absorbing a library into a file that uses it (unpublishing or deleting
the library) saved the file without its features. Saving stores the
changed pointer-map fragments only when the features name
fdata/pointer-map, so with that feature the file pointed at a fragment
of components that was never written, and loading it failed with
:fragment-not-found. The file's features now go with the save.
rpc-file-test/unpublish-library-absorbs-it-into-the-files-using-it
unpublishes a library with a copy in another file, then loads that
file. The existing absorption test turns fdata/pointer-map off, so it
missed this.
AI-assisted-by: claude-opus-5-5
When the variant property panel went away (selecting another layer),
input-with-meta* sent a blur from the last input it had rendered, to
commit an edit still open. After an edit was already committed that
input is gone and keeps the text it had: reordering the properties
and then selecting a variant renamed the property now in that row
after the old one, "Property 1" becoming "Property 2 (1)"
(PENPOT-2588). The input is forgotten once its edit is committed, so
the blur is sent only for an edit still open.
ds-input-with-meta-test mounts the component in a jsdom document: a
committed edit is not sent again when the value changes and the input
goes away; an edit still open is committed.
AI-assisted-by: claude-opus-5-5
A switch picks the variant whose properties are nearest the wanted
ones; between equally near ones it took the last child of the variant
container. Since combine as variants keeps the order of the components
it combines, that is the last of them: switching an empty variant to
"primary" gave primary/icon+test where the QA case (PENPOT-2446)
expects primary/hover, the first primary variant. It now takes the
first child among the equally near ones.
variants-switch-target-test: two variants one value away, in both
orders of the container's children.
Benchmark against the 1e baseline (lower quantile, ms):
world baseline now ratio
deepest-edit-through-three-levels 35.215 16.810 0.48
edit-through-a-chain-of-dependents 68.931 32.765 0.48
library-update-into-copies 83.083 39.232 0.47
main-edit-among-many-shapes 4.837 2.277 0.47
main-edit-into-10-copies 1.790 0.807 0.45
main-edit-into-100-copies 15.415 8.054 0.52
main-edit-into-1000-copies 153.086 81.186 0.53
variant-switch-into-copies 32.052 17.933 0.56
AI-assisted-by: claude-opus-5-5
The registry tests mock the RPC and check every call recorded while the
mock is installed. A component sync deletes the thumbnails of the
frames it changes 200 ms later, so a sync at the end of an earlier test
of the suite can call the mock in that window: under the coverage run,
the frontend suite failed in register-test, a different test each time,
with an extra :delete-file-object-thumbnails call. The mock now records
and answers only the plugin profile commands; any other call gets an
empty answer.
AI-assisted-by: claude-opus-5-5
The contract memory says how the code now holds it: the eager cascade
and where it lives, the automatic text name, the children of a
transformed copy shape, measured text sizes on both renderers, repairs
as derived writes, the tolerance of "differs" and the :set-touched
operation. The debugging recipes add the override scanner; the data
model and the composable test notes follow the changes.
AI-assisted-by: claude-opus-5-5
A copy keeps a value of its own only in a touched group, so a value
that differs from its main's in an untouched group is lost
propagation. app.common.logic.override-scan runs the sync of every
component a file uses in memory and lists the copy shapes it would
change, with the attributes and groups, or :structure for children
that differ. The size of a text that grows with its content and the
place of a layout child are left out: they are not the main's to
give. Copies of components a predicate names are skipped.
debug.scan_overrides() lists them for the current file in the browser
console, skipping copies of library components changed since the file
last synced with the library.
override-scan-test: a synced copy and an override give nothing; an
untouched difference, in a local or a library copy, is found; a
skipped component is not scanned.
AI-assisted-by: claude-opus-5-5
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
Decision (c) of the component contract, after a fix that made the sync of a
library file reach that file at all (Phase 3c).
- Commit the sync of a library file to that file.
An update of the main of a library component, made from a copy in the
file, syncs the copies of the component in the file and in the library.
The changes of a sync carry no file id, so the commit took the current
file's: the library's copies, among them those its other mains nest, were
written to pages the current file does not have, and never changed (F19).
A sync now commits to the file it syncs.
workspace-remote-cascade-test: an update of the main of a library
component reaches the copy of it that another library main nests.
- Sync the components a sync changes in the same commit.
Decision (c) of the component contract: a sync reaches the copies of the
components it changes, until none is left, in one commit. A sync of a file
changed the mains of the components that nest the synced copies, and the
component watcher synced those in a commit of its own, one level per pass,
so the file stayed partly synced in between; the watcher only watches the
current file, so a sync of a library file stopped at its first level
(F19).
common: generate-sync-file-cascade syncs a file as
generate-sync-file-changes does, then the copies of each component of the
file that the sync changed, one per pass, applying each pass to the file
data, until none is left (generate-sync-components-cascade). The
composable runner, the benchmark and the golden master use it.
frontend: sync-file commits the whole cascade at once. When it syncs a
library file, it then syncs the copies in the current file of the library
components the cascade changed.
workspace-remote-cascade-test: an update of the main of a library
component, made from a copy in the file, reaches the file's copy of a
library component whose main nests it. The common golden master records
fewer changes undone in case W: a cascade is one change set.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 17.0 / 0.48
edit-through-a-chain-of-dependents 68.9 / 33.2 / 0.48
library-update-into-copies 83.1 / 43.6 / 0.52
main-edit-among-many-shapes 4.8 / 3.5 / 0.73
main-edit-into-10-copies 1.8 / 0.9 / 0.49
main-edit-into-100-copies 15.4 / 8.1 / 0.52
main-edit-into-1000-copies 153.1 / 83.9 / 0.55
variant-switch-into-copies 32.1 / 19.6 / 0.61
AI-assisted-by: claude-opus-5-5
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
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
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
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
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
A fix and a check in the variant switch (Phase 3c).
- Keep a fixed-sizing size on a switch when both variants agree.
A keep-touched variant switch carries a copy's resized size when the old
and new variants agree on it (decision (a)). A shape whose
:layout-item-h-sizing or :layout-item-v-sizing is :fix took the new
variant's size on that axis even when both variants had the same one, so a
resize the user made was lost (F61). The fixed axes now follow the same
rule as the others: the copy's size when the variants agree, the new
variant's when they differ, which is what the tests of a switch between
variants of different sizes already expect.
variants-switch-test: a fixed-width rect keeps its resized size.
- Leave the shape out of child-of-swapped? by id.
A keep-touched switch skips the touched shapes below a swapped child,
which move with it. child-of-swapped? looked for a swap among the heads
between the switched root and the shape, and left the shape out by
dropping the last head, which is the shape only when it is a head; below a
swapped child, a shape that is not a head saw that child dropped instead
and was not skipped (F100). The switch still did the right thing, as such
a shape finds nothing to pair with in the new variant. The shape is now
left out by its id. The golden master is unchanged.
variants-switch-test: an override inside a swapped child survives a
keep-touched switch.
- Check a switched resized copy text keeps only its geometry.
A keep-touched variant switch of a copy whose text was resized, between
variants whose texts are measured differently, carries the size and
touches only :geometry-group: it adds no :content-group for the position
data it leaves to be measured again (F101 does not happen).
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 16.7 / 0.47
edit-through-a-chain-of-dependents 68.9 / 33.1 / 0.48
library-update-into-copies 83.1 / 65.1 / 0.78
main-edit-among-many-shapes 4.8 / 3.6 / 0.73
main-edit-into-10-copies 1.8 / 0.8 / 0.47
main-edit-into-100-copies 15.4 / 8.3 / 0.54
main-edit-into-1000-copies 153.1 / 86.6 / 0.57
variant-switch-into-copies 32.1 / 20.1 / 0.63
AI-assisted-by: claude-opus-5-5
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