82 Commits

Author SHA1 Message Date
alonso.torres
feaffafcfb 🐛 Keep pending library edits out of transform settlement
Use the component sync result only as a geometry proposal when settling
reflections and swapped placement. Correct existing shapes without pulling
pending library content, styles or structure into a transformed copy.
Keep path geometry and the primary action's exact undo/redo group.

Generate source transforms, letters and all twelve text style units with
separate source histories and explicit consumer acceptance. Add dropped
source-edit controls and portable/frontend regressions for the accidental
pull. Register the new portable namespace in the JavaScript runner.
The complete original program and both reductions pass unchanged.

Common JVM passes 1,919 tests/31,313 assertions. Full JS passes
1,845/29,770; the newly registered source namespace adds 5/135 passing
checks. Frontend passes 1,566/11,119, backend 1,610/8,104 and exporter
14/32. Lint and formatting pass; browser ShapeProxy replay passes.
No golden data changes.

Benchmark ratios against the unchanged baseline: mains 10/100/1000
0.57/0.65/0.61, levels 0.52, chain 0.51, library 0.52, many shapes 0.47,
variant switch 0.60. No regression flag.

AI-assisted-by: gpt-6
2026-10-30 09:50:19 +01:00
alonso.torres
fcab7a85d1 🐛 Carry child geometry through parent rotations received by sync
When sync turns a parent, turn its subtree as derived placement before
walking the children. Geometry overrides keep their size and relative
angle, including swapped children. Read current builder objects throughout
the walk so later syncs do not transform stale records again.

Normalize matrix axis directions for this turn: constraints already handle
resize, and minimum-size matrix scale must not resize children twice.

Portable regression fails for ordinary and swapped geometry overrides.
Expanded controls cover parent turns of 45/135 degrees and authored child
angles of 0/10 degrees, points, flags, retained size, repeat sync and undo.
Broad focused/golden checks: 204 tests, 2314 assertions. Latest controls
and shrunk program: 2 tests, 89 assertions. Goldens remain unchanged.
Initialize the container when direct sync receives an empty builder.
Register the geometry namespace in the JavaScript runner and read record
fields with get in the older flip control. Geometry: 9 tests, 149
assertions on both runtimes. Full common JS: 1768 tests, 29215 assertions.
Full frontend: 1532 tests, 9039 assertions. Lint and formatting pass.
Live WASM verifies both roles, retained placement and later style sync.

Component benchmark ratios: mains 0.51/0.56/0.64; levels 0.62;
chain 0.55; library 0.53; many shapes 0.45; variant switch 0.60.
All worlds pass the unchanged baseline gate.

AI-assisted-by: GPT-6
2026-10-30 09:50:18 +01:00
alonso.torres
957a7fada8 🐛 Run all registered common JavaScript test namespaces
Include the generated and oracle component tests, remote-sync regressions,
and schema-check tests in the execution list, as well as the requires.
Previously full JS runs silently skipped them and focused runs rejected
these namespaces as unknown (F166).

The focused generated command reproduces the registration failure before
the change. The registered full JS run passes 1733 tests and 29293
assertions with 100 scenarios per property, seed 712.

AI-assisted-by: GPT-6
2026-10-30 09:50:18 +01:00
alonso.torres
7c5fde720c ✨ Generate shrinkable component composition scenarios
Add a shared generator, authored override shadow and independent oracle.
Resolve tuples against current trees, log skips and stable replay paths,
and check propagation, sync equivalence, values and exact undo/redo.
Keep partial sync in the preceding user's undo group.

Validation: 5,000 seeded scenarios passed; common JVM/JS, golden masters,
lint and format passed. Focused oracle and sequence checks also pass.
Production sync is unchanged by this test harness commit.

AI-assisted-by: GPT-6
2026-10-30 09:50:08 +01:00
alonso.torres
31a1fce025 🐛 Preserve main overrides across repeated component updates
F114: updating a nested copy's main cleared the groups transferred by a
previous update whenever the source still carried remote-synced. A later
edit of the inner component could then overwrite those authored values.
Union the main's and copy's groups on every update.

The focused nested regression failed before the fix and now passes.
Common lint, JVM/JS suites, golden masters and format checks pass.
Seeded frontend replay passes 2 tests and 253 assertions.

Benchmark lower-quantile ratios against Phase 1e:
main 10/100/1000 copies: 0.61/0.63/0.67; three levels: 0.61;
chain: 0.56; library: 0.62; many shapes: 0.63; switch: 0.76.
No regression flag.

AI-assisted-by: GPT-6
2026-10-30 09:50:08 +01:00
alonso.torres
9c40c84b3b 🐛 Honor generative test size and seed options
Preserve explicit quick-check options and read PENPOT_GEN_NUM and
PENPOT_GEN_SEED when callers leave them unset. Print the final shrunk
counterexample in full so failures can be replayed.

The new regressions failed before the fix and now pass on the JVM.
Common lint and the full JVM/JS suites pass. Format checks pass for
these files; the in-progress Phase 4 harness remains outside this commit.

