758 Commits

Author SHA1 Message Date
alonso.torres
a841f3cd8a ✨ Add language-specific CJK layout controls
Add independent Japanese, Chinese and Korean file switches and all-files
preferences under the shared vertical-text-layout feature flag. Keep
control availability separate from saved paragraph profiles and rendering.

Expose explicit language profiles in separate sidebar sections. Preserve
mixed values, report the active paragraph profile at the caret, and keep
shared writing-mode controls consistent without radio group collisions.

Validation: common JVM/JS and frontend suites, backend profile tests,
exporter tests and lint pass. Existing runner formatting and Spanish
translation QA issues remain unchanged.

AI-assisted-by: GPT-6
2026-11-03 17:36:40 +01:00
alonso.torres
a6ddae653d ✨ Add Japanese layout controls and opt-in settings
Enable Japanese layout per file through undoable changes, or for all
files through a saved profile preference. Show the controls when the
WASM renderer and either setting are active.

Add writing mode, orientation, ruby, emphasis, warichu and line fitting
controls with icons and English and Japanese translations. Cover the
file change, profile preference and control behavior.
2026-11-03 17:36:40 +01:00
Andrey Antukh
93d36d04d8
✨ Stabilize fragmented file data across snapshots and GC (#12420)
* ✨ Stabilize fragmented file data across snapshots and GC

Snapshots, automatic and user, now store pointer-based rows that share
fragments with the live file instead of duplicating page content.
`get-file-snapshot` resolves those pointers on the server so version
previews keep rendering plain data, and `restore!` reuses the shared
rows. Export still materializes (the package carries no fragments) and
import regenerates them.

`file-gc` treats the main file and every unpurged snapshot as liveness
roots, reads the file unrealized, and reattaches the original pointer
wherever the cleaned value is unchanged, so a pass over an untouched
file writes no fragments and a pass over an edited file keeps the ids
of the pages it did not touch. Snapshot creation and the restore
backup take the file row `FOR SHARE`, which serializes them with the
pass and closes the race that let a new snapshot reference fragments
the pass was about to mark.

All backend pointer bindings go through the `fdata/with-pointers`,
`with-tracked`, `with-tracked-pointers` and `with-untracked` macros.
The loader reads fragments a previous pass marked until `objects-gc`
purges them, and the frontend fails loudly with retry instead of
dropping an unresolved fragment.

Review fixes in this branch: the automatic snapshot attaches before
pending migrations, `realize-pointers` is the single verb,
`reattach-unchanged` takes the identity fast path, the restore guard
takes its revn from the backup read, and `PointerMap.withMeta`
generates a fresh id when metadata changes.

Tests cover both snapshot writers (red without the lock), snapshot
sharing and pin release through the real delete and purge paths,
reattach against orphan marking, preview after offload, the pointer
binding contracts and the with-meta id change.

Closes #12410

AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: deepseek-v4.1-flash

♻️ Pin no-churn behavior for untouched pages

Pin the behavior the churn verification measured: a save keeps the
fragment id of every page it does not touch, with a page of eleven keys
(PersistentHashMap), a 46-shape objects map and a nine-page index (hash
map). Also pin the mixed case: a data-level objects-map flip rewrites
the old pointer pages once and the next save keeps the new ids.

The memory records the identity rule behind it: re-assoc with the same
value returns the same map in array and hash maps alike.

Closes #12410

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

⚡ Stop loading untouched pages when enabling objects-map

`enable-objects-map` wrapped every page on every save, and reading a
page's objects forced its PointerMap to load the fragment. A save of
one page in a seven-page file therefore made seven fragment reads.

Skip unloaded pointers: their fragment was already wrapped by the
encode-file that wrote it, so loading it only to wrap it again is a
no-op. The save now loads only the pages it edits. A page with a plain
fragment (a data-level feature flip) keeps its plain fragment until it
is first loaded or edited; a migration that enables the feature must
rewrite the data itself.

Cover the change with load-count tests for save and restore, update the
flip test to the new contract, and drop the stale restore comment.

Closes #12410

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

⚡ Keep page fragment ids through pending migrations

Applying a pending migration on read realized the file, ran the
migration and persisted it again; the persist wrapped every plain page
in a new PointerMap, so every page got a new fragment id even when the
migration did not change it. That leaves the fragment cache cold and
rewrites every fragment on the first read after a deploy.

Capture the original pointers before realizing, reattach the unchanged
positions after migrating (same value comparison file-gc already uses),
persist the reattached file and return the realized one: the read
contract is plain data. Read-only connections skip the persist and the
reattach. A migration that adds objects-map skips the reattach so the
pages get wrapped.

Move the three helpers to fdata so file-gc and the migration path share
them, and cover the path with a real 0031 migration test: the unchanged
page keeps its id, the changed page gets a new one, the caller gets
realized data and a second read does not migrate again.

Closes #12410

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

* 🐛 Realize library pointers with each library's own loader

Fragment rows are keyed by file id, and every caller binds the
fragment loader of the file it works on. get-resolved-file-libraries
returned libraries with their pages still as pointers, so the first
read of a library page under fdata/pointer-map asked the consuming
file for the fragment and failed with :fragment-not-found. That
broke migrations and file validation for every file using a
pointer-mapped library.

The weak-map loader now realizes each library's pointers with the
library's own id. The test fails without the change.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 🐛 Restore the pointer-map require the repair path still needs

The branch dropped `app.util.pointer-map` from the namespace while the
auto-repair path (added to develop before this branch) still rebinds
`pmap/*tracked*` around validation and repair in
`repair-preexisting-damage` and `process-changes-and-validate`. The
namespace did not compile, so the whole backend failed to load.

Those toggles do not fit the fdata macros: `with-untracked` binds nil
and `with-tracked` creates a fresh atom, while the repair path has to
turn tracking off and restore the existing atom (and read it before the
repair starts). Restore the require and keep the bindings explicit.

Also pin the feature-off path with a test: with `fdata/pointer-map` off
(the default), a save creates no fragments, file-gc writes nothing and
the snapshot preview still returns plain data. Drop a stray `\n` char
literal left at the end of one deftest.

Update the memories with the exception to the macro rule, the snapshot
FOR SHARE locking and the pre-migration automatic snapshot.

Closes #12410

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

* 🐛 Validate debug files with their own pointer loader

The `/dbg/actions` file validation passed the file id where
`get-resolved-file-libraries` expects the file map, so the library map
was seeded with a nil key. It also validated a file read without
realizing it, so the first page lookup of a pointer-mapped file raised
"load is not supported when *load-fn* is not bind" and the endpoint
could not validate exactly the files the feature touches.

Read the file realized (validation walks every page and component
anyway) and build the library map from the file. The library loader
already resolves each library's pointers with its own id, so the
realized file and its libraries validate as plain data. Covered by a
handler-level test that fails before the change.

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

* ♻️ Bind the snapshot loader through the fdata helper

`reduce-snapshots` hand-rolled the same `binding` that
`fdata/with-pointers` builds (it also loaded the function's own
pointer loader before entering the reduction). Use the macro so the
rule holds there too and the namespace no longer needs
`app.util.pointer-map`; the loader is identical (same cfg and file-id)
and snapshot walking behaves the same.

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

* 📚 Note the pointer-binding exceptions and snapshot ordering

Record what the feature review settled:

- Two direct `binding`s stay on purpose: the fine-grained `*tracked*`
  toggles of `app.rpc.commands.files-update` (validation/repair turn
  tracking off and restore the existing atom) and
  `binfile.common/encode-file` (fresh tracked set inheriting the
  current one). Atom-scoped macros would only be worth it if the set
  grows.
- The automatic snapshot is taken before the pending migration, and
  the pre-migration window is narrow: the `get-file` RPC migrates on
  read and its ETag hashes the available migrations, so a post-deploy
  read cannot be a 304 from the pre-deploy state. The old-format
  snapshot only appears when the save is the first request to touch
  the file after the deploy, and `get-file-snapshot` previews it as
  stored.

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

* 🐛 Apply pending migrations when previewing a version

Automatic snapshots are attached before the save applies pending file
migrations, so a version can store the old format. The preview RPC
handed that data to the UI as stored, and the viewport renders it with
the current code.

Realize the snapshot with the live file id (fragments are keyed by
file, not by snapshot), normalize its migrations to a set (the decoder
keeps the pgarray as a vector, and `set/difference` would compare
indexes) and apply the pending migrations with the pure
`fmg/migrate-file` and the live libraries. Nothing is persisted: the
stored version keeps its data and restoring it re-migrates on the next
read. The response now carries the snapshot's migrations.

Use `:features` the way `get-file` does for `fdata/objects-map`: a
client that does not declare it gets plain objects. The historical
feature set is not checked against the enabled one on purpose: a
version may predate a feature change.

Cover the preview of a snapshot with a pending migration and the
objects realization; both pin that the stored row is untouched. Update
the file-data memory with the preview contract and the vector/set
trap.

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

* 🐛 Decode snapshot migrations as an ordered set

`file-snapshots/decode-snapshot` handed the `file_change.migrations`
pgarray as a vector. `set/difference` falls back to index lookups when
the first collection is shorter than the second, so the pending set was
right only by accident: a stored subset happens to work, while a
stored set larger than `available-migrations` (a snapshot taken by a
newer build) marks every available migration as pending and re-applies
it to already migrated data.

Decode it as a `d/ordered-set`, the type the rest of the code and
`schema:file` expect. This also covers `restore!` and the snapshot
list, and it lets `get-file-snapshot` drop the `(into #{} ...)`
normalization it was carrying. Re-wrap the union in `fmg/migrate` so
the invariant holds even when a caller hands a plain set.

A snapshot with NULL `migrations` predates SQL migration 0140 and
keeps the format version of its time. Pin that legacy path: the
preview regenerates the applied set from that version, migrates in
memory and leaves the stored row untouched. Update the file-data
memory with the invariant and the legacy path.

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

* 🐛 Fix flaky waiting-gauge assertion in climit test

The throwable-while-waiting test asserted the exact list of
waiting-gauge publications, but the component sampler republishes
the same count when its first tick lands inside the recorded
window under load. Assert only the last value, which is the
contract the helper documents.

AI-assisted-by: muse-spark-1.3

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-02 15:02:55 +01:00
Alejandro Alonso
4dc684dd9c Merge remote-tracking branch 'origin/staging' into develop 2026-10-28 11:50:56 +01:00
Pablo Alba
0532f15cee
🐛 Fix tab on variant name doesn't validate formula (#12655)
* 🐛 Fix tab on variant name doesn't validate formula

* ✨ Move helpers to common

* ✨ Mr proposed changes
2026-10-28 11:44:38 +01:00
Andrey Antukh
dfc55a7192 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:48:49 +02:00
Andrey Antukh
9ea4c3404f Merge remote-tracking branch 'origin/main' into staging 2026-10-16 11:25:25 +02:00
Andrey Antukh
d57cdb4588 ⏪ Backport persistence related fixes from staging (#12306)
* ✨ Add async test helpers and the with-mocks* macro

Backport of the test scaffolding that the persistence retry work needs,
taken from staging. Purely additive: no existing helper changes behavior,
so the suite result is unchanged (740 tests, 0 failures).

- frontend-tests.helpers.async: ->promise, await-response, settle,
  observe and wait-for. wait-for checks immediately and then polls one
  macrotask at a time, bounded, so it fails instead of hanging.
- mock/with-mocks*: installs mocks with set! and evaluates its body in a
  generated ^:async fn, so the test reads sequentially and there is no
  done to thread. Evaluates to a promise.
- mock/asap and mock/run-mocked: run-mocked awaits the body and restores
  the originals always, in that order, reporting a rejection or a
  non-promise return as an :error.

The matching change that made with-mocks defer its body via asap is NOT
backported: it would force all 58 existing uses across 14 files into
done-chained contexts, which is a separate refactor.

AI-assisted-by: space-bunny-free

* ✨ Add an error report governor with compact payloads

One browser session could emit an unbounded number of identical error
reports, and a dropped connection was reported as an application defect
with the full payload. Both are fixed here, in one place, because the
persistence retry work that follows depends on the new submit-report
contract.

- A report governor in app.main.errors. Each report carries a
  fingerprint (report-name|type|code|hint|first stack frame); the first
  occurrence is always emitted, repeats inside a 2 minute window are
  counted, and the next emitted report carries them as :occurrences. The
  cache is bounded by evicting the oldest entry, so memory stays fixed.
- flash reserves the report before generating it, so a suppressed
  occurrence never pays the report-building cost. Its whole body now
  runs inside one ts/schedule callback, a reporting or notification
  failure is logged instead of propagating, and it returns a total
  promise so tests can await it.
- generate-report accepts :format :compact|:full. The compact payload
  keeps the context header plus type, code and uri, and skips the
  stack, the ex-data dump (it can carry request headers) and the
  last-events list.
- environment-error-types is derived from rp/retryable-types, so the
  transport classification has a single source of truth. Environment
  failures are audit-only handled-exceptions and never reach the
  exception page or the alerts. :offline gets its own handler instead
  of falling through to :default.

:occurrences travels inside the audit event :props, which the backend
schema accepts as a free-form map, so no backend change is needed.

The dedup governor test file is new: 7 tests over the fingerprint, the
pure reservation decision, and the flash pipeline.

Relates to #11726, #11743

AI-assisted-by: space-bunny-free

* ♻️ Answer commit refusals with an empty stream

commit-changes used to return nil when it refused to build a commit,
which forced every caller that wanted to observe the outcome to
special-case nil. It now returns an empty stream, so a refusal and an
empty result look the same and callers can subscribe uniformly.

Only the refusal branch changes: the accepted branch already answered
with (rx/of commit). Potok treats a nil watch result as "nothing", so
both spellings were already correct at the store level.

The 57 call sites that wrap this event in rx/of are unaffected. They
hold the ::commit-changes event itself, not the value its watch
returns, so they keep processing exactly as before.

AI-assisted-by: space-bunny-free

* ✨ Retry transient saves with backoff and a reconnect notice

Any failed update-file landed on the same path: the queue halted in
:error, the retained changes were flushed away, and the user was told
to reload. A dropped connection and a revoked permission are not the
same thing, so they now take different paths.

transient-error? splits them, checking :type and :cause-type because
persistence-failed wraps the original cause. Transient failures enter
a :retrying episode: the head commit stays queued and resends with
backoff at 2s, 8s and 20s, then falls through to today's terminal path.
Everything else is terminal and keeps the exact :error path.

- Resends rotate the ::request-id stamp only once the old request has
  left active-requests, carry the same :commit-id, and never double
  send while one request is in flight.
- Retry timers carry an episode token; a timer whose episode was
  superseded stays silent instead of sending or failing.
- One tagged reconnect notice per episode, hidden by tag on save and on
  terminal failure. Recovery is silent.
- The browser online event re-enters the runner, but only for a live
  :retrying episode, guarded by exists? js/window.
- wait-persisted-or-error waits through :retrying instead of cutting the
  wait short.

The episode keeps its :run-id, so a new local edit only joins the queue.
Re-entering the runner on every edit would bypass the backoff and spend
one attempt per batched commit.

The retry test namespace is new: 13 tests over the classification, the
backoff schedule, stamp rotation, stale tokens and the notice lifecycle.

Relates to #11724

AI-assisted-by: space-bunny-free

* 🐛 Fix stalled saves that never answer and edits lost on leave

Two holes the retry work did not close.

An update-file request that never gets an answer kept its attempt
stamp forever, so the queue could not move and the user saw a saving
state that never resolved. The request now has a 7 minute timeout that
fails it as a transient network error, which hands it to the retry path
instead of blocking. The timeout is longer than the 5 minute stall
threshold on purpose: the stalled report must fire before the retry,
not instead of it.

The leave-page warning also had gaps. It now tracks unsaved edits
directly, through sync-unload-guard, which holds :persistence in
st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept
in the queue after a failed save keep the guard on too, so closing the
tab warns instead of dropping them.

store.cljs guards its ongoing-tasks watcher with exists? js/window,
because the new tests exercise it without a window.

Four new tests: three for the guard across buffering, an in-flight save
and a retained edit, and one for a hung request that must resend and
save.

Closes #12223

AI-assisted-by: space-bunny-free

* 🐛 Skip the save when a missing font cannot be remapped

fix-deleted-fonts emitted text and typography changes even when no
replacement font existed, so opening a page that uses a deleted custom
font rewrote identical content and cleared position-data on every
visit. Every visit became a save with nothing to save.

Both loops now compare the remapped value with the original and emit
nothing when they match. The remapped path is unchanged.

Two new tests cover the no-replacement case and the remapped case.

Closes #12151

AI-assisted-by: space-bunny-free

* ✨ Add a debug helper that simulates an intermediary response

A save that never reaches the backend is hard to reproduce: you need a
proxy, a captive portal or a CDN in the way. This wraps fetch and
answers the named API method with a status and a body that carries no
Penpot error code, leaving every other request alone.

Without a duration the simulation is spent after answering one call.
With one it answers every matching call for that long, which is how to
watch the save retry, warn, keep retrying and finally recover.

  debug.simulateHttpError('update-file')
  debug.simulateHttpError('get-comment-threads', 403)
  debug.simulateHttpError('update-file', 524, 120)
  debug.clearHttpErrorSimulation()

Dev-only and self-contained: no production path reads it.

AI-assisted-by: space-bunny-free

* 🌐 Add the save retry and connection error strings

Three strings the new persistence and error paths need:

- errors.save-retrying, shown once per retry episode while the queue
  resends a queued save after a connection loss.
- workspace.header.retrying, the save indicator state for an episode
  in progress.
- errors.connection-error, shown by the network and offline handlers,
  which no longer fall through to the generic message.

Added to en.po only. The translations script also normalizes every
other locale and drops 439 strings that were already stale before this
work; that belongs in its own maintenance pass, not in a backport.

AI-assisted-by: space-bunny-free

* 🐛 Report save failures as unhandled when they are our defect

flash-persistence reported every failure it did not delegate as
handled-exception, including the ones that are plainly our fault. A
validation failure means the client sent something the server refused,
yet it landed in the audit log with a name that skips the internal
reports and the alerts. Nobody was paged.

The report type now follows the cause. :validation, :server-error and
:internal report as unhandled-exception, so they alert. The rest are
unchanged.

Two exclusions are deliberate:

- Environment failures stay handled. They reach this point after the
  retry budget is spent, and the user's connection is not our bug.
- :assertion stays handled, because its own handler already reports it
  that way and the two paths should not disagree.

The cause type is read from :cause-type first, since persistence-failed
wraps the original under :type :persistence. Reading :type alone would
match the wrapper every time and change nothing.

The user still gets the same sticky toast and report link; only the
audit event name moves.

The test that asserted a validation failure is "still notified" now
asserts which event name it gets, across the bare and wrapped shapes,
the environment types that must stay handled, and an unclassified one.

AI-assisted-by: space-bunny-free

* 🐛 Unblock persistence when opening a file after a terminal failure

A terminal save failure set the state to :error and left the queue
alone. recover-persistence only resumed for :save-permission-denied, so
for every other terminal code both of its branches were false and it
emitted nothing. Leaving and reopening the same file therefore changed
nothing: the editor stayed halted until a reload, which rebuilds the
whole store and loses the queue anyway.

The path had two jobs under one name. initialize-persistence fires when
the workspace view mounts and tears down the previous subscription;
recover-persistence fires when a file is opened or the role changes.
They are now separate: initialization picks up a queue left behind by a
previous session, and deliberately does not lift a halt, so undeliverable
edits are released when the user acts rather than when the view mounts.

Recovery lifts the halt by releasing what can never be saved.
release-failed-file drops every queued commit of the file that failed,
then resume-persistence clears the error and settles an empty queue to
:saved. Retaining them until that point is deliberate: it is what keeps
wait-persisted-or-error rejecting, which is what stops
workspace/versions.cljs from building a snapshot out of unsaved edits.
Releasing at failure time would make waiters resolve instead.

Permission loss is treated as permanent too, which the previous
exception got wrong. The backend raises :not-found for a missing edit
permission, so a resend does not succeed — it routes the user to the
exception page. The old branch made that reachable: it resumed the save
whenever the cached can-edit flag said yes, and the server disagreed.
The only case where the flag can return to true is a team switch, where
the queue is gone anyway, so nothing real is lost by releasing.

Because the queue is shared, dropping the failed file is also what lets
the other files' pending edits drain. One file that cannot be saved no
longer blocks everything else.

Tests: the failure retains the queue and notifies once, recovery
releases it without replaying, a failed file does not block a second
one, permission loss is released rather than resumed, and initialization
leaves a halt alone.

AI-assisted-by: space-bunny-free

* ✨ Offer a reload on the save failure toast

A terminal save failure stops autosave for the whole session, but the
toast asked the user to reload without offering the reload. It listed
five implied actions, none of them reachable from the banner, and it
never said that the changes could not be saved.

flash now takes a :links vector of actions, appended after the download
link that :report-link? produces, so a caller can add recovery actions
without reaching into the report handling. flash-persistence passes a
reload built from labels.reload-page and location.reload, the pattern
already used elsewhere in the app for a full restart.

The message now says what happened, what is lost, and what to do about
it: autosave has stopped for this session, the changes since the last
successful save cannot be saved, and either reloading or leaving the
file and opening it again restarts it. Naming both matters, because the
toast links are easy to miss.

Saying the edits are lost is not optional: recovery releases them, so
without it the user finds out when they come back to the file.

Translated in en.po and es.po only. The translations script would also
normalize all 40 locales and drop entries that were already stale, which
is a maintenance pass of its own.

The reload callback cannot be invoked under the test runner, since
js/location does not exist there, so it is covered by its label and by
being callable. Whether the reload works is a manual check.

AI-assisted-by: space-bunny-free

* 🐛 Classify the responses the backend never wrote

A reply written by a proxy instead of the backend carries no Penpot
error code, so handle-response raised it as :internal. A 504 on a save
skipped the retry cycle and stopped autosave at once, and because
:internal reports as unhandled, it raised an alert. In a standard
deployment this is how a slow save ends: nginx answers 504 after 300s,
before the client's 7 minute timeout fires.

The status now decides the type:

- 504 and the Cloudflare 520-524 family raise :gateway-error, which
  joins retryable-types. Saves retry with backoff like 502 and 503,
  GETs retry, and a save that spends its retries reports as handled,
  because the report rules derive from the same set.
- 429 raises :rate-limit, and any other error status
  :unexpected-response. Neither retries; both show a toast instead of
  the error page.
- :internal is kept for a response below 400 that does not decode.

Bodies we cannot read are cut to 500 characters, since proxy error
pages run to tens of kilobytes.

Tests: classification and retries per status, the transport handlers
keep the page, a 504 and a 524 built by the real handle-response go
through the save path and land on a retry, and the new types stay
handled once the retries are spent.

AI-assisted-by: claude-opus-5-5

* ✨ Keep retrying failed saves on a slow cycle

Once the three backoff retries were spent, a save that failed on the
connection stopped autosave like any terminal failure, and opening a
file again dropped its edits. A connection that comes back after a
minute lost the work all the same.

A transport failure that outlasts the backoff now arms a slow cycle:
one silent resend every 30s, until a save lands or 24h have passed
since the failure started. The cycle stops when persistence restarts,
the queue resumes another way, or the workspace closes.

A save that keeps failing slowly is not resent for a day. A gateway
that answers 504 after its own timeout may leave the backend applying
the save, so each resend can cost the whole save again, and the failing
file holds back every other file's queued edits. An attempt that fails
after 90s or more counts as a slow failure; at three, the slow cycle
stops sending the commit and the save fails as before. Fast failures,
such as being offline, never count.

- The first failure shows a toast that says the changes are kept and
  keep being sent, with no reload link, since a reload loses them. A
  save that lands hides it.
- Opening a file sends a queue still inside its retry window once
  more instead of releasing it. Failures outside the window, and every
  non-transport failure, are still released.
- A freshly loaded file gets back the queued edits of its version, so
  reopening it shows the changes that are still waiting to be saved. An
  edit that no longer applies to the loaded data leaves the file as the
  server sent it, so the file still opens; the edits stay queued.
- A commit whose earlier request is no longer in flight is simply sent
  again under the same commit id: changes are idempotent, so there is
  no unknown-outcome guard any more. A request still in flight still
  blocks a parallel send.

flash takes a :tag so the save failure toast can be hidden by tag.

Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal
failure does not, the cycle stops when the window closes or at the slow
failure limit, recovery resends instead of releasing, reopening restores
only the loaded file's edits and survives one that no longer applies,
and the retrying toast offers no reload.

* 🐛 Turn off the leave-page warning when failed edits are released

The leave-page warning only followed the queue on appends and discards.
Opening a file again releases the edits of a file that failed for good,
but that release changed the queue without either event, so the warning
stayed on with nothing left to save. Leaving the page asked about
unsaved changes until the next save went through.

The warning now also follows the release.

Tests: releasing the last failed edit turns the warning off.

* 🐛 Stop retrying failed saves outside the workspace

Leaving the workspace was meant to stop the slow retry cycle, but only
a cycle already armed stopped. A backoff timer kept firing after the
user left, and a backoff spent outside the workspace armed a slow cycle
that kept sending for up to a day from the dashboard. Those sends check
the edit permission of whatever team is open, so a switch to a team
where the user only views failed the queued edits for good, and the
next file they opened dropped them.

Leaving the workspace now suspends retries. A pending backoff timer
goes stale, and while suspended a transport failure schedules no
backoff, a spent backoff arms no slow cycle and the online event sends
nothing. The edits stay queued and a request in flight still lands.
Opening a file lifts the suspension and resends a retrying save whose
backoff was cut short; a role change does not lift it.

Tests: leaving stops a pending backoff, a failure after leaving
schedules no retry and ignores the online event, a backoff spent
outside the workspace arms no slow cycle, and opening a file saves the
held edit in each case.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-15 15:54:12 +02:00
Dominik Jain
be17fabc6a 🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
  across dashboard and workspace navigation within one browser tab.
  Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
  loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
  cleanup of listeners, timers and UI resources. Global scope does not
  imply automatic startup or persistence across a browser reload.

Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
  workspace, while user, theme and UI facilities remain available on
  the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
  none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
  defaulting to the current team and resolving only once the requested
  file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
  exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
  return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
  reacquire file, page and shape references after changing files.

MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
  authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
  well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
  and each tab connects explicitly through dashboard or workspace
  controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
  during navigation; disconnect on request and close on disable/logout.

MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
  excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
  to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
  the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
  not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
  navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.

Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
  readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
  that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
  builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
  still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
  document or iframe reload, preserving facade identity and storage.

AI-assisted-by: gpt-6
2026-10-07 14:58:32 +02:00
Andrey Antukh
f9b8f1ba75 Merge remote-tracking branch 'origin/staging' into develop 2026-09-28 10:31:20 +02:00
Alonso Torres
a31409617f
🐛 Fix loop with context menu (#11891) 2026-09-28 09:08:28 +02:00
Eva Marco
7e3f779d8c
🎉 Add tokens to the canvas (#11923)
* ✨ 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
2026-09-28 08:53:42 +02:00
Alonso Torres
420aa981b2 🐛 Fix stalled saving states (#11699) 2026-09-17 11:25:36 +02:00
Alonso Torres
ff88a055fd
🐛 Fix stalled saving states (#11699) 2026-09-15 16:04:00 +02:00
Belén Albeza
91b433985a
🎉 Add context menu for v3 editor (#11677)
* 🎉 Add a context menu to the v3 text editor

Right-clicking while editing a text shape now opens a menu with Cut,
Copy, Paste and Select all, so those actions stop being reachable only
from the keyboard. Cut and Copy are disabled when nothing is selected,
and with no selection the caret first moves to the click position.

The menu is a new `:text` kind of the workspace context menu, so it
inherits the existing positioning, styling and shortcut hints. Its
entries emit events that drive the WASM editor through the same calls
the keyboard path uses, so a menu edit commits exactly like a
keystroke: same undo step, same layer rename.

A secondary click has to leave the text alone until the menu acts on
it. The pointer handlers now ignore it, both the right button and the
macOS Ctrl+Click that reports button 0, so it can no longer start a
drag-selection over what was selected. Pressing the mouse on the menu
would blur the capture surface and end the session, so the menu
cancels the default action of `mousedown` while a text is being
edited.

Paste reads the system clipboard through `navigator.clipboard`, since
a menu click carries no clipboard event.

Closes #10914

AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ✨ Add Playwright tests for the text editor context menu

Eight tests under "Text Editor context menu" in the v3 editor spec.
They load the existing lorem ipsum fixture rather than drawing a shape,
enter edit mode, and drive the menu with a real right click.

They cover the four entries, that the right click keeps the selection
(copy returns the whole text), that a right button drag no longer
replaces it, that moving the caret abandons a staged typography, and
that cut and copy are disabled at a collapsed caret. Typing right after
a menu action also shows the editor kept the focus while the menu was
open.

AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ♻️ Use a test id instead of a DOM id in the v3 editor spec

AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:53:07 +02:00
andrés gonzález
6f63a5fcbf
✨ Persist hide resolved comments preference (#10694)
Store the hide-resolved filter in user storage and restore it when
entering the workspace or viewer, consistent with canvas comment
visibility from #10239. Match the comments filter separator styling to
the main menu and add the missing mentions option in the viewer
dropdown.

Closes #10686

Signed-off-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:14:37 +02:00
Alejandro Alonso
fb22c1547c
🐛 Skip component sync for derived WASM text layout commits (#11490)
Post-font-load selrect fixes and position-data regeneration write
sync-attrs on texts inside mains. That made watch-component-changes
treat them as edits and run touch/sync per component, freezing large
files. Mark those commits with skip-component-sync? (same idea as
translation?) so only real user edits propagate.
2026-09-03 13:43:36 +02:00
Andrey Antukh
0a14a1f3db Merge remote-tracking branch 'origin/staging' into develop 2026-08-28 11:22:52 +02:00
Andrey Antukh
1baa8bb29f
🐛 Center canvas and select layer when navigating layer search results (#10422) (#11391)
When using previous/next buttons in the layer search panel, the canvas
now centers on the matched layer and selects it in the layers panel.
Previously only the internal search cursor was advanced without any
visible canvas or selection update.

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-08-27 18:33:48 +02:00
Alonso Torres
217284b1e1
✨ Improve path operations and edition (#10807)
* ✨ 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>
2026-08-27 10:51:10 +02:00
Andrey Antukh
81c3b3cd56
📎 Update copyright name on file header (#11346) 2026-08-25 11:55:10 +02:00
Andrey Antukh
64a52d6b04 Merge remote-tracking branch 'origin/staging' into develop 2026-08-23 21:01:31 +02:00
Andrey Antukh
47d599fe34
✨ Persist binfile manifest and emit workspace audit events (#11106) (#11138)
Persist binfile manifest metadata in file_data on import so file
statistics are available at open-workspace time. Emit a new
open-workspace-file audit event enriched with file statistics:
page count, shape count, component count, linked libraries,
design tokens, and whether the file is a shared library.

Closes #11106

AI-assisted-by: mimo-v2.5-pro
2026-08-21 14:37:21 +02:00
Andrey Antukh
fb9f92ae6a Merge remote-tracking branch 'origin/staging' into develop 2026-08-17 13:52:46 +02:00
Andrey Antukh
0797d7235a
🐛 Fix workspace crash on rapid sidebar measures input changes (#10793) (#10794)
The sidebar measures panel numeric inputs (X, Y, width, height,
rotation) emitted one full apply-modifiers commit per DOM event with
no throttle: every arrow key-repeat, wheel tick and scrub pointermove
became update-positions / update-dimensions / increase-rotation. A
sustained gesture starved the React renderer and crashed the
workspace with error #185 (Maximum update depth exceeded).

Coalesce those bursts at the data layer (potok), following the
update-position-data debounce pattern in texts.cljs:

- update-positions is now burst-coalesced in place (its only caller
  is the measures panel); new update-dimensions-coalesced and
  increase-rotation-coalesced variants are used by the measures
  panel, while the immediate events keep serving plugins, variants
  and token application (including the delta? rotation path).
- The first event of a burst commits immediately (leading edge, so
  single edits stay synchronous); further ticks commit at most once
  per 50 ms (throttle); a trailing debounced flush guarantees the
  exact final value lands. All payloads are absolute values, so
  keeping the latest queued value per shape/attribute is lossless.
- Pending payloads are drained atomically and stale shape ids
  (deleted mid-burst) are skipped. The drain stream lives until the
  workspace is finalized, so bursts reuse a single subscription.
- Fewer commits per burst also means fewer undo entries; scrub drags
  still produce a single entry via the input's outer transaction.

Tests: new frontend-tests.logic.sidebar-transform-coalescing-test (8
tests, legacy SVG and WASM renderer branches) guards the invariant
that a 20-event burst commits the exact final value in a handful of
commits. The previously unregistered update-position-test is wired
into the runner with WASM mock fixtures (it fails in full-suite
context without them due to a pre-existing global mock-state issue).

AI-assisted-by: kimi-k3
2026-08-17 11:51:24 +02:00
Alejandro Alonso
0de47302a6 Merge remote-tracking branch 'origin/staging' into develop 2026-08-11 12:47:36 +02:00
Luis de Dios
86c563f11f
🐛 Fix font family typography asset persist across files in new created text layers (#11134) 2026-08-10 11:35:51 +02:00
Elena Torró
38b990ef90
🔧 Add exporter headless backend (#10875)
* ✨ Add headless wasm render backend to the exporter

* ♻️ Move render-wasm bridge to common and split wasm builds

* 🔧 Upload builtin font variants in the wasm exporter

* ♻️ Move shared font and resources utils out of render_wasm

* ⚡ Fetch only the exported roots in the wasm exporter

* ⚡ Bound save_layer rects in the vector export path
2026-08-06 16:13:06 +02:00
Alejandro Alonso
4cd34b22e2
🐛 Fix wasm context restore guards (#10824)
* 🐛 Prevent WASM panics during WebGL context restore

Delay context-restored until reload finishes and no-op app
mutations while reloading so resize/modifiers cannot panic
mid-teardown.

* 🐛 Fall back to CLJS bool content when WASM is not ready

Returning nil from calculate-bool would persist empty path content
during context loss/reload; use path/calc-bool-content instead.
2026-07-29 10:06:52 +02:00
Alonso Torres
cb21f6401a
✨ Add waitForLayoutUpdate plugin method (#9898)
* ✨ Add waitForLayoutUpdate plugin method

* ✨ Refactor internal wait for tasks
2026-07-28 14:44:30 +02:00
Juan de la Cruz
5dff551f31
🎉 Add page multi-selection in the workspace sitemap (#10581)
* 🎉 Add page multi-selection in the workspace sitemap

* ♻️ Simplify page selection state updates with single assoc

* 🐛 Fix SCSS issue

* ♻️ Update some components to new syntax

* ♻️ Adapt SCSS to the new guidelines

* ♻️ SCSS cleanup

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-07-21 14:26:03 +02:00
Pablo Alba
0ff73ae711
✨ Add prune-unrelated-items debug utility (#10687) 2026-07-21 12:31:23 +02:00
Pablo Alba
95cfbf5f7c
✨ Improve workspace debug sidebar workflow (#10678) 2026-07-14 12:42:19 +02:00
Andrey Antukh
a004f7cd84 Merge remote-tracking branch 'origin/staging' into develop 2026-07-08 15:44:37 +02:00
Elena Torró
d656d342fe
🐛 Fix text selrect size (#10566)
* 🐛 Fix text selrect size

* 🐛 Fix blinks on complex files

---------

Co-authored-by: Belén Albeza <belen@hey.com>
2026-07-08 13:56:58 +02:00
Andrey Antukh
c4e72fd7f9 Merge remote-tracking branch 'origin/staging' into develop 2026-06-30 16:23:23 +02:00
Luis de Dios
46f5346045
♻️ Merge :thumbnails and :thumbnails-meta into single state key (#10021)
* ♻️ Merge :thumbnails and :thumbnails-meta into single state key

♻️ Unify thumbnail refs in a single ref

🐛 Fix test

* ♻️ Update tests
2026-06-30 14:37:27 +02:00
Alonso Torres
ca81776d04
🐛 Fix problems when dragging frame with comments (#10460)
* 🐛 Fix undo frame position not undoing comments

* 🐛 Fix problem with hover capturing dragging event

* 🐛 Fix watch updates for comment bubbles

* 🐛 Fix "Maximum update depth" crash on SVG shape transforms

* 🐛 Fix comment geometry problems
2026-06-30 13:33:47 +02:00
Alonso Torres
f993f203bd
🐛 Fix problems with plugins API (#10412)
* ✨ Adds static dispatch safe stubs in tests

* 🐛 Fix shapesColors metadata key to match ColorShapeInfo

* 🐛 Fix CommentThread.remove rejecting the owner's own threads

* 🐛 Fix page.removeCommentThread throwing on a spurious Promise

* ✨ Implement ShapeBase.swapComponent in the plugin API

* ✨ Expose File.revn in the plugin API

* 🐛 Fix FileVersion.createdAt calling Luxon method on a js/Date

* 🐛 Fix plugin font/typography application to text and ranges

* 🐛 Default plugin overlay interaction position for non-manual types

* 🐛 Fix plugin interaction setters passing an id-only shape

* 🐛 Fix grid addColumnAtIndex rejecting valid track types

* 🐛 Expose libraryId on library color/typography/component proxies

* ✨ Implement LibraryTypography.setFont in the plugin API

* 🐛 Fix typography.applyToTextRange reading unexposed range bounds

* 🐛 Fix utils.geometry.center argument mismatch

* 🐛 Fix localStorage.removeItem calling getItem

* 🐛 Fix shape backgroundBlur proxy key casing

* 🐛 Report boolean shape type as 'boolean' in the plugin API

* 🐛 Return the resulting paths from plugin flatten

* 🐛 Make plugin z-order methods act on the target shape

* 🐛 Make is-variant-container? return a boolean

* ✨ Implement Group.isMask in the plugin API

* 🐛 Return a shape proxy from TextRange.shape

* 🐛 Return the duplicated set from TokenSet.duplicate

* 🐛 Fix theme addSet/removeSet reading set name with a keyword

* 🐛 Accept string fontFamilies token value in the plugin API

* 🐛 Fix combineAsVariants ignoring the passed component ids

* 🐛 Fix board removeRulerGuide ignoring its argument

* 🐛 Fix board guides setter schema and parser

* 🐛 Avoid 0-byte allocation when syncing empty grid tracks

* 🐛 Validate grid track indices in the plugin API

* 🐛 Return null for empty input in group() and centerShapes()

* 🐛 Return TokenTypographyValue[] from a typography token's resolvedValue

* 🐛 Return TokenShadowValue[] from a shadow token's resolvedValue

* 🐛 Return string[] from a fontFamilies token's resolvedValue

* 🐛 Clear mutually-exclusive reps when setting LibraryColor gradient/image

* 🐛 Add readonly tags to types, deprecate Image type

* 📚 Update plugins changelog
2026-06-29 17:32:15 +02:00
Belén Albeza
e9c0982a94
✨ Disable hot swap of render engines (#10444) 2026-06-26 15:04:52 +02:00
Andrey Antukh
540bc97787 🐛 Remove inconsistent library :is-indirect handling on frontend state
Related to #9506
2026-06-18 11:29:21 +02:00
Andrey Antukh
b391a4c8d3
♻️ Add mcp integration state management refactor (#10226)
* ♻️ Add mcp integration state management refactor

* 🐛 Fix access tokens do not appear

* ♻️ Refactor some names

* ♻️ Refactor token deletion

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-06-16 18:35:30 +02:00
Andrey Antukh
d44c6250ea Merge remote-tracking branch 'origin/staging' into develop 2026-06-15 13:04:19 +02:00
Juan de la Cruz
ddeaf3ce2a
✨ Add MCP status button to workspace toolbar with single-tab connection control (#9930)
* ✨ Add MCP connection badge to the workspace toolbar

* ✨ Add MCP status button with single-tab connection control

* ♻️ Extract component for MCP indicator in the toolbar

* ♻️ Some improvements

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-06-15 11:29:25 +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
Alonso Torres
5a3a855b24
🐛 Fix problem with position-data not present
* 🐛 Fix problem with position-data not present

* 🐛 Async set-objects wait before calculate-position-data
2026-05-26 09:50:23 +02:00
Belén Albeza
4a73a97a32 🐛 Fix shapes not being rendered after render switch 2026-05-21 16:31:56 +02:00