mirror of
https://github.com/penpot/penpot.git
synced 2026-11-03 08:30:40 +00:00
162 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
8fea2a5c56 |
🐛 Preserve scaled content styles through component sync
Store cumulative content scale as an optional shape attribute, default 1. Sync scales the main's styles by the copy/main ratio separately from constraint resizing. Keep descendants untouched and allow later main style edits to propagate (F109). Both renderer modifier paths use this field. Cover a later sync, repeated scaling, main radius edits, reset and its undo. The original stroke regression failed before the fix. Common JVM/JS and frontend suites, backend and exporter tests, lint and format checks pass. Historical scale gestures cannot be reconstructed from old file data. Benchmark vs Phase 1e (lower quantile, ms): world baseline now ratio main-10 1.790 0.993 0.55 main-100 15.415 9.038 0.59 main-1000 153.086 90.145 0.59 three-levels 35.215 18.989 0.54 chain 68.931 35.045 0.51 library-500 83.083 85.830 1.03 many-shapes 4.837 3.344 0.69 variant-100 32.052 33.586 1.05 All worlds pass the 10% regression threshold. AI-assisted-by: GPT-6 |
||
|
|
032a097764 |
🐛 Bound component sync retries per component
Count repeated processing of each component instead of total graph width. A valid dependency graph with 102 outer components now settles and updates every copy (F108). The non-settling cascade guard remains bounded. The regression failed at 100 passes before the fix, then passed with 103 components. Common JVM/JS, frontend, backend and exporter suites pass. Benchmark vs Phase 1e (lower quantile, ms): world baseline now ratio main-10 1.790 0.993 0.55 main-100 15.415 9.038 0.59 main-1000 153.086 90.145 0.59 three-levels 35.215 18.989 0.54 chain 68.931 35.045 0.51 library-500 83.083 85.830 1.03 many-shapes 4.837 3.344 0.69 variant-100 32.052 33.586 1.05 All worlds pass the 10% regression threshold. AI-assisted-by: GPT-6 |
||
|
|
37e233f6e1 |
🐛 Take the first equally near variant on a switch
A switch picks the variant whose properties are nearest the wanted ones; between equally near ones it took the last child of the variant container. Since combine as variants keeps the order of the components it combines, that is the last of them: switching an empty variant to "primary" gave primary/icon+test where the QA case (PENPOT-2446) expects primary/hover, the first primary variant. It now takes the first child among the equally near ones. variants-switch-target-test: two variants one value away, in both orders of the container's children. Benchmark against the 1e baseline (lower quantile, ms): world baseline now ratio deepest-edit-through-three-levels 35.215 16.810 0.48 edit-through-a-chain-of-dependents 68.931 32.765 0.48 library-update-into-copies 83.083 39.232 0.47 main-edit-among-many-shapes 4.837 2.277 0.47 main-edit-into-10-copies 1.790 0.807 0.45 main-edit-into-100-copies 15.415 8.054 0.52 main-edit-into-1000-copies 153.086 81.186 0.53 variant-switch-into-copies 32.052 17.933 0.56 AI-assisted-by: claude-opus-5-5 |
||
|
|
69bf2da97f |
✨ Add a scanner of copies that differ from their mains
A copy keeps a value of its own only in a touched group, so a value that differs from its main's in an untouched group is lost propagation. app.common.logic.override-scan runs the sync of every component a file uses in memory and lists the copy shapes it would change, with the attributes and groups, or :structure for children that differ. The size of a text that grows with its content and the place of a layout child are left out: they are not the main's to give. Copies of components a predicate names are skipped. debug.scan_overrides() lists them for the current file in the browser console, skipping copies of library components changed since the file last synced with the library. override-scan-test: a synced copy and an override give nothing; an untouched difference, in a local or a library copy, is found; a skipped component is not scanned. AI-assisted-by: claude-opus-5-5 |
||
|
|
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 |
||
|
|
52e1ddfd1f |
✨ Sync the components a sync changes in the same commit
Decision (c) of the component contract, after a fix that made the sync of a library file reach that file at all (Phase 3c). - Commit the sync of a library file to that file. An update of the main of a library component, made from a copy in the file, syncs the copies of the component in the file and in the library. The changes of a sync carry no file id, so the commit took the current file's: the library's copies, among them those its other mains nest, were written to pages the current file does not have, and never changed (F19). A sync now commits to the file it syncs. workspace-remote-cascade-test: an update of the main of a library component reaches the copy of it that another library main nests. - Sync the components a sync changes in the same commit. Decision (c) of the component contract: a sync reaches the copies of the components it changes, until none is left, in one commit. A sync of a file changed the mains of the components that nest the synced copies, and the component watcher synced those in a commit of its own, one level per pass, so the file stayed partly synced in between; the watcher only watches the current file, so a sync of a library file stopped at its first level (F19). common: generate-sync-file-cascade syncs a file as generate-sync-file-changes does, then the copies of each component of the file that the sync changed, one per pass, applying each pass to the file data, until none is left (generate-sync-components-cascade). The composable runner, the benchmark and the golden master use it. frontend: sync-file commits the whole cascade at once. When it syncs a library file, it then syncs the copies in the current file of the library components the cascade changed. workspace-remote-cascade-test: an update of the main of a library component, made from a copy in the file, reaches the file's copy of a library component whose main nests it. The common golden master records fewer changes undone in case W: a cascade is one change set. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 17.0 / 0.48 edit-through-a-chain-of-dependents 68.9 / 33.2 / 0.48 library-update-into-copies 83.1 / 43.6 / 0.52 main-edit-among-many-shapes 4.8 / 3.5 / 0.73 main-edit-into-10-copies 1.8 / 0.9 / 0.49 main-edit-into-100-copies 15.4 / 8.1 / 0.52 main-edit-into-1000-copies 153.1 / 83.9 / 0.55 variant-switch-into-copies 32.1 / 19.6 / 0.61 AI-assisted-by: claude-opus-5-5 |
||
|
|
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 |
||
|
|
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
|
||
|
|
36507f666c |
🐛 Fix what a variant switch keeps
A fix and a check in the variant switch (Phase 3c). - Keep a fixed-sizing size on a switch when both variants agree. A keep-touched variant switch carries a copy's resized size when the old and new variants agree on it (decision (a)). A shape whose :layout-item-h-sizing or :layout-item-v-sizing is :fix took the new variant's size on that axis even when both variants had the same one, so a resize the user made was lost (F61). The fixed axes now follow the same rule as the others: the copy's size when the variants agree, the new variant's when they differ, which is what the tests of a switch between variants of different sizes already expect. variants-switch-test: a fixed-width rect keeps its resized size. - Leave the shape out of child-of-swapped? by id. A keep-touched switch skips the touched shapes below a swapped child, which move with it. child-of-swapped? looked for a swap among the heads between the switched root and the shape, and left the shape out by dropping the last head, which is the shape only when it is a head; below a swapped child, a shape that is not a head saw that child dropped instead and was not skipped (F100). The switch still did the right thing, as such a shape finds nothing to pair with in the new variant. The shape is now left out by its id. The golden master is unchanged. variants-switch-test: an override inside a swapped child survives a keep-touched switch. - Check a switched resized copy text keeps only its geometry. A keep-touched variant switch of a copy whose text was resized, between variants whose texts are measured differently, carries the size and touches only :geometry-group: it adds no :content-group for the position data it leaves to be measured again (F101 does not happen). Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 16.7 / 0.47 edit-through-a-chain-of-dependents 68.9 / 33.1 / 0.48 library-update-into-copies 83.1 / 65.1 / 0.78 main-edit-among-many-shapes 4.8 / 3.6 / 0.73 main-edit-into-10-copies 1.8 / 0.8 / 0.47 main-edit-into-100-copies 15.4 / 8.3 / 0.54 main-edit-into-1000-copies 153.1 / 86.6 / 0.57 variant-switch-into-copies 32.1 / 20.1 / 0.63 AI-assisted-by: claude-opus-5-5 |
||
|
|
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
|
||
|
|
57e80061ad |
🐛 Fix how changes touch copies and mark components
Fixes in how a change touches a copy shape and marks its component modified
(Phase 3c).
- Give a copy shape back :remote-synced when its touch is undone.
Touching a copy shape clears its :remote-synced (the mark that it was last
synced from a remote main, which update main reads). The undo that
pcb/update-shapes writes set the touched groups back but not that mark, so
undoing an edit left the shape without it (F25).
The undo now sets :remote-synced back when the shape had it.
comp-touched-test: undoing an edit of a remote-synced copy shape gives the
mark back and leaves the shape untouched.
- Apply an update's touched groups before the attrs it changes.
When the function given to update-shapes set a copy shape's :touched and
changed an attribute that touches, the order of the two operations
followed the order of the shape's keys: a record field such as :name came
before :touched, so the :set of :touched ran last and dropped the group
the name had just touched (F24); keys in the shape's map came in hash
order.
The :set of :touched now always goes first, so the update's touched groups
are the base and each attribute it changes touches on top.
comp-touched-test: an update that sets the touched groups and changes the
fills, opacity, strokes, visibility, blocking, a radius or the name keeps
both.
- Count an :assign on a main as a change of its component.
components-changed, which decides which components a commit changed and so
need their copies synced, looked only at :set operations, so a :mod-obj
with an :assign of synced attributes on a main never started a sync (F28).
Nothing in the code writes :assign today, so no user hit it. An :assign
now counts when any of its attributes is one that component sync reads.
comp-sync-test: a :set and an :assign of opacity on a main child report
its component; an :assign of interactions reports none.
- Mark a component modified only by changes copies can show.
A change to a main marks its component modified, which tells the files
that use the library to offer an update. set-shape-attr left in the
shape's metadata whether its attribute is one that sync reads and changed,
and the change was judged by that flag (F21):
- :position-data, which a text re-measures from its content, counted, so
re-measuring a main text prompted every file to update; decision (e) of
the component contract says it never does;
- each :set overwrote the flag, so a change that set a synced attribute
and then the position data counted only the last one.
:position-data no longer counts, and the flag is cleared at the start of
each change and set by any of its :set operations.
comp-sync-test: a fill change marks the component, a re-measure does not,
and a fill change followed by a re-measure does.
- Compare transforms and paths of a copy with a tolerance.
A change to a copy touches the group of the attribute when the value
differs from the one it has. Numbers, selection rects, points and position
data are compared with a tolerance, but the transform, its inverse and the
content of a path were compared exactly, so float noise (a transform
recomputed, a path moved there and back) touched :geometry-group (F23).
The matrices are now compared term by term with the precision of the
matrix helpers, as their scale and rotation terms need, and the paths
segment by segment, with the precision the caller gives.
container-test: float noise in a transform, its inverse or a path touches
nothing; a rotation or a moved point still touches.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 19.0 / 0.54
edit-through-a-chain-of-dependents 68.9 / 34.6 / 0.50
library-update-into-copies 83.1 / 43.8 / 0.53
main-edit-among-many-shapes 4.8 / 2.5 / 0.51
main-edit-into-10-copies 1.8 / 0.9 / 0.48
main-edit-into-100-copies 15.4 / 9.7 / 0.63
main-edit-into-1000-copies 153.1 / 85.6 / 0.56
variant-switch-into-copies 32.1 / 20.5 / 0.64
AI-assisted-by: claude-opus-5-5
|
||
|
|
b4c2240a60 |
🐛 Fix export detach and the migrations of copies
Fixes in what export does to the copies of libraries it leaves out, and in
the migrations of swap slots and copy texts (Phase 3c).
- Drop library color refs in fills and strokes on detached export.
The binfile export that detaches libraries drops each reference to another
file. For colors it read only where the older format kept them
(:fill-color-ref-file and :stroke-color-ref-file on the shape and on text
nodes), so a library color ref inside :fills or :strokes, the current
format, stayed and the exported file pointed at a library it does not
carry (F57). Every color of the shape, its text nodes included, now goes
through cts/process-shape-colors, which detaches the ones that refer to
another file.
file-test: a fill ref inside :fills is dropped.
- Detach copies by where their refs and components point on export.
The binfile export that detaches libraries decided for each copy shape
from one file, the :component-file of the shape or of the closest head
above it, and then dropped its component and its :shape-ref together
(F52). That is wrong both ways:
- a library copy nested in a local component refers, in each copy of that
component, to the nested copy in the local main; using the head's own
library, its :shape-ref was dropped, and the next sync treated the shape
as one the copy added;
- a dropped link kept :touched, :remote-synced and :main-instance;
- the shapes stored in deleted components were not detached at all.
A shape's :shape-ref now points into the file of the component of the top
head of its copy, or of the closest swapped head above it, and the link
goes with the shape's other component attributes (ctk/detach-shape) only
when that file is another one. A head whose own component is in another
file stops being a head and loses :swap-group, but stays a copy while its
main shape is in this file. Deleted components' shapes are detached the
same way.
file-test: a dropped link loses :touched; a library copy nested in a local
component keeps its ref in the component's copy, and the file validates; a
deleted component's nested library copy is detached.
- Follow a swap slot's chain through the pages of its mains.
find-ref-id-for-swapped, which migration 0010 runs on files older than the
swap group, walks a swapped shape's ref chain to the main child its swap
slot names. It read each step's children from the page of the step's own
component, not from the page the step lives on, so when the mains of a
nesting sat on different pages it found nothing, and the migration then
removed a valid slot: the swap was lost (F56). Each step now goes on in
the container the ref lookup found it in.
file-test: the original ref of a swap is found with the mains on two
pages.
- Remove ctf/get-component-container-from-head.
Its last caller, the swap slot chain walk of migration 0010, now takes
each step's container from the ref lookup.
- Recompute copy texts touched with the content group alone.
Files older than the text override groups mark a copy text whose content
changed with :content-group alone. Sync reads that as nothing touched in
the text and writes the main's content, so the copy lost its text and
styles at the next sync of its main (F50). Migration 0034 converted the
later groups but left this one.
convert-text-overrides now also converts a copy text with :content-group
and no text group, reading it as allowing every text group: compared with
its near main, it takes the groups where it differs (all of them when the
near main cannot be reached). Migration 0035 runs it on files that already
passed 0034; a binfile import converts them with the other old text
groups, deferred as they are.
convert-text-overrides-test: such a copy takes the letters and styles that
differ, nothing where nothing differs, and through the migration.
text-sync-test: once migrated, the copy keeps its text and takes the
main's new size.
AI-assisted-by: claude-opus-5-5
|
||
|
|
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 |
||
|
|
a0dcb42c4c |
♻️ Simplify the component sync walk and its changes
- The sync walk took its direction as a map of seven closures (direct-sync, inverse-sync). The two directions are fixed, so each is now a plain recursive function: sync-copy-recursive (main to copy, a sync or a reset) and update-main-recursive (copy to main), with sync-copy-shape and update-main-shape for the pair itself. Both share compare-children, which takes the children, a pair? predicate and four callbacks instead of 13 arguments; the inverse branch's call of the swapped callback, which could never run, goes. - The container of the main shapes is found once, at the start of the walk. The walk holds the :remote-synced a reset or an update main gives the copy shapes, instead of initial-root? and two branches. add-shape-to-instance and add-shape-to-main take the parent from the pair being walked instead of searching the whole subtree for it. - pcb/add-changes takes a container and the redo and undo changes, and applies them locally. Eight sync functions and the library typography sync each wrote that block; make-change goes from both namespaces. One list holds the attributes that link a copy shape with its main, and one shape-label prints shapes in the log lines. - change-touched chose what to write from three flags. It is now set-touched, which takes the value each caller computes. The swap's restore of the kept properties passes ignore-touched instead of calling it afterwards to put the groups back. - generate-sync-shape was a multimethod over the three asset types; it is a case in generate-sync-container. generate-sync-file loses its unused arity. The 100-line comment that opened the sync helpers described an older model; a short note points at the component sync contract. text-content-with-overrides only called txt/merge-overrides, which sync and switch now call. - update-flex-child-copy-attrs and update-flex-child-main-attrs are one function, update-flex-children, and the omit-touched? argument that was always false goes. - In the variant switch, sized-geometry-value builds the resized :selrect and :points that two geometry policies each built, and a binding of skip? shadowed by a second one is skip-attr?. add-shape-to-main keeps two changes that look wrong, now marked F102: its :reg-objects names the component, so it only reaches a deleted component's objects, and the undo of its new shapes deletes them from the copy's page. No behavior change: the golden masters and the suites are unchanged. libraries.cljc: 1816 -> 1284 lines. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 15.2 / 0.43 edit-through-a-chain-of-dependents 68.9 / 35.7 / 0.52 library-update-into-copies 83.1 / 40.5 / 0.49 main-edit-among-many-shapes 4.8 / 2.4 / 0.51 main-edit-into-10-copies 1.8 / 0.8 / 0.46 main-edit-into-100-copies 15.4 / 7.8 / 0.51 main-edit-into-1000-copies 153.1 / 97.4 / 0.64 variant-switch-into-copies 32.1 / 17.6 / 0.55 (The machine was busy for the 1000-copies world: its mean was 119 ms, against about 83 in the runs of the earlier steps, at 0.49-0.52.) AI-assisted-by: claude-opus-5-5 |
||
|
|
677a1d5c63 |
⚡ Make component sync about twice as fast
A profile of the benchmark worlds showed where a sync spends its time. - The token sync of shapes that apply no tokens took 38% of a sync in the 1000-copy world: for every synced shape, generate-update-tokens walked every sync attribute, resolved its group and its token attributes, and only then found that neither the main shape nor the copy applies a token. It now returns at once, and the token attributes of each sync attribute are computed once, at load. - A sync looks through every object of each page and component to find the copies it updates, so in a file with many shapes and few copies the search costs more than the sync itself. The new benchmark world main-edit-among-many-shapes syncs a main edit into 10 copies in a page with 20,000 plain rects; its baseline, measured before the change below, joins the recorded one with a note, and its result joins the golden master. generate-sync-container called the uses-assets? multimethod on every object through a lazy filter; the predicate is now chosen once per container (asset-user?) and the objects walked with reduce-kv into a vector, in the same order. - compare-children searched the remaining children (d/seek) for every child out of place, and dropped a child taken out of order with a lazy remove, so a reorder took quadratic time and nested one lazy sequence per child taken. It now looks children up by their reference or id (index-positions) and skips those taken out of order when the walk reaches them, with the same callbacks in the same order. The sync of a reorder stays slow: when a main with 1,000 children moves its first one to the end, each copy still gets 999 moves (F85), and applying them is what costs (4.75 s to 4.26 s for 10 copies). Fewer moves change what a sync writes, so that waits for Phase 4c. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 18.6 / 0.53 edit-through-a-chain-of-dependents 68.9 / 36.4 / 0.53 library-update-into-copies 83.1 / 43.0 / 0.52 main-edit-among-many-shapes 4.8 / 2.3 / 0.48 main-edit-into-10-copies 1.8 / 0.9 / 0.48 main-edit-into-100-copies 15.4 / 8.4 / 0.54 main-edit-into-1000-copies 153.1 / 90.1 / 0.59 variant-switch-into-copies 32.1 / 20.0 / 0.63 (main-edit-among-many-shapes: baseline measured before the search change.) AI-assisted-by: claude-opus-5-5 |
||
|
|
97ad0e065f |
🐛 Keep the undo of a large component sync from overflowing
Undoing a main edit synced into thousands of copies overflowed the stack, on the JVM and in JS (F84). Each synced shape added the undo of its parents' update to the end of the undo list with a lazy concat, so the list nested one lazy sequence per synced shape, and reading it recursed through all of them. concat-changes and the inverse sync's marking of local changes (a map over the whole list at every level of the walk) added more. - A shape's :reg-objects undo now goes right after the shape's own. Each parent is still updated after all its children are undone, and a synced parent gets back its stored values instead of recomputed ones; the undo round trip of every composable case is unchanged. - concat-changes builds the joined undo list at once, at a cost that grows with the list it adds. - The inverse sync collects the copy shapes it syncs and marks their changes as local once, at the end, instead of at every level. large-sync-undo-test builds 5,000 copies in one step, syncs a main edit into them and reads the undo list. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 37.3 / 1.06 edit-through-a-chain-of-dependents 68.9 / 69.8 / 1.01 library-update-into-copies 83.1 / 76.7 / 0.92 main-edit-into-10-copies 1.8 / 1.6 / 0.88 main-edit-into-100-copies 15.4 / 15.2 / 0.99 main-edit-into-1000-copies 153.1 / 152.9 / 1.00 variant-switch-into-copies 32.1 / 27.3 / 0.85 (Measured with the dead :shapes-group reads already removed; run side by side, this commit and that state give the same ratios.) AI-assisted-by: claude-opus-5-5 |
||
|
|
5f06c289ce |
♻️ Move the placement step of a transform to common
Which shapes of a copy a transform leaves untouched (the ignore tree) was frontend code, written twice: calculate-ignore-tree for the modifiers of the SVG renderer and calculate-ignore-tree-wasm for the matrices of the WASM renderer. The second is the single-pass version of the first: each shape still compares itself with the copy root of the latest transformed shape above it, as the repeated walks did when the last one won. app.common.logic.placement now holds it once: calculate-ignore-tree takes the transformed ids in order and a function that transforms a shape, built with modifiers-transform (SVG) or matrices-transform (WASM), both cached per shape. check-delta and transform-attrs moved with it. The frontend calls the common version, and its tests moved to common-tests.logic.placement-test, which runs on the JVM and in JS, with a rotation through modifiers added. No behavior change: the golden masters and the suites are unchanged. The benchmark does not run this code. AI-assisted-by: claude-opus-5-5 |
||
|
|
c6b6572ae4 |
♻️ Split app.common.logic.libraries by concern
The namespace held 3,300 lines: component sync, instance creation and detach, swap and the variant switch's attribute carry, duplication, and the sync of library colors and typographies. Each now has its own namespace: - app.common.logic.libraries (cll): component sync, reset, update main, attribute and placement sync, and the sync of a file with its libraries; - app.common.logic.instances (cli): create, instantiate and restore a component, detach a copy; - app.common.logic.swap (clsw): swap a copy head, carry overrides across a variant switch; - app.common.logic.duplicate (cld): duplicate components and shapes; - app.common.logic.library-assets (clla): sync of library colors and typographies, which the asset multimethods delegate to. Each new namespace may require libraries, never the reverse; the helpers they share became public there (attr-operations, reposition-shape, orient-like, swap-content-root-id, ...). Callers use the new aliases. The functions move as they were. No behavior change: the golden masters and the suites are unchanged. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 37.3 / 1.06 edit-through-a-chain-of-dependents 68.9 / 69.7 / 1.01 library-update-into-copies 83.1 / 79.8 / 0.96 main-edit-into-10-copies 1.8 / 1.6 / 0.92 main-edit-into-100-copies 15.4 / 15.4 / 1.00 main-edit-into-1000-copies 153.1 / 156.0 / 1.02 variant-switch-into-copies 32.1 / 27.0 / 0.84 AI-assisted-by: claude-opus-5-5 |
||
|
|
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 |
||
|
|
45d6b112e4 |
✨ Give each style of a copy text its own override
A copy text had three override subgroups: its letters, any style, and its structure (the number of paragraphs or runs). One style override kept every other style of the main out (F72), letters and a style together kept everything out (F73), a structure change kept the main's styles out unless both texts had one style (F74), which the editors' content never had (F79), and alignment, direction and vertical alignment overrides were lost at the next sync (F76). Now a copy text has :text-content-text for its letters and line breaks and one group per style unit: family, weight (with style and variant), size, line height, spacing, decoration, case, fills, typography, alignment, direction and vertical alignment (cttx/style-units). An edit sets the groups of what it changes: a letter both texts share that changes its style, or a style that was not in the text before, so typed letters touch no style. A token in the content sets the groups of the styles it sets, and token sync of a text follows them. Sync and the keep-touched variant switch share one merge (cttx/merge-overrides): the letters of one side, and each unit from its side placed on them. A unit with one value applies everywhere; else letters both texts share (stretches of three or more) take the source's style, and the rest follow the letter before them. The switch carries an override only when the old and new mains agree on it (F77, F81), and the copy then holds the groups its result differs in. A typography reference stays only when every unit it sets comes from its side. The variant id goes with the weight; where the family comes from the other side, the frontend has to pick the family's closest variant. app.common.types.text reads a content as letters, each with its run and paragraph, and builds the content back from them; it gives the style of one unit for a letter, compares styles by what they show, and finds the letters two texts share with a letter diff (Myers). The component contract memory describes the model. Files older than this keep :text-content-attribute and :text-content-structure, read as every group they may cover until the migration recomputes them. Tests whose expected values the model changes: text-sync-test (the copy's weight, letters or new run no longer keep the main's size out; the letters read whole), text-touched-test (its edits went to the paragraph node, which shows nothing; they now edit the run), variants-switch-test (the copies with two runs keep their letters across a switch, and the size override the mains agree on), the editor output table, case Y and the notation test (new groups). The 1j pins of F72-F79 and F81 go; F79's direct test of equal-attrs? becomes a sync of editor content. A new check holds the production override rule to the 1j reference rule. New tests: the letter view, and a detached, duplicated or reset copy text keeping or clearing its override groups. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 36.6 / 1.04 edit-through-a-chain-of-dependents 68.9 / 67.2 / 0.98 library-update-into-copies 83.1 / 72.5 / 0.87 main-edit-into-10-copies 1.8 / 1.5 / 0.82 main-edit-into-100-copies 15.4 / 14.9 / 0.97 main-edit-into-1000-copies 153.1 / 153.4 / 1.00 variant-switch-into-copies 32.1 / 27.1 / 0.85 The worlds hold no text. A main text edit synced into 100 copy texts that changed their letters takes 40 ms, against 29 ms before: the merge aligns the letters of each copy with the main's. AI-assisted-by: claude-opus-5-5 |
||
|
|
7ac68a551b |
🐛 Keep library color and typography syncs from touching copies
A library color sync wrote a copy's fills through update-shapes, and a library typography sync wrote a copy text's content with a plain :set, so both marked the copy as overridden (:fill-group, or :content-group with :text-content-attribute). A later edit of that fill or text style in the main then never reached the copy. Both syncs are derived writes: they now write with ignore-touched. The typography sync also merged the whole typography into each text node that uses it, so its :modified-at (and :path) ended in the content of the main and of every copy. It now writes only the text styles of the typography. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 34.7 / 0.98 edit-through-a-chain-of-dependents 68.9 / 67.6 / 0.98 library-update-into-copies 83.1 / 76.0 / 0.91 main-edit-into-10-copies 1.8 / 1.5 / 0.86 main-edit-into-100-copies 15.4 / 15.2 / 0.99 main-edit-into-1000-copies 153.1 / 157.1 / 1.03 variant-switch-into-copies 32.1 / 27.0 / 0.84 AI-assisted-by: claude-opus-5-5 |
||
|
|
280c34302f |
🐛 Sync a reordered copy child into its main on update main
In an inverse sync, when a copy child paired with a main child further along the main's children, compare-children asked whether the copy child was the main of the main child, with the arguments of ctk/is-main-of? reversed. The answer was always no, so the pair took the swapped callback, which does nothing in an inverse sync, and only the move ran: the overrides of that copy child never reached the main. The UI does not let a copy reorder its children, so it only showed when the order differed for another reason, such as the undo of a sync reorder. The inverse branch now asks synced-pair? with the main child first, as the direct branch does. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 35.5 / 1.01 edit-through-a-chain-of-dependents 68.9 / 66.1 / 0.96 library-update-into-copies 83.1 / 77.0 / 0.93 main-edit-into-10-copies 1.8 / 1.6 / 0.92 main-edit-into-100-copies 15.4 / 16.2 / 1.05 main-edit-into-1000-copies 153.1 / 156.4 / 1.02 variant-switch-into-copies 32.1 / 28.3 / 0.88 AI-assisted-by: claude-opus-5-5 |
||
|
|
db480ed1fd |
✨ Pin that a deleted component keeps passing changes on
When a component nested in others is deleted (inner in mid in outer, mid deleted), a change to inner still reaches mid's stored shapes, mid's copies, outer and outer's copies, and restoring mid brings back an up-to-date main. Keep this behavior: outer is a live component, and its users cannot see a deleted mid between it and inner. Extend the deleted-component propagation test with a copy of the deleted component and its restore, and reword the contract, which said copies of a deleted component do not sync. AI-assisted-by: claude-opus-5-5 |
||
|
|
0c423d7490 |
🐛 Make a copy head that takes a position refer to its children
When a copy head took the place of a main shape and its component was the one of that place, it stopped being a swap, but its children kept referring to the main of its own component instead of to the children of that place. Validation did not see it, and the next sync deleted those children and added them again, losing their overrides, or the head did not follow later changes to that place. It happened in: - a variant switch that carries a swapped child into a variant whose child there has the same component; - update main of a copy that swapped a nested copy back to the component a deeper main puts in that place, after the main in between swapped it for another one; the clone in the main also kept :swap-group; - the repair of :ref-not-in-near-main, which also never cleared :swap-group. The swap in a copy, the switch, update main and the repair now share ctf/take-position: the head refers to the main shape of its position, is swapped while its component differs from that shape's, and else its descendants refer to the descendants of that shape. The switch also read the swapped child from the objects of the swap, where it was already gone with its old parent. It now reads it once it is moved into the new copy, so its placement turns its children too, and the undo takes it back to the root as the move to the root left it. The swap finds the new shapes in its own objects, not in its changes. The component memory says how a head takes a position. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 37.1 / 1.05 edit-through-a-chain-of-dependents 68.9 / 70.5 / 1.02 library-update-into-copies 83.1 / 77.3 / 0.93 main-edit-into-10-copies 1.8 / 1.7 / 0.94 main-edit-into-100-copies 15.4 / 16.0 / 1.04 main-edit-into-1000-copies 153.1 / 160.6 / 1.05 variant-switch-into-copies 32.1 / 28.7 / 0.89 AI-assisted-by: claude-opus-5-5 |
||
|
|
a7c5cd304a |
✨ Turn a swapped copy head with the child it replaces
A swapped copy head followed the position, constraints and layout item attributes of the main child it replaces, but not its rotation or flips. When the main rotated or flipped that child, the copies that kept it turned and the swapped ones did not. Sync, and a variant switch that carries a swap, now give the head and its subtree the flips and then the rotation of that child, around the head's own center, unless the user changed the head's geometry. The swap itself uses the same step. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 34.1 / 0.97 edit-through-a-chain-of-dependents 68.9 / 75.5 / 1.10 library-update-into-copies 83.1 / 81.3 / 0.98 main-edit-into-10-copies 1.8 / 1.6 / 0.88 main-edit-into-100-copies 15.4 / 15.6 / 1.01 main-edit-into-1000-copies 153.1 / 153.2 / 1.00 variant-switch-into-copies 32.1 / 31.4 / 0.98 AI-assisted-by: claude-opus-5-5 |
||
|
|
2793c0a6d4 |
🐛 Keep the rotation and flips of a swapped shape
A swap placed the new copy at the position of the shape it replaces, but not with its rotation or flips. When that shape was rotated or flipped with its parent, the new copy came out straight. The new copy and its subtree now take the flips and then the rotation of the shape it replaces, around their own center, as geometry that comes from the parent, not as an override. A variant switch goes through the same swap. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 38.3 / 1.09 edit-through-a-chain-of-dependents 68.9 / 70.7 / 1.03 library-update-into-copies 83.1 / 82.2 / 0.99 main-edit-into-10-copies 1.8 / 1.7 / 0.95 main-edit-into-100-copies 15.4 / 16.7 / 1.08 main-edit-into-1000-copies 153.1 / 167.4 / 1.09 variant-switch-into-copies 32.1 / 29.6 / 0.92 AI-assisted-by: claude-opus-5-5 |
||
|
|
1ed5700cf5 |
🐛 Keep a swapped child when a variant switch is undone
A variant switch with keep-touched moves a swapped child out of the old copy to the page root, then into the new copy, which takes the same id as the old one. The undo of the second move took the child back to that id, so it landed in the new copy and went away with it when the undo deleted the new copy. The undo now takes it back to the root, and from there into the old copy. AI-assisted-by: claude-opus-5-5 |
||
|
|
dd4973851f |
✨ Place a swapped child where the new variant has it
A variant switch with keep-touched carries a swapped child into the new variant, but left it where it was in the old one. The next sync then moved it to the place of the child it replaces, at a moment unrelated to the switch. The switch now gives it that placement at once, through the same step sync uses: position, constraints and layout item attributes the user did not change. Benchmark, against the same code without this change under the same load (lower quantile, ms; without / with): deepest-edit-through-three-levels 36.0 / 36.0 edit-through-a-chain-of-dependents 65.2 / 70.2 library-update-into-copies 74.3 / 75.9 main-edit-into-10-copies 1.6 / 1.5 main-edit-into-100-copies 16.3 / 15.7 main-edit-into-1000-copies 162.6 / 165.8 variant-switch-into-copies 27.2 / 27.8 AI-assisted-by: claude-opus-5-5 |
||
|
|
217d87a438 |
✨ Migrate swap slots to override groups and remove them
The migration 0033 stores every swap of a file as an override group. A swap made in a copy is paired with the child of the near main that holds the same position, as sync paired it; any other slot is dropped. All pairings are found on the data as it was, so swaps nested in swaps pair against the content of the outer one, and libraries pair the same whether they are migrated or not. A head that refers to its own slot only inherited it, and drops it even when its near main cannot be reached. Any other slot that matches no child, or whose near main is in a library that is not loaded, keeps the slot as its ref, so validation reports it. An import, which migrates its files before it links their libraries, leaves those slots and converts them once the libraries are linked. A migrated head that sits elsewhere than the child it replaces would place it gets its geometry marked as touched, so the first sync does not move it. Migrated and new files carry the feature components/swap-group, so a client that still writes slots cannot open them. Validation checks the new form: :misplaced-slot reports any slot, :misplaced-swap-group the group anywhere but on a nested copy head in a copy, :ref-not-in-near-main a nested copy head whose ref is not a child of the near main of its parent, and :duplicate-ref two children of a copy that refer to the same main shape. Each has a repair. Nothing but the migrations reads slots any more: the slot branches go from sync, swap, switch, reset, detach, paste, duplicate and move. The helpers that read and write a slot on a shape move to files/swap-slots, and the ones that follow refs to files/comp-processors. The graph projection drops the FillsSwapSlot edge, the components debugger shows the component a swapped head takes its content from, and the plugin API documents componentRefShape for a swapped copy. The swap tests are named after what they check. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 34.6 / 0.98 edit-through-a-chain-of-dependents 68.9 / 63.5 / 0.92 library-update-into-copies 83.1 / 76.4 / 0.92 main-edit-into-10-copies 1.8 / 1.6 / 0.87 main-edit-into-100-copies 15.4 / 15.1 / 0.98 main-edit-into-1000-copies 153.1 / 148.8 / 0.97 variant-switch-into-copies 32.1 / 26.4 / 0.82 AI-assisted-by: claude-opus-5-5 |
||
|
|
7d5b37dbdd |
✨ Store a swap inside a copy as an override group
A swapped copy sub-head now keeps the shape-ref of the main child it replaces (its position), takes its content from its own component, and carries the touched group :swap-group. It used to point at the root of the new component and store a swap slot naming the replaced child. Every path reads and writes that form: - sync pairs a swapped head with the child it replaces and syncs it from its own component; it takes from the replaced child only its placement: position, constraints and layout item attributes; - reset undoes a swap in place, keeping the head's id and the other overrides of the copy; - detach, paste and duplicate make a head that leaves every copy level a plain copy of its own component, and advance a deeper one by position; the children of a swapped head never advance; - a variant switch keeps a swap stored this way, and counts only the copy's own swap, not one made in a main of the chain; - update main replaces the main child with a clone of the swapped one, and from a swapped head updates its own component; - a swap inside a copy keeps the old shape-ref and adds :swap-group; swapping back to the component of that position gives a plain nested copy whose children refer to the position's children; a swap inside a main is a plain nested copy. Files that hold swap slots keep working until a migration converts them. The component memories describe the model. This fixes what came from the slot: reset of a swapped child cleared the other overrides of the copy (F12) and gave copies of a main new ids (F66); update main left a swap mark (F16); a second variant switch lost the overrides (F29); grid cells did not pair a swapped child (F44); a swapped child did not follow its position (F65). F49 keeps failing, in another form. The tests that asserted slots now assert the override group; the tests of the slot code build the old form with :as-slot?. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 35.4 / 1.01 edit-through-a-chain-of-dependents 68.9 / 68.7 / 1.00 library-update-into-copies 83.1 / 77.5 / 0.93 main-edit-into-10-copies 1.8 / 1.6 / 0.92 main-edit-into-100-copies 15.4 / 15.4 / 1.00 main-edit-into-1000-copies 153.1 / 151.1 / 0.99 variant-switch-into-copies 32.1 / 32.4 / 1.01 AI-assisted-by: claude-opus-5-5 |
||
|
|
0959e5b44b |
🔥 Remove the components v1 leftovers
No components v1 file reaches the code any more: opening one fails the feature check, and imports reject it or give it the feature. What only served v1 goes: - validate-file and validate-file-affected no longer skip a file without components/v2; - the root of a deleted component is found by its main instance id only, in the type helper and the component sprite; - generate-sync-library, which synced shapes inside live components, and the live components in containers-seq, so asset usages walk the pages only (deleted components keep their objects and still sync); - the unused :components-v2 file option, from the schema, the file data and the test fixtures; stored files that carry it still validate; - the team migration-in-progress check and its frontend alert, which leaves ephimeral features out of the team comparison; - the components v2 introduction dialog, its styles and icons, and the :v2-info-shown profile prop; - the graphic asset events and builders and the reads of graphics in the selection (the :add-media, :mod-media and :del-media changes stay, so stored changes still apply), and the uncalled render-page and render-components helpers. AI-assisted-by: claude-opus-5-5 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
b0b2044b57 |
✨ Test component validation, repair and library asset sync
Fill the unit test gaps in common and frontend component code: - One test per component error code: break a file in one way, check the codes validation reports, repair it as the backend does (until valid, at most ten passes) and check what the repair changed. Pinned: F46 (a dangling ref in a copy child is never reported), F47 and F48 (two repairs that never converge). :component-not-main, :should-be-component-root and :not-component-not-allowed have no test, as no file can produce them. - Library color and typography sync must not touch copies. Both do (F3): the color sync touches :fill-group, the typography sync :content-group and :text-content-attribute, and a later main edit of that attribute then no longer reaches the copy. Pinned. - Twenty live thf/dump-file calls in common tests go; they printed whole file trees on every run and asserted nothing. - The disabled copy-nested-3 frontend test runs again: a stray backquote quoted its run-store call, so it never called done. |
||
|
|
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). |
||
|
|
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). |
||
|
|
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. |
||
|
|
bb5aeaa7a0 |
♻️ Move variant switch and swap setup to common
The variant switch event picked its target variant, and the component swap event worked out where the new head goes, inside the frontend events. Code that runs without the frontend store, such as the pure composable runner, could not do the same switch or swap. The swap and the switch also checked for nesting loops with two different functions. Move them to app.common.logic.variants: - find-switch-target picks the nearest variant with the requested value. - generate-component-swap-in-place builds the swap changes keeping the old head's index, grid cell and swap-keep-attrs, plus the keep-touched step when asked. - swap-nesting-loop? checks that a swap or switch does not nest a component inside itself. The swap menu, the plugin swapComponent and the variants-switch event all use it. The frontend events behave as before, with two exceptions. Switching to a value no other variant has does nothing, where it threw in min-key. A swap also updates the layout of the shapes the keep-touched step adds. |
||
|
|
31a079a7a2
|
🐛 Allow creating tokens when the file is its own tokens source (#12182)
Setting the local file back as the tokens source, or unlinking the library that was the source, stored the file's own id in :tokens-source. ensure-tokens-lib only created a tokens-lib when :tokens-source was nil, so a file without a tokens-lib never got one, and every token change was dropped without any error. Now ensure-tokens-lib also creates the tokens-lib when the source is the file itself, which fixes files already in that state, and set-tokens-source removes the attribute instead of storing the file's own id. Closes #12180 AI-assisted-by: claude-opus-5-5 |
||
|
|
331e9168b5
|
🐛 Keep interactions of flex children when syncing components (#11788)
* 🐛 Keep interactions of flex children when syncing components The flex-child sync between main and copy children propagated every attribute in swap-keep-attrs, which includes :interactions. Since :interactions has no sync group, the touched check never protected it, so any change that reflowed a flex component (like toggling a child's visibility) overwrote the copy children's interactions with the main's. Closes #11746 AI-assisted-by: claude-opus-5 Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> * 🐛 Add another test and simplify comments --------- Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> |
||
|
|
98daf1592d
|
✨ Apply tokens to stroke per side (#10913)
* ♻️ Derive per-side stroke widths from the side values The per-stroke `:stroke-per-side` boolean only gated whether the renderer looked at the four side widths, and the CSS generator used it to decide whether to emit one `border-width` or four. Comparing the sides is enough, so drop the attribute from the shape schema and from the stroke attribute list. The WASM property and upload bridges and `stroke-per-side-widths` now derive the per-side widths from the values alone. AI-assisted-by: deepseek-v4.1-flash * ✨ Declare per-side stroke width token attributes Replace the single `:stroke-width` token attribute with `:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom` and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map the new attributes to the strokes shape attribute and to the dimensions token type. AI-assisted-by: deepseek-v4.1-flash * 🐛 Unapply only the token of the changed stroke side A stroke change that reports a single per-side sub-attribute now resolves to that side's token only. A plain `:stroke-width` change still resolves to every side, and a change with no sub-attribute resolves to all width keys plus the color. Add a regression test that tokens on untouched sides survive a change to another side. AI-assisted-by: deepseek-v4.1-flash * ✨ Add a predicate for per-side stroke shapes Boards and rectangles support independent stroke widths per side. Expose `per-side-stroke-shape?` so callers can gate the per-side UI, and cover the supported and unsupported shape types with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add stroke side width materialization helper `materialize-stroke-side-widths` concretizes the four per-side width keys from a stroke: edited sides take the new value, the others keep their current width (0 when there is no stroke), and `:stroke-width` mirrors the top side for legacy consumers. This pulls the logic already duplicated in the token apply path into a shared helper, ready for the direct-edit path. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to every side `update-stroke-width` now writes the four per-side width keys, both when the shape already has a stroke and when it gets a new default one, so the applied-token bookkeeping matches the stroke attributes. AI-assisted-by: deepseek-v4.1-flash * ✨ Apply a stroke width token to a single side Add `update-stroke-width-side`, which changes only the sides named in `attributes` on the first stroke of each shape. The remaining sides keep their current width (0 when the shape had no stroke) and all side keys are materialized through the shared helper, so consumers never fall back to `:stroke-width`. Route the per-side token keys to the new function and update the apply, remap and component tests. AI-assisted-by: deepseek-v4.1-flash * ✨ Complete a partially applied per-side token on toggle When explicit attributes come from an input or a plugin call, toggle the token off only if it already covers every target attribute on every selected shape. A partial per-side application is completed instead of removed. The token pill keeps the previous any-attribute behavior. Add tests for both the completion and the full removal. AI-assisted-by: deepseek-v4.1-flash * ✨ Offer per-side stroke width actions in the token menu Boards and rectangles get a stroke width submenu with an all-sides action and one action per side; other shapes keep the single global action. The global action targets every per-side attribute so the design tab keeps showing the token on each side. Add the pill labels for the new attributes, the menu test, and the `workspace.tokens.stroke-width` string. AI-assisted-by: deepseek-v4.1-flash * ✨ Persist the per-side stroke preference Add `:stroke-per-side` to the user profile props schema and expose it through a derived `stroke-per-side` ref. The design tab will read the preference from here instead of a per-stroke attribute. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width helpers to the stroke menu Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which checks the feature flag and that a single board or rectangle, or a uniform multi-selection of them, is selected. Use it instead of the inline shape-type check and drop the old per-side toggle handler. Cover both helpers with a test. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke width token inputs to the design tab Turn the four side width fields into token inputs with detach actions and a `:multiple` mixed value when the sides differ. The per-side toggle now reads the persisted profile preference instead of a per-stroke attribute, so previous per-side edits survive. AI-assisted-by: deepseek-v4.1-flash * 🐛 Materialize stroke sides on direct width edit The stroke menu per-side handler only wrote the edited side key and, for the top side, the global `:stroke-width`. A stroke holding just `:stroke-width` made every consumer fall back to the global value, so editing one side changed all the others. Add a `change-stroke-side-width` event that materializes the four side keys through the shared helper and reports only the edited key as changed, so tokens on untouched sides are not unapplied. Route the menu handler through it. AI-assisted-by: deepseek-v4.1-flash * 🐛 Make stroke width fields non nullable Drop `:nillable` from the global and per-side stroke width inputs and use `:multiple` for the mixed state, so an empty field no longer represents a null width. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix the numeric-input props schema key The schema declared `:applied-token`, but the component body and every caller use `:applied-token-name`, so the prop was never validated. Rename the schema entry to match. AI-assisted-by: deepseek-v4.1-flash * ✨ Add token-disabled support to the numeric input The design-system numeric input accepts `:token-disabled` and `:token-tooltip`; the token button is disabled and shows the reason. Scope the disabled input style to `input:disabled` so a disabled token button no longer dims the whole field. Pass both props through the token wrapper. AI-assisted-by: deepseek-v4.1-flash * 🐛 Disable token controls below the first fill or stroke Design tokens only apply to the first fill or stroke of a shape. Add `tokens-allowed-position?` and mark the fill and stroke lists with `tokens-first-only`, so later entries disable their token controls and explain why. The colorpicker opens on the direct color tab and disables the token tab for those rows. Cover the helper with a test and add the new translation. AI-assisted-by: deepseek-v4.1-flash * ♻️ Refactor colorpicker style switcher to DS radio buttons Replace the legacy `components/radio-buttons` markup in the colorpicker with the design system `radio-buttons*`, using its declarative options API. Switching between direct color and token mode now passes string values, as the DS component expects. The previous keyword values broke the round trip back to color mode: the DOM stringifies keywords with a leading colon, so the value never matched `:direct-color`. Using plain strings keeps the conversion clean. AI-assisted-by: deepseek-v4.1-flash * ✨ Add playwright tests * ✨ Scope per-side stroke controls to each stroke Give every stroke row its own expanded state instead of sharing one profile-wide preference. The state lives in `:workspace-local`, keyed by `[ids index]`, so it survives selecting another shape and coming back but resets on reload. Remove the `:stroke-per-side` profile prop and its ref. The ref now derives from `:workspace-local`. Update the Playwright spec to expand the controls per stroke through the toggle, and assert that strokes toggle independently, that the state resets on reload, and that it survives switching shapes. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep stroke tokens when editing or removing later strokes The token unapply logic decided which tokens to clear from the shape using only the changed sub-attributes, without knowing which stroke was edited. Since stroke tokens only live on the first stroke, editing or removing a later stroke cleared the first stroke's tokens. Add a `:changed-item-index` option to `generate-update-shapes` and skip unapplying fill/stroke tokens when the changed item is not the first. The stroke color, attrs, side-width and remove events now report the index they touch. AI-assisted-by: deepseek-v4.1-flash * 🐛 Ignore token shortcuts when tokens are disabled for input The numeric input opened the token dropdown on `{` regardless of `token-disabled?`, so inputs that cannot hold tokens (for example, strokes after the first one) still opened it, and typing `{token}` plus `}` could apply a token there. Extract the key handling into `token-shortcut`, which returns nil when tokens are disabled, and use it for both `{` and `}`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Gate per-side stroke tokens on the WASM renderer The token context menu offered per-side stroke width actions whenever the feature flag was on and the shape was a board or rectangle, without checking the renderer. The classic renderer only draws the single `:stroke-width`, so applying a per-side token there wrote inert data, the token pill reported it, and the stroke changed appearance when the WASM renderer was later enabled. Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from both the design tab and the token context menu. Thread the renderer flag into the context menu through `:render-wasm`. AI-assisted-by: deepseek-v4.1-flash * 🐛 Keep first-stroke tokens when reordering later strokes Fill and stroke tokens only ever live on the first item of the collection. When a stroke update arrives without a changed item index (for example reordering the second and third strokes), the unapply logic assumed the first item had been edited and removed every stroke token from the shape. Compare the first item before and after the update instead: when no item index is given, unapply only if the first item actually changed. Reordering later strokes now leaves the first stroke and its tokens untouched, while moving the first stroke away still detaches them. Explicit item edits keep their previous behavior. AI-assisted-by: deepseek-v4-flash |
||
|
|
b3c1aab720 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
45b8320ac7
|
🐛 Fix error when reset overrides (#11604)
* 🐛 Fix error when reset overrides on a swapped copy * 🐛 Add regression test for reset overrides inside group Cover the case where a nested copy lives inside a group (not directly under the instance root). After a swap, reset overrides must undo the swap without error (#11656). --------- Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com> |
||
|
|
3cd9bfa9de
|
✨ Add sync with design tokens in external libraries (#10293)
* ✨ Auto link tokens when adding external libraries (provisional) * 🔧 Refactor tokens-lib initialization * 🔧 Add separated TokenStatus to store status apart of TokensLib * 🔧 Make all status operations use the new data structure * 🔧 Normalize status helper functions and access token sets by id * 🔧 Rename :tokens-file to :tokens-source * 🎉 Allow the user to choose the tokens-source of a file * 🎉 Make tokens library readonly when it's in an external file * 🎉 Show tokens in library summaries * 🎉 Show source info in sidebar * 🔧 Fix integration tests * 🐛 Propagate changes of token values in external library * 🎉 Layout updates * 🔧 Refactor tokens source calculations * 🔧 Add harder checks for nil or empty values in everything * 🐛 Fix some integration tests * 🔧 Add integration tests for tokens in external libs * 🔧 Validate and repair missing tokens status * 🎉 Make ui changes optional with config flag * 🐛 Propagate tokens after synchronizing components in ext library * 🐛 Propagate tokens after creating new instances * 🐛 Propagate tokens after synchronizing tokens in ext library * 🐛 Add a tokens source icon to libraries section (#11439) * 🐛 Add a tokens source icon to libraries section * 🐛 Fix ellipsis on library names * ♻️ Remove code under flag on legacy component * 🐛 Fix token theme name on inspect tab * 🎉 Add changes notification (#11476) * 🎉 Add changes notification * ♻️ Change fn names * 🐛 Fix tokens source label truncation and missing translations (#11533) * 🐛 Fix tokens source label truncation and missing translations The tokens source file name always showed, even for the current file, and long names wrapped onto a second line instead of truncating because the header used flex-wrap and overflow-wrap: break-word instead of single-line ellipsis. Show the source row unconditionally (it now displays "This file" when the source is the current file, matching the connected-library case), truncate the file name to one line with an ellipsis, and only attach a tooltip with the full name when the text is actually truncated. Replace the hardcoded UI strings with translated ones and add their English and Spanish entries. AI-assisted-by: claude-sonnet-5 * 🐛 Remove redundant effect dependency in tokens source file-name-truncated? was listed as a dependency of the with-effect that checks and observes label truncation, even though it isn't read inside the effect body. Since the effect itself flips that state via check-file-name-truncated, including it as a dependency caused the ResizeObserver to be needlessly disconnected and reconnected on every truncation change. AI-assisted-by: claude-sonnet-5 * 🐛 Fix small visual error * 🐛 Fix problem with plugins * 🐛 Fix playwright tests --------- Co-authored-by: Eva Marco <evamarcod@gmail.com> Co-authored-by: Eva Marco <eva.marco@kaleidos.net> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> |
||
|
|
81c3b3cd56
|
📎 Update copyright name on file header (#11346) | ||
|
|
b9c92496f1
|
🐛 Fix overrides lost after switch (#10619) |