AI-assisted-by: GPT-6
2026-10-30 09:50:07 +01:00
alonso.torres
d8a18b8b74 ✨ 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
2026-10-30 09:50:07 +01:00
alonso.torres
48fd0400d7 🐛 Keep the undo of a large component sync from overflowing
Undoing a main edit synced into thousands of copies overflowed the
stack, on the JVM and in JS (F84). Each synced shape added the undo of
its parents' update to the end of the undo list with a lazy concat, so
the list nested one lazy sequence per synced shape, and reading it
recursed through all of them. concat-changes and the inverse sync's
marking of local changes (a map over the whole list at every level of
the walk) added more.

- A shape's :reg-objects undo now goes right after the shape's own.
  Each parent is still updated after all its children are undone, and
  a synced parent gets back its stored values instead of recomputed
  ones; the undo round trip of every composable case is unchanged.
- concat-changes builds the joined undo list at once, at a cost that
  grows with the list it adds.
- The inverse sync collects the copy shapes it syncs and marks their
  changes as local once, at the end, instead of at every level.

large-sync-undo-test builds 5,000 copies in one step, syncs a main
edit into them and reads the undo list.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  37.3 / 1.06
  edit-through-a-chain-of-dependents   68.9 /  69.8 / 1.01
  library-update-into-copies           83.1 /  76.7 / 0.92
  main-edit-into-10-copies              1.8 /   1.6 / 0.88
  main-edit-into-100-copies            15.4 /  15.2 / 0.99
  main-edit-into-1000-copies          153.1 / 152.9 / 1.00
  variant-switch-into-copies           32.1 /  27.3 / 0.85
(Measured with the dead :shapes-group reads already removed; run side
by side, this commit and that state give the same ratios.)

AI-assisted-by: claude-opus-5-5
2026-10-30 09:48:15 +01:00
alonso.torres
dc6f39e970 ♻️ Move the placement step of a transform to common
Which shapes of a copy a transform leaves untouched (the ignore tree)
was frontend code, written twice: calculate-ignore-tree for the
modifiers of the SVG renderer and calculate-ignore-tree-wasm for the
matrices of the WASM renderer. The second is the single-pass version
of the first: each shape still compares itself with the copy root of
the latest transformed shape above it, as the repeated walks did when
the last one won.

app.common.logic.placement now holds it once: calculate-ignore-tree
takes the transformed ids in order and a function that transforms a
shape, built with modifiers-transform (SVG) or matrices-transform
(WASM), both cached per shape. check-delta and transform-attrs moved
with it. The frontend calls the common version, and its tests moved
to common-tests.logic.placement-test, which runs on the JVM and in JS,
with a rotation through modifiers added.

No behavior change: the golden masters and the suites are unchanged.
The benchmark does not run this code.

AI-assisted-by: claude-opus-5-5
2026-10-30 09:47:49 +01:00
alonso.torres
c8d373eca1 ✨ Migrate copy texts to the per-style text overrides
Files older than the feature components/text-overrides mark a copy text
with :text-content-attribute (any style) or :text-content-structure
(letters and styles). Migration 0034 compares each such copy with its
near main and gives it the groups its old flags allowed where it
differs; an old flag where nothing differs sets nothing. A copy whose
near main cannot be reached keeps every group its flags allowed, so it
loses no override. The migration adds the feature, so a client on the
old code cannot write the old groups into a migrated file.

Binfile import leaves the copies whose near main is in a library not
linked yet, as it does with swap slots, and converts them once the
libraries are linked: the deferred pass of the import now runs both
conversions (convert-deferred!, *defer-unreachable*).

Validation reports the old groups (:legacy-text-touched), and its
repair recomputes them against the near main; a text override group
without :content-group is still :invalid-text-touched. A shape with no
letters in it and its main no longer counts as differing in its styles.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  34.8 / 0.99
  edit-through-a-chain-of-dependents   68.9 /  65.0 / 0.94
  library-update-into-copies           83.1 /  86.9 / 1.05
  main-edit-into-10-copies              1.8 /   1.5 / 0.86
  main-edit-into-100-copies            15.4 /  15.0 / 0.97
  main-edit-into-1000-copies          153.1 / 150.6 / 0.98
  variant-switch-into-copies           32.1 /  26.8 / 0.84

AI-assisted-by: claude-opus-5-5
2026-10-30 09:47:49 +01:00
alonso.torres
5d527e25ed ✨ Give each style of a copy text its own override
A copy text had three override subgroups: its letters, any style, and
its structure (the number of paragraphs or runs). One style override
kept every other style of the main out (F72), letters and a style
together kept everything out (F73), a structure change kept the main's
styles out unless both texts had one style (F74), which the editors'
content never had (F79), and alignment, direction and vertical
alignment overrides were lost at the next sync (F76).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AI-assisted-by: claude-opus-5-5
2026-10-30 09:47:18 +01:00
alonso.torres
a6ae24d68e 🔥 Remove the components v1 leftovers
No components v1 file reaches the code any more: opening one fails the
feature check, and imports reject it or give it the feature. What only
served v1 goes:

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

