Cover the callers of the variant generators that had no tests with the
new validation: restoring a deleted variant into its container, restoring
it after its container was deleted, and adding a property and then
setting its value, as the workspace properties panel does. Each case
runs with valid data and expects no validation error on JS.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When generate-relocate moves a variant inside a component, the variant
is no longer a root, so relocate left it out and validated nothing in
its place. Validate the root of the enclosing component instead. The
workspace does not allow this move, but relocate can be called
directly.
Document that the default call of generate-make-shapes-no-variant
requires the shapes to be outside their variant container.
Add tests for moving a variant into another container, and into a
component with a corrupted root.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Validating a variant container already checks each of its main
instances, so listing the mains next to it repeated the work. Each id
costs a pass over the whole page, so a variant with N components cost
N+1 passes per call, and each error was reported twice.
The generators in variant_properties now validate only the container.
generate-update-property-value and generate-set-variant-error fall back
to the main instance when the component is not a variant. Before, they
emitted a nil id, which fails the :validate-shapes schema.
Add a test that generate-set-variant-error on a component that is not a
variant produces valid changes.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
duplicate-variant calls generate-make-shapes-variant before it moves the
new shape into the target container. When a variant is pasted into a
different variant container, the new shape already has the target
container's data but still sits in the old one, so the validation in
generate-make-shapes-variant reported errors on valid data.
Pass :skip-validation? to generate-make-shapes-variant, and validate the
target container once the shape is inside it. That also covers the
pasted shape, which the closing validation of
generate-duplicate-changes missed.
Add tests for pasting a variant into another container and for
duplicating it inside the same container.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-make-shapes-variant now validates the new variants and their
variant container, and accepts a :skip-validation? option.
generate-relocate calls it before the shapes are inside the container,
so it passes :skip-validation?. Relocate already validates the new
container once the shapes are moved.
Add tests that corrupt the new variant or the container and expect a
validation error, and a test for :skip-validation?.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-make-shapes-no-variant now validates the shapes and their old
variant containers, and accepts a :skip-validation? option.
generate-relocate calls it before moving the shapes, while they are
still inside the container. Validating the container at that point
reported :main-instance-not-a-variant on valid data. Relocate now passes
:skip-validation? and validates at the end: the old and new variant
containers, and the moved variants when they end up outside any
component. Inside a component they are no longer roots, and the
validator can't check them on their own.
Add tests for the function and for moving a variant out of its
container, with valid and corrupted data.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-add-new-property now validates the main instances and the
variant container. Add a :skip-validation? option to it.
generate-make-shapes-variant calls it several times before the new
shapes become variants. Validating the container at that point reported
:main-instance-not-a-variant on valid data, so it now passes
:skip-validation? and will validate once the shapes are variants.
Add tests that corrupt a main instance or the container and expect a
validation error. Add a test that moves into a variant a component whose
name needs more properties, which runs that intermediate path. Register
variants-test in the runner so that it also runs on JS.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-reorder-variant-poperties now validates the main instances and
the variant container, but only when the reorder changed something, so
a no-op still returns the same changes. Add tests that corrupt a main
instance or the container and expect a validation error.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-set-variant-error now validates the main instance of the
component and its variant container. The validator has no rule for
:variant-error itself, so the new tests corrupt the main instance or the
container and expect a validation error.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-update-property-value now validates the main instance of the
component and its variant container once the new value is set. Add tests
that corrupt the main instance or the container and expect a
validation error.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apply-changes-local replays the pending redo changes on a local copy of
the data, so that later builder calls can read up-to-date values. It
replayed :validate-shapes too, so the validator ran on that copy, which
has no real file id and no libraries. Any chain of two generators that
apply local changes then failed with false errors, such as
:component-not-found. Skip :validate-shapes when applying changes
locally.
Add the variant container to the shapes that
generate-update-property-name and generate-remove-property validate.
Validating only the main instances misses errors that only the container
check finds, such as siblings with different property names.
Register variant-properties-test in the runner so that it runs on JS,
where validation takes place. Add tests that corrupt a main instance or
the container and expect a validation error. Remove two debug prn calls
from the validator.
AI-assisted-by: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ✨ Show color tokens toggle on canvas background picker
* 🐛 Apply token click on canvas background with no shape selected
* ✨ Apply and persist color tokens on canvas background
* 🌐 Translate canvas background section label
* 🐛 Fix shadows derived from imported SVG filters
* 🐛 Fix inherited group fills in exports and group drop shadows
* 🔧 Run exporter tests on render-wasm changes
* ♻️ 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
Before background blur got its own shape attribute, the `:blur` attribute
accepted both `:layer-blur` and `:background-blur` types, so the editor and
the plugin API could save a background blur under `:blur`. The shape schema
was later tightened to only allow `:layer-blur` on `:blur`, but no migration
moved the existing values, so those files fail server schema validation.
Add migration 0029: when a shape has a `:blur` map with `:type
:background-blur`, move it to the `:background-blur` attribute. When the
shape already has a `:background-blur`, keep it and drop the mis-typed
`:blur`. The migration walks both pages and components.
Closes#11904
AI-assisted-by: deepseek-v4.1-flash
Introduce a shared xf:add-index transducer in app.common.data that
attaches the position to each item, and cover it with unit tests.
Use it in the workspace interactions menu: the indexed interactions
list is now derived in a memoized step keyed on the interactions
prop, so it is not rebuilt when the section is collapsed or
expanded. The previous code called d/enumerate on every render.
Update the frontend UI conventions memory with the pattern and the
constraint that the transducer only works on associative items.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 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>
* ✨ Restrict optional RPC ids to user-provided UUIDs
Add ::sm/user-provided-uuid, backed by a version and variant
aware regex that only accepts v4, v7 and v8 instances. Use it
for the optional :id of the creation RPC commands so reserved
versions such as v3 are rejected at validation time. Reads
such as get-team keep the lax ::sm/uuid. Cover the predicate
and the schema on both JVM and JS runtimes.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Cover id version restriction at the RPC boundary
Add backend regression tests proving the seven creation commands
reject reserved-version ids (v3) with :params-validation and
accept v4 ids (plus v7/v8 on create-team) through the real
decode and validate path. Also drop two duplicated assertions
and document the version and variant of every fixture UUID in
user-provided-test.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ 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>
* 🐛 Reflow auto grid cells on flow direction change
Remap only single-span auto cells to the new
:layout-grid-dir traversal order, keeping source
order, manual and area placements untouched.
Update both grid direction controls to use the
new change-grid-direction event and refresh the
stale active button on persisted direction.
Add a RED-to-GREEN model regression covering a
2x2 row-to-column transition and source-order.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Keep source order on grid flow change with areas
Skip the generic grid cell pass for the
direction event, since reflowing already
places every eligible auto item and a blind
reorder rewrites shapes around pinned areas.
Pin area/span grids with a regression test
covering direction change and source order.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Scope grid skip to direction changes only
Replace the translation flag with a narrow
skip-grid-reassignment option so component
sync and reflow metadata stay intact while
the generic grid cell pass is skipped.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Clear leftover auto cells on grid flow change
Write remapped shapes to every target auto cell and
empty leftover cells so sparse grids cannot duplicate
a child across target cells. Manual, area and
spanned cells stay untouched; source order is kept.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Pin grid flow invariants with span and manual regressions
Keep the direction-change design unchanged and lock the
claimed invariants with tests: a real 2x1 manual span
cell and an occupied manual cell stay byte-identical,
row->column->row round-trips to the original cells,
and a mixed auto/manual/span/area grid shows no shape
loss or duplication. Also drop the unused page-objects
binding from the direction-change watcher.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Unoverlap mixed grid fixture and assert movement
Move auto C to (1,3) so it no longer overlaps the 2x1
manual span at (1,2). Row auto order A,C,B,E becomes
column order A,B,E,C; assert the exact placement
while keeping pinned, source-order and no-loss
checks. Test-only change.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Unify plugin dir setter and normalize missing direction
Route GridLayoutProxy.dir through change-grid-direction so the
plugin API shares the UI direction-change path with its reflow
and source-order guarantees. Normalize a missing
:layout-grid-dir to :row at the change-grid-direction entry
point and cover it with a missing-direction regression plus a
plugin setter routing regression.
AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Fix grid plugin dir setter syntax
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
AI-assisted-by: opencode-go/muse-spark-1.3-contributor
* 🐛 Fix comments and tests
---------
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ✨ Add expires-in option to create-demo-profile
Allow passing an optional expires-in duration when creating a demo profile so its purge is scheduled sooner than the global deletion delay. Values below 5 minutes or above the global delay are rejected with an invalid-expires-in validation error, resolved before any profile is created.
Closes#11573
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Make duration schema decoding total instead of throwing
parse-duration returned by the duration schema decoder threw DateTimeParseException on invalid strings, escaping params validation as a raw error. It now returns the input unchanged so invalid values fail the duration predicate with a clean params-validation error. Closes#11573 AI-assisted-by: muse-spark-1.3-contributor
* 📎 Fix doc version for expires-in change entry
The expires-in change entry was documented under 2.20 but the current version is 2.18.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix nested board drop target ignoring ancestor clip bounds
Frame hit-testing (get-frame-by-position, get-frames-by-position and
top-nested-frame) only checked a candidate board's own rectangle,
without accounting for an ancestor board with clip content enabled.
A nested board wider/taller than its clipping ancestor could still be
picked as the drop target in its invisible, clipped-away area, so a
dragged shape would get reparented there and disappear from view.
Add clipped-by-ancestor? to reject a point when it falls outside the
bounds of any ancestor board that has clip content enabled, so the
lookup now stops at the correct visible ancestor instead of
descending into the hidden region.
* 🐛 Fix Ctrl+click deep-select reaching into clipped board area
The clip-aware quadtree query (query-index) filters candidate shapes
by whether they overlap every clip-parent ancestor, but the whole
filter was skipped whenever clip-children? was false. That flag is
turned off while a modifier key (Ctrl/Cmd) is held for deep/penetrate
selection, which was meant to let it reach past boolean/mask clip
boundaries, but it also disabled enforcement for board "Clip content"
ancestors, letting a modifier-held click select a shape sitting in a
board's invisible, clipped-away region.
overlaps-parent? now only relaxes the check for non-frame clip-parents
(bool shapes / mask children) when clip-children? is false; board clip
ancestors are always enforced regardless of the modifier key.
* 🎉 Implement independent image bounds resizing
Add canvas resize interaction mode that allows users to resize an
image object's bounding box independently from the underlying bitmap
content without scaling or distortion while holding the Mod key.
AI-assisted-by: gemini-2.5-pro
* ♻️ Address reviewer feedback from elenatorro
- Remove legacy cfh/image-shape? check in shape-has-image-fill?
- Guard bounds-resize with positive dimensions instead of clamping scalev to preserve flipping
- Remove :metadata from transform-attrs in modifiers.cljs
- Restore preserveAspectRatio logic based on keep-ar? in fills.cljs
- Compute source rect against destination rect for raster and SVG fills in WASM renderer
* 🔧 Fix clippy needless borrow warnings in wasm image fills
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Improve path operations and edition
* 🐛 Fix floating-point equality issues in path editing
Replace exact equality checks with tolerance-based comparisons
in path editing functions to handle floating-point rounding
differences after transforms, rotations, or curve fitting.
Changes:
- distribute-content: Round coordinates to 0.1 precision before
grouping to ensure coincident nodes move together
- separate-node: Use gpt/close? instead of exact equality to
find nodes with floating-point imprecision
- collision-step: Use mth/close? for tolerance-based comparison
to detect paste collisions correctly
- resolve-edit-fills: Add cycle detection to prevent infinite
loops with corrupted parent chains
Made collision-step, available-offset-step, and resolve-edit-fills
public for better testability.
Added comprehensive tests for all fixes covering both exact and
floating-point coordinate scenarios.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix path editor code review findings
Fix issues identified during code review of path editor enhancements:
- Fix unused binding lint warning in distribute-content that blocked CI
- Fix collision-step floor comparison to use round instead of floor,
correctly detecting collisions when coordinates drift slightly below
integer boundaries
- Fix resolve-edit-fills to recurse through empty parent groups when
searching for inherited fills in nested group hierarchies
- Fix expand-coincident-node-indices to use fuzzy comparison (gpt/close?)
instead of exact equality, handling floating-point divergence after
transforms or rotations
- Remove unreachable dead code in path-point* on-pointer-down handler
- Add tests for collision-step boundary cases, nested group fill
inheritance, and coincident node alignment/flipping
AI-assisted-by: mimo-v2.5-pro
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix svg-raw child ids stored as strings in existing files
Before schema:svg-raw-attrs declared :shapes as a vector of uuid, the
JSON decoder had no type information for those child ids and left them
as plain strings on any round trip, so they got persisted as strings.
Once the schema was tightened those files fail schema validation on
load.
Add migration 0026-fix-svg-raw-shapes-uuids that coerces the :shapes
entries of svg-raw shapes back to uuid instances, in both pages and
components.
AI-assisted-by: deepseek-v4-flash
* ♻️ Drop redundant vector check in svg-raw shapes migration
:shapes is always a vector when present, so the guard is unnecessary.
AI-assisted-by: deepseek-v4-flash
Editor/WASM round-trips can truncate line-height strings
(e.g. 1.3333333333333333 → 1.33333). Exact string compare
treated that as a style change and detached typography tokens.
Add escape-markdown to common/data.cljc that escapes Markdown
special characters (*, _, ~, `, [, ], >, #, @, etc.) by prefixing
them with backslash. Apply it to user-controlled fields (:hint,
:href) in the Mattermost error reporter before constructing the
notification message.
This is an internal-only feature not accessible to end users.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Declare the shape attributes stored files carry
`schema:shape-attrs` is the shape model as *declared*, and it has fallen
behind the `Shape` record. Three record fields are absent from it:
`rotation`, `flip-x` and `flip-y` are therefore present on every shape
that exists and declared nowhere. `rotation` is already named twice in
this namespace, in `allowed-shape-attrs`, and once in
`app.common.types.shape.attrs/editable-attrs`, so the schema is
demonstrably the odd one out rather than the data being unusual.
Nothing complains, because the maps are open: an undeclared key
validates fine. What breaks is everything that reads the model *from the
schema* rather than from a live value, such as the generative tests'
shape generator, the generated OpenAPI surface, and any consumer
reflecting over `schema:shape-attrs`.
Whether an entry is optional, nilable, or both is decided by the record
rather than by taste. `app.common.record/defrecord` cannot remove a base
field: its `without` assocs nil and its `containsKey` answers true
whatever the field holds, on both platforms. So a `Shape` base field is
always present, and nil is how that field says "unset". Every other key
lives in the `$extmap`, disappears on dissoc, and is dropped by
`setup-shape` when a caller passes nil. Base fields are therefore
nilable, and the rest are optional.
Declared here, measured over a 305-shape corpus:
- `rotation`, `flip-x` and `flip-y`, record fields present on every
shape, nilable for the reason above: `make-minimal-shape` gives the
two flip fields no default, so they are nil on all 305. Optional as
well, unlike the geometry below, because `schema:shape-generic-attrs`
has a second job: `check-shape-generic-attrs` validates partial update
payloads with it, such as the `{:blocked true}` that
`app.main.data.workspace/update-shape` passes, and a required key here
would reject every such payload.
- `hide-in-viewer`, moved out of `schema:frame-attrs`, because circles,
rects and texts carry it too, 197 shapes.
- `svg-attrs`, `svg-defs`, `svg-transform` and `svg-viewbox`, the SVG
provenance an import leaves behind, 101 shapes and 63 for the
transform. Typed `:map` rather than more precisely on purpose: legacy
files hold `svg-transform` as a plain `{:a … :f}` map rather than a
`::gmt/matrix` record, and `svg-viewbox` as either a `::grc/rect`
record or a plain map, so a tighter schema would reject files that are
otherwise valid.
- `use-for-thumbnail` on frames. The model has long had it:
`app.common.files.migrations` renames `:use-for-thumbnail?` to it and
`app.common.logic.libraries` reads it. This schema had not declared
it.
- `rx` and `ry` on rects and circles, the legacy radii SVG import parses
off the element and migration 0003 assocs as `0`. Superseded by `r1`
to `r4`, but stored files carry them.
- `content` on svg-raw. `shapes-builder/create-raw-svg` sets it and
`allowed-svg-attrs` names it. Typed `[:or :map :string]`, because a
bare text node arrives as the string itself: `<text>hi</text>` becomes
one svg-raw for the element and another for `"hi"`, and
`shapes-builder/parse-svg-element` carries a FIXME about exactly that.
`schema:nilable-geom-attrs` is new, for bool and path. Those two are the
only shape types whose geometry can be nil: `make-minimal-shape` gives
`x`, `y`, `width` and `height` a default for every other type and skips
those two, whose extent their content and `selrect` imply instead. The
four keys stay required, as they already are in the other seven
branches, and only the nil is new.
**Do not make the analogous change to `ctf/schema:file`.** That map
carries `:backend`, `:comment-thread-seqn` and `:ignore-sync-until`,
none of which the schema declares, and declaring them breaks saving:
`app.binfile.common/update-file!` derives its UPDATE column list from a
file map's keys, and the `file` table has no `backend` column, it being
synthesized on read. Measured at 185 failures, mostly `rpc-file-test`.
Whether a schema serving as both read description and write contract is
itself a defect is a real design question, and a separate one. The
`check-shape-generic-attrs` case above is a second instance of it.
Adding entries changes what `shape-generator` produces, so generative
tests begin exercising code paths with these attributes present. That is
where a problem would surface. With this applied the common suite is
1142 tests and 24702 assertions on the Clojure side, 992 tests and 24017
assertions on the ClojureScript side, no failures on either.
AI-assisted-by: mixed models
* ✨ Align shape generator with declared schema and add key-presence test
shape-generator now selects geometry attrs per-type: nilable-geom-attrs
for bool/path, shape-geom-attrs for everything else, and always merges
them. This removes the dead attrs2 generation for bool/path and the
implicit dependency on create-shape adding nil defaults for missing
base record fields.
The new shape-generator-key-presence test asserts that generated shapes
carry the required keys: rotation, flip-x, flip-y on all shapes and x,
y, width, height on bool/path, even when nilable.
AI-assisted-by: longcat-2.0-free
* 🐛 Sample 200 shapes in the key-presence test, not 10
`sg/sample` hands its options to `malli.generator/sample`, which reads
`:size`. `:num` is test.check's option. It is correct for the
`smt/check!` call directly above, where it came from, but `sg/sample`
ignores it and falls back to its default of 10.
Ten samples leave the bool and path assertions vacuous about one run in
fourteen. Simulated over 200 draws of 10, 14 contained no bool and no
path at all, and the median draw held 2. Those four assertions defend
exactly the keys this branch made required, so a run that skips them
silently is the one case worth not missing.
The assertion count shows the arithmetic. The test contributed 42 with
`:num`, which is 10 shapes times 3 keys plus 3 bool-or-path shapes times
4 keys, and contributes 756 with `:size`. The common suite goes from
1143 tests and 24744 assertions to 1143 tests and 25458 assertions, no
failures either way.
AI-assisted-by: mixed models
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>