AI-assisted-by: claude-opus-5-5
2026-10-30 09:47:18 +01:00
alonso.torres
55b1f14df2 ✨ Test what copy texts keep through sync and switches
Add a battery of tests for text overrides in copies.
- A short notation for text content in tests (thtx): paragraphs of
  runs with short style names, read back with equal runs joined,
  edits in the notation, set-text and set-content through the change
  path, and the override rule for texts (override-units), which says
  which overrides a copy must hold against its main.
- Sync matrix: every whole-text copy edit against every main edit,
  plus the cards of the worked examples, the "Hello world" table, a
  token on a copy, typographies and a library color sync. Where the
  code differs from decisions (k) to (o), the result is pinned
  against F72 to F80.
- Switch matrix: copy edits against variants that differ in letters,
  size, color, a new line or a bolded word; F76, F77, F79 and F81
  pinned.
- Composable case BA: EditText also edits fills, font family and a
  whole text; the copy and the main change different styles, then
  reset or update main, in both runners; F72 pinned.
- Editor output: the content the WASM and v1 editors commit when a
  copy text is typed into, retyped, split with Enter and has a word
  bolded, recorded in the workspace and replayed on a copy. Typing
  sets only the text groups and a style change no text override;
  F79 pinned on both editors' content.
2026-10-30 09:47:18 +01:00
alonso.torres
222bbe6b4e ✨ Test the file and container helpers and the library events
The component helpers of types/file and types/container and the
library events of the workspace had the lowest coverage left.
- common-tests.types.file-test: the export detach, the reuse of the
  library page, the original ref of a swap, deleted components and
  used-in?. Pinned: F52 (a library copy nested in a local component
  loses its ref in every copy), F56 (the original ref of a swap is nil
  when the mains are on different pages) and F57 (library color refs
  inside :fills survive the export detach).
- common-tests.types.container-test: the placement rules for moves
  and pastes (copies, mains, variant containers, deleted components,
  component loops) and small helper branches. A plain shape moved
  into its own child passes the rules (F58); relocate-shapes drops it
  first.
- frontend-tests.data.workspace-libraries-test: the events on several
  shapes at once, each one undo entry; update of a library main; a
  blank rename; placing with an id ref.
- workspace-library-updates-test: how a file learns that a library
  changed, and the sync dialog with its update and dismiss, with the
  RPC mocked.
- workspace-library-links-test: linking, unlinking and sharing.
2026-10-30 09:47:18 +01:00
alonso.torres
a817cf5e3b ♻️ Tidy the composable suite and make its cases stricter
A review of the suite after the new operations:
- Dead helpers and duplicate nodes go; the lineage reset and swap
  nodes are constructors of reset and swap-shape.
- Repeated helpers are shared: grid boards (tho/add-grid-frame,
  tho/place-in-grid-columns), n/nested-head-under, n/child-shape,
  r/sync-cascade (the benchmark uses it too), the frontend fixture and
  fill helpers, and the plugin getMainComponent.
- Runner tests that repeated cases B, C and D go.
- Comments say what the code does, and the wrong ones are fixed.
- Case K's remote edit painted red on red and its unedited variants
  asserted nothing; it starts grey and every variant asserts.
- F43 is pinned in one case, AJ, by the value one undo leaves. L, M, Q
  and K get real frame sizes, since their pins depended on when the
  100 ms layout buffer flushed.
- make-nested-component takes a level count and depth-sweep gives each
  depth once, so K, Q, R, T and W stop running depth 1 twice.
- The completion-contract tests no longer depend on fixed pauses or
  on how the watcher batches its events.

Benchmark against the baseline (lower quantile): ratios 0.92 to 1.03,
no world flagged.
2026-10-30 09:47:18 +01:00
alonso.torres
81af925bba ✨ Test component validation, repair and library asset sync
Fill the unit test gaps in common and frontend component code:
- One test per component error code: break a file in one way, check
  the codes validation reports, repair it as the backend does (until
  valid, at most ten passes) and check what the repair changed.
  Pinned: F46 (a dangling ref in a copy child is never reported), F47
  and F48 (two repairs that never converge). :component-not-main,
  :should-be-component-root and :not-component-not-allowed have no
  test, as no file can produce them.
- Library color and typography sync must not touch copies. Both do
  (F3): the color sync touches :fill-group, the typography sync
  :content-group and :text-content-attribute, and a later main edit of
  that attribute then no longer reaches the copy. Pinned.
- Twenty live thf/dump-file calls in common tests go; they printed
  whole file trees on every run and asserted nothing.
- The disabled copy-nested-3 frontend test runs again: a stray
  backquote quoted its run-store call, so it never called done.
2026-10-30 09:47:18 +01:00
alonso.torres
2e52819490 ✨ Run the composable component cases on the JVM
The composable test engine, nodes, setups and cases lived in the
frontend test tree, so only the frontend interpreter could run them.

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

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

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

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

The frontend events behave as before, with two exceptions. Switching
to a value no other variant has does nothing, where it threw in
min-key. A swap also updates the layout of the shapes the keep-touched
step adds.
2026-10-30 09:47:17 +01:00
Andrés Moya
6f185c624f
🐛 Fix swap when the original main component is not available (#12333)
* 🐛 Fix swap when the original main component is not available

* 🐛 Change the shape-ref for the id
2026-10-26 12:25:00 +01:00
Belén Albeza
41d95abc5c
🐛 Fix math symbols rendered as boxes in the new render engine (#12471)
Noto Sans Symbols lacks most arrows and math operators. Fall back to
Noto Sans Math for them, and to Noto Sans for letterlike symbols.

Closes #12469

AI-assisted-by: claude-opus-5-5
2026-10-20 14:09:01 +02:00
makesomethingshit
91a5b3a9fb
🐛 Fix token tree path handling for repeated and nested segments (#11591)
* 🐛 Fix token tree collapsing repeated path segments

Tokens whose path contains the same segment name twice in a row
(e.g. test.pointer.pointer) collapsed to a single level in the
token tree.

build-tree-node marked a node as a leaf when the current segment
name matched the first segment of every remaining path. Because
group-by-first-segment rewrites nested paths into the segment
name, a still-nested remaining path was indistinguishable from a
token ending at the current node, so test.pointer.pointer merged
into one leaf.

Mark rewritten segments with an internal ::remaining? marker and
strip it when the rest-path is fully consumed; is-leaf? only
accepts unmarked remaining segments as leaves.

Closes #11588

AI-assisted-by: glm-5.3-flash
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Preserve ancestor tokens in path trees

Keep a node's token and descendants independently when imported paths
contain both an ancestor and a child. Render both in the token tree and
include the ancestor token in existing path-based group operations.

Cover accepted same-set imports, leaf IDs, repeated segments, and internal
marker removal without changing import or collision validation.

AI-assisted-by: GPT-6
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* ♻️ Rework token tree path building

Carry the segments left to descend as a ::remaining-path vector on each
segment and advance it by subvec, instead of rewriting the segment :name
and re-splitting it later.

With that, build-tree-node finds the leaf token with a predicate over
::remaining-path and takes children from the segments that still have
one, so the pre-grouping children-first sort no longer changes the
result. Drop sort-children-first and its segment-has-children? helper,
and document inside-path?.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix and relocate the token tree tests

The ancestor-and-descendant test called a function that does not exist
in app.common.types.tokens-lib, so the namespace failed to load and the
whole common suite errored. Use make-tokens-status-from-lib and the
two-argument get-tokens-in-active-sets instead.

Move the build-tree-root tests to path-names-tree-test so
path-names-test stays a pure split/join unit test.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Refactor the token tree rendering and share path helpers

Extract the token pill into a leaf-node* component and treat a node as a
leaf only when it holds a token and has no children, so a node can carry
both a token and its children.

Make token-separator public and reuse it from the token tree and the
management panel instead of local copies. Both management path filters
inlined the same ancestor-or-descendant rule; extract it as
token-in-path? and cover it with a unit test.

AI-assisted-by: deepseek-v4.1-flash

---------

Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: luisddm <luis.dedios@kaieldos.net>
2026-10-19 09:44:32 +02:00
Eva Marco
98f8b7e625
🐛 Keep the picture in place when a crop crosses the opposite edge (#12361)
With the mod key, resizing a shape with an image fill crops it: the
shape is a window over a picture that keeps its size and position.
When the dragged edge crossed the opposite one, the crop mode turned
off and a normal resize with a negative scale flipped the shape. The
image transform is relative to the shape, so the picture was mirrored
and shrank a bit more on every crossing until it disappeared.

Keep the crop mode when the edge crosses: the window moves to the
other side of the fixed edge without flipping, the part outside the
picture shows empty, and moving the window back shows the picture
again. The crop math lives in app.common.geom.image-crop and replaces
the image transform code that was duplicated for fills and fill-image.

Closes #12349

AI-assisted-by: claude-opus-5-5
2026-10-15 16:23:32 +02:00
Andrey Antukh
c74d415ddd
🐛 Stop oversized audit events from wedging the archive queue (#12252)
* 🐛 Stop the export-shapes modal event from dumping all exports

The `:export-shapes` modal event carried the full `:exports` vector,
each export embedding its shape (path data, geometry, text content).
One event reached 1.7 MB, which made the audit archive cron POST a
chunk past the proxy body limit and wedge the queue.

Build a compact body for that modal instead: total exports, distinct
shapes and one count per export type, plus the page name. Other modals
keep their props unchanged.

Move the modal `:origin` prop to `::ev/origin` so `make-proto-event`
records it on the event context as `:event-origin`; the UI still reads
the plain prop from the raw state.

Add tests for the summary and for the modal origin.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Bound exception messages in reports and audit events

A frontend crash whose message embeds a whole payload (a serialized
file) produced a 9.4 MB audit event: the message was repeated in the
`:hint` prop, in the report context and in the stack trace. A single
event that big wedged the audit archive queue.

Bound exception messages at the source: `get-hint` clamps to
`max-message-length` (1000 chars), and the CLJS `format-throwable`
clamps the message line of the stack trace and honors `:trace-length`
(parity with the CLJ version). `report-context` now prints `get-hint`
instead of the raw `:hint` from `ex-data`.

Fix `first-line` on the JVM: `cuerdas/index-of` returns nil when the
substring is missing and `(pos? nil)` throws there (it is false in
CLJS), so single-line messages crashed the backend path.

AI-assisted-by: deepseek-v4.1-flash
2026-10-15 10:19:26 +02:00
Shreyash Agare
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>
2026-10-05 12:04:11 +02:00
Eva Marco
c04a3d346d
🔧 Register the missing test namespaces in the common JS runner (#12074)
Kaocha finds every test namespace on the JVM, but the JS runner only
runs the ones listed in runner.cljc. Sixteen namespaces were missing
from both lists, and files.shapes-builder-test was required but not
listed, so 125 tests never ran in JS. Add them to both lists, sorted.

The only one that failed in JS was make-tokens-status-from-tokens-lib:
(.sets base-lib) is a method call in ClojureScript. On the JVM it read
the field, but (:sets ...) on the result gave nil, so the legacy lib
was built without sets or themes. Use (.-sets base-lib) and
(.-themes base-lib), as the first part of the same test does.

AI-assisted-by: claude-opus-5-5
2026-10-05 08:25:15 +02:00
Andrey Antukh
b5fbc4fd8c
✨ Add size limits to profile props and plugin registry (#11596)
* ✨ Add size limits to profile props and plugin registry

Bound the total serialized size of profile settings to 2 MiB
(:profile-props-max-size), checked on the merged result before
persisting, with a controlled :props-too-large error. Profiles
that already exceed the limit can still shrink but cannot grow.

Cap plugin registry entries in the shared schema (code 1 MiB, 50
plugins max, bounded name/host/description/icon) and restore rate
limiting on the plugin RPCs (profile-mutations bucket, one write
at a time per profile). The plugin manager now asks for
confirmation before removal and ignores repeated clicks while a
persist request is in flight.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Enforce plugin count cap, byte sizes and removal guard

Enforce the declared 50-plugin cap in add-profile-plugin with a
specific :too-many-plugins error (updates of existing entries
still pass); the cap lives in a shared max-plugins constant.

Measure profile props size in UTF-8 bytes instead of chars so
multibyte content cannot slip past the limit.

Cover install/remove persist logic with mocked-RPC frontend tests
(release semantics, in-flight dedupe, validation vs rollback
split) and add the missing boundary tests in common.

Expose the in-flight persist set from the plugin registry and
disable the remove button of entries being saved.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix rollback loops and restore paths in plugin registry

Restore the previous plugin version instead of dropping the entry
when a validation error rejects an update of an installed plugin.

Make compensating writes one-shot with terminal callbacks so a
persistent failure cannot ping-pong between install and remove.
Restores keep the original list position; the unused public
plugin-persisting? predicate is removed.

Pin count-before-size precedence with a dedicated test and fix
translation source refs to their canonical lines.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Guard notifications write and fix restore ordering

Route update-profile-notifications through check-props-size! so
oversized profiles cannot grow through that path; document the
exempt system writers. Remove the duplicated stale entries in
en.po, keeping the canonical translation refs.

Restore rejected plugin updates at their original list position
instead of leaving the optimistic move in place.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Skip no-op plugin removal and clarify size comments

Return early from remove-profile-plugin when the id is absent:
no wasted write, no size check, and no manufactured :plugins key
that could spuriously fail on oversized profiles.

Clarify that per-field string caps count chars while the byte
budget is enforced by profile-props-max-size.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 📎 Fix formatting in rlimit.edn for profile operations

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* 📎 Fix formatting of import-binfile/global entry

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Simplify props size check and tighten plugin entry caps

Measure props with transit bytes directly instead of the
PGobject string roundtrip.

Rename check-props-size! to check-props-size: single hard limit
on the merged props, no growth comparison, and return props so
writers thread the check into the update.

Move the 2 MiB default into default-props-max-size on the
profile namespace, still overridable with the optional
:profile-props-max-size config entry.

Tighten registry-entry :code and :icon to 500 chars: they hold
manifest paths, not content.

Closes #11592

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix compatibility problems

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-01 14:31:30 +02:00
Andrey Antukh
10ccfd2218
🐛 Gate pastes on page load and harden base-shape lookup (#11674)
* 🐛 Gate pastes on page load and harden base-shape lookup

Pasting while the workspace is still opening crashed the
session: the layer-order lookup called rseq on a missing
root children list.

Ignore paste events until the page objects are loaded (the
clipboard keeps its content, so retrying works), return empty
instead of throwing from the shared layer-order helpers, and
fall back to pasting at the pointer position when the selection
is detached from the shape tree. Selecting the page root keeps
working as before through the frame branches.

Closes #11666

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Cover props paste and root-plus-other in paste guards

Address review follow-ups on the paste-before-init fix: gate
props pasting on page readiness like the shape entries, and
route root-plus-other selections without a base shape to the
pointer fallback instead of the unguarded else branch. Pin
single-root selection to the frame path with a regression test.

Closes #11666

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Hoist page lookup out of paste-shapes gate

Bind page and page-objects once in an outer let instead of
calling lookup-page twice (once for the readiness gate and
once inside the body). No behavior change.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Reuse bound ids in paste-shapes page lookup

Bind file-id and page-id once and pass them to the lookup-page
arity that takes both, instead of resolving the page twice and
rebinding file-id in the inner let. No behavior change.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-30 07:24:03 +02:00
Andrey Antukh
0ef35fc52a
🐛 Harden browser logging against invalid levels (#11693)
* 🐛 Stop browser logging from crashing on empty levels

Stop level->int crashes from taking down the dashboard when a
nil or unknown level reaches the browser logger. Invalid levels
now warn and are ignored in enabled?, setup! and the console
handler, which renders unknown records with a neutral fallback.

Alias the schema-legal :fatal level to :error in the browser
mappings and validate the JS-exported debug.set_logging, which
previously threw on missing arguments and wrote unreachable
keyword keys into the loggers map.

Closes #11690

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Guard logger args and strengthen logging tests

Close the residual throw paths next to the empty-level crash:
guard non-string loggers in enabled? and setup!, coerce
set_logging arguments safely, and validate logger keys.

Strengthen the regression tests so the fatal alias cannot
regress silently: enabled-logger filtering, JVM fatal and bogus
cases, setup! skip proof, and invalid-logger cases.

Related to #11690

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Address low findings from logging review

Validate the logger before the level in console-log-handler,
share a public valid-logger? predicate with debug/set-logging,
and keep warn formatting consistent across boundaries.

Document the fail-soft-FE/strict-BE split on enabled? and the
valid-level? contract on set-level!. Cover safe fallbacks, bad
logger keys, handler logger skips, and loggers-map isolation in
common tests, and add a frontend test for debug/set-logging.

Related to #11690

AI-assisted-by: muse-spark-1.3-contributor
2026-09-30 07:17:02 +02:00
Luis de Dios
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
2026-09-25 13:09:55 +02:00
Danny Shirely
d45c6710b7
🎉 Implement independent image bounds resizing (#11430)
* 🎉 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>
2026-09-09 15:16:56 +02:00
Andrey Antukh
a3feb4ef3b Merge remote-tracking branch 'origin/staging' into develop 2026-08-27 11:54:55 +02:00
Andrés Moya
c9a2b8f12f
🔧 Add TokensStatus data type for forward compatibility (#11314)
* 🔧 Add TokensStatus data type for forward compatibility

* 📚 Add Transit round-trip tests for TokensStatus type

Add serialization tests validating TokensStatus survives Transit
encode/decode and Fressian round-trip (JVM). Clarify tokens-source
field comment as forward-compatibility placeholder.

AI-assisted-by: mimo-v2.5-pro

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-27 11:51:47 +02:00
Andrey Antukh
17befc1db9 Merge remote-tracking branch 'origin/staging' into develop 2026-08-27 10:05:43 +02:00
Andrey Antukh
06491a2d24
🐛 Fix svg-raw child ids stored as strings in existing files (#11387)
* 🐛 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
2026-08-27 09:49:22 +02:00
Andrey Antukh
81c3b3cd56
📎 Update copyright name on file header (#11346) 2026-08-25 11:55:10 +02:00
Andrey Antukh
49119e0339
♻️ Rename nitrate config to admin-console (#10929)
* ♻️ Rename nitrate config to admin-console

Rename user-facing configuration from 'nitrate' to 'admin-console':
- Feature flags: :nitrate -> :admin-console, :nitrate-bulk-create-profiles -> :admin-console-bulk-create-profiles
- Config keys: :nitrate-shared-key -> :admin-console-shared-key, :nitrate-backend-uri -> :admin-console-uri
- Shared-keys map entry: :nitrate -> :admin-console (setup.clj + main.clj)
- Env vars: PENPOT_NITRATE_SHARED_KEY -> PENPOT_ADMIN_CONSOLE_SHARED_KEY, PENPOT_NITRATE_BACKEND_URI removed (consolidated into PENPOT_ADMIN_CONSOLE_URI)
- Docker/nginx: PENPOT_NITRATE_URI -> PENPOT_ADMIN_CONSOLE_URI

Code namespaces, file paths, CSS classes, and i18n keys stay as-is.

AI-assisted-by: mimo-v2.5-pro

* ♻️ Rename initialize-user-in-nitrate-organization to initialize-user-in-organization

Part of the nitrate -> admin-console rename series. The function and all 9 references across 6 files have been renamed.

* ♻️ Rename :nitrate-bulk-create-profiles-not-allowed to :bulk-create-profiles-not-allowed

* ♻️ Inline nitrate-permissions into app.common.types.organization

- Delete app.common.types.nitrate-permissions and its test
- Move permission rules (allowed?, can-send-invitations?, etc.) into organization.cljc
- Harmonize all consumers to use alias cto for app.common.types.organization
- Update test runner and create organization_test.cljc
2026-08-03 17:23:55 +02:00
Andrey Antukh
e741313add Merge remote-tracking branch 'origin/staging' into develop 2026-07-31 19:21:07 +02:00
Andrey Antukh
d04cbf175e
🐛 Fix nil dereference crash during flex layout drag operations (#10845)
Production crash where @(get bounds id) threw
"No protocol method IDeref.-deref defined for type null"
when a shape ID had no corresponding entry in the bounds map
during layout calculations.

Added defensive nil guards (when-let / when) to all unprotected
bounds dereference sites:

- flex_layout/bounds.cljc: layout-content-points (parent + child)
  and layout-content-bounds
- grid_layout/bounds.cljc: layout-content-points and
  layout-content-bounds
- min_size_layout.cljc: child-min-width grid branch (3 sites) and
  child-min-height grid branch

Added 7 new tests in geom_bounds_layout_nil_test.cljc covering all
nil-bounds edge cases for flex, grid, and min-size layout paths.
Registered in runner.cljc.

Closes #10843

AI-assisted-by: qwen3.7-plus
2026-07-31 12:51:35 +02:00
Alonso Torres
014ec34249
🐛 Fix system crash on grid element deletion of a component (#10956)
* 🐛 Add regression test: main-side component edits break copy swap slots

Reproduces the :missing-slot referential-integrity failure ("Shape has been
swapped, should have swap slot") that crashes files with component copies.

Root cause: reordering or deleting a nested sub-head IN THE MAIN of a component,
while copies exist, does not propagate swap slots to the copies. find-near-match
matches a copy's sub-heads to the main's children by POSITION, so once the main's
order changes the copies' shape-refs no longer match their position and, lacking a
swap slot, fail referential-integrity validation.

- Copy-side edits are handled correctly (characterization tests, pass today).
- The two main-side tests fail today with :missing-slot and go green once the
  sync assigns swap slots to copies on a main reorder/delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 Fix integrity crashes from copy/main child-order divergence

Copy sub-heads were matched to their main's children purely by position
(find-near-match), while several code paths reorder or mutilate one side
only. Any of them made file validation fail with :missing-slot ("Shape
has been swapped, should have swap slot"), crashing the workspace on the
next validated commit, or persisting a corrupt file whose later edits
crash. Reproduced live: deleting a sub-head inside a copy (which only
hides it) and then reflowing the copy's grid moved the hidden (cell-less)
child to the front of :shapes, knocking every sibling out of its
positional slot.

Four fixes, one per divergence path:

- validate/check-required-swap-slot: a sub-head whose shape-ref is still
  a child of the near main parent is a REORDER (the component sync
  realigns it), not a swap; a swap slot is required only when the ref
  points outside the near main parent (a real swap). This matches how
  the sync engine itself pairs children (by shape-ref, not by position).
  comp-processors/fix-missing-swap-slots (migration 0019) is aligned:
  adding slots to merely-reordered sub-heads would freeze them out of
  normal synchronization.

- changes/:reorder-children now refuses to alter the child structure of
  component copies unless allow-altering-copies is set, mirroring the
  is-valid-move? rule of :mov-objects; that structure is owned by the
  component sync engine. Grid reflows emitted this change type with no
  guard. pcb/reorder-grid-children also skips copy grids producer-side.

- layout/reorder-grid-children keeps children that participate in no
  cell (hidden or absolute positioned) at their original index instead
  of lumping them at the front: moving them gratuitously changed their
  z-order and, in copies, broke the positional matching. Note :shapes
  stays reversed relative to the sorted cell order for in-cell children.

- logic/generate-delete-shapes: deleting shapes from inside a main
  (without deleting the main root, whose copies keep working against the
  deleted component) now also deletes the copy shapes that reference
  them, transitively (copies of copies) and across all pages of the
  file, so no dangling shape-refs remain. Skipped for
  allow-altering-copies flows (component swap replaces the shape and the
  sync reconciles copies via swap slots). Cross-page removals build
  redo/undo changes against that page's objects directly, since the
  changes-builder mounts only the current page; their undo mov-objects
  carry allow-altering-copies so restoring inside a copy is not rejected
  by the new guard.

The regression tests assert the fixed semantics: main-side reorders and
deletes keep copies valid, the previously crashing full chain (unvalidated
main reorder + later copy edit) stays healthy, :reorder-children cannot
scramble copies, and reorder-grid-children keeps cell-less children in
place. The namespace is now also registered in the JS test runner.

AI-assisted-by: Claude Opus 4.8 (1M context)

* ✨ Extend composable slot cases to the grid-reflow crash sweep

Case D (CopySubheadDeletePreservesSlots) now sweeps which copy sub-head
is deleted (first or last) and whether the copy root is resized
afterwards, forcing a grid reflow: the reflow used to move the hidden
(cell-less) child to the front of the copy's children, shifting every
sibling out of its positional slot. Deleting the FIRST sub-head masked
the bug (moving it to the front is a no-op), which is why the case
passed before this sweep. The foundation layout is grid accordingly.

Case E (MainReorderKeepsCopySlots) no longer hangs the app now that a
main-side reorder leaves a valid file, so its warning docstring is
replaced: it runs as a routine test (verified headless, passing) and is
safe in a "run all".

SlotIntegrity's doc is updated to the new validator semantics (a slot is
required only for real swaps, not reorders); the positional alignment it
asserts remains the correct, stronger steady-state invariant for these
cases. The lockfile change materializes the playwright devDependency
already declared in package.json.

AI-assisted-by: Claude Opus 4.8 (1M context)

* 📚 Record copy/main order-divergence invariants in memories

Swap-slot semantics (membership, not positional; slots only for real
swaps), the copy-structure guards on :mov-objects/:reorder-children, the
grid reorder stability for cell-less children, and the main-side delete
propagation in generate-delete-shapes.

AI-assisted-by: Claude Opus 4.8 (1M context)

* 🐛 Add adjustements to the code

---------

Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 13:25:03 +02:00
Andrey Antukh
972353eccb
🐛 Fix layout padding persisted as string after invalid input (#10758)
Combined fixes from PR #10656 (numeric-input redesign) and PR #10696
(global finite? guard) for issue #10638.

- Add (number? v) guard to cljs mth/finite? so strings are rejected
- Redesign numeric-input last-value* to store number, not formatted string
- Invalid-input fallback restores display without emitting on-change
- Esc now fully discards typed text (resets raw-value* + dirty flag)
- Token dedup by name instead of resolved value
- Defense-in-depth: d/parse-double at 4 padding/gap handlers
- Math tests (common), unit tests, Storybook play tests, Playwright E2E

AI-assisted-by: deepseek-v4-flash

Co-authored-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Ulises Millán <ulises.millanguerrero@gmail.com>
2026-07-30 12:51:44 +02:00
Andrey Antukh
7eb188b077
🐛 Fix text content repair and remove blast-radius containment (#10731)
Repair text shapes with empty/broken content at all three levels
(root, paragraph-set, paragraph) in migration 0025 to prevent
workspace update failures. Handle all corner cases: nil/empty/non-
vector children, non-map items, wrong types. Remove geometry-only
validation skip in changes.cljc so all shapes are validated.

AI-assisted-by: qwen3.7-plus
2026-07-28 11:00:53 +02:00
Andrey Antukh
4687b52bf3 📎 Fix fmt issues introduced in previous commits 2026-06-24 11:30:32 +02:00
Andrey Antukh
bfa65547f8 ✨ Add more testing related improvements 2026-06-24 11:17:47 +02:00
Andrey Antukh
4a8fb5af53 Merge remote-tracking branch 'origin/staging' into develop 2026-06-01 13:15:57 +02:00
Andrey Antukh
c5de4c27b0 Merge remote-tracking branch 'origin/main' into staging 2026-06-01 12:57:39 +02:00
Yamila Moreno
ddba2ffa75
📎 Update Kaleidos Copyright (#9929) 2026-05-29 11:24:58 +02:00
Andrey Antukh
3858993a57 Merge remote-tracking branch 'origin/staging' into develop 2026-05-27 13:37:02 +02:00
Andrey Antukh
40ce360c99
⚡ Improve performance and fix orphan detection in validate-file (#9789)
* ⚡ Improve performance and fix orphan detection in validate-file

- Add `*ref-shape-cache*` dynamic var to memoize `find-ref-shape`
  lookups per page, avoiding repeated O(depth) ancestor walks.
- Add `*children-sets*` pre-computed maps for O(1) parent-child
  containment checks, replacing linear `some` scans.
- Short-circuit `inside-component-main?` when the shape context
  already implies a main component.
- Use single-pass reduce with early exit for duplicate detection
  (children, swap slots) instead of count/distinct or frequencies.
- Guard `check-missing-slot` to skip expensive `find-near-match`
  when the shape already has a swap slot.
- Refactor variant-set validation to use `run!` with direct `get`.
- Refactor `check-ref-cycles` to use a single `reduce-kv` pass.
- Fix `get-orphan-shapes`: the original `map` pipeline produced
  nils so orphan shapes were never validated; rewrite with
  `reduce-kv` for correct results.
- Add `validate-file-affected!` for change-scoped validation,
  replacing full file validation in `process-changes-and-validate`
  to only validate pages and components touched by the changes.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ✨ Improved validation

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-05-27 12:36:21 +02:00