Closing the v2 text editor with Escape could crash the workspace. The
text options panel renders once more with the editor it saw last, after
closing has disposed it, and reading the style of a disposed editor
throws.
The panel now takes the values from the shape when the editor is gone.
- Ending a drag inside the frame the shapes already belong to sent an
empty reparent save and bumped the revision. The move commits only
when some shape changes parent.
- When a user restored a version, other sessions reloaded the file with
no notice. They now see a dialog naming who restored which version
(by its name, else its date), saying that later changes are no longer
in the file and, when they had changes not saved yet, that those were
lost.
- Exports drawn in the browser (PDF, SVG, and PNG when the WASM renderer
draws it) and duplicating a file from the dashboard started at once,
so an export could show changes a pending save then rejected, and a
copy could miss queued edits. Both wait for the file's pending saves.
A client export warns that it includes unsaved changes; a copy warns
that it misses them.
A rollback can remove or change shapes the UI still refers to.
- The selection drops shapes that are gone and the color picker that
edited them closes.
- The undo history is cut at the reverted action; it stays when no
entry holds a reverted change. Undo and redo commits carry real undo
changes and the history index they moved from, so a reverted Ctrl+Z
puts the change and the index back.
- An open path edition whose shape the rollback changed closes without
saving the editor's stale copy. The v2 text editor loads the reverted
text again; the WASM editor already showed it.
- The Alt measurements look the hovered shape up in the current data.
- Plugin setters, resize() and rotate() on a removed shape throw
"Shape not found", whatever the plugin's throwValidationErrors flag.
- A save that failed on a short platform failure (a lost or refused
database connection, lack of resources, a server shutting down, a
serialization failure or deadlock, a socket or HTTP timeout, a host
that did not resolve, an interruption) answered 500, which stops
autosave. It now answers 503, which the frontend retries. Other I/O
and database errors keep answering 500, timeouts 504.
- A save answered not-found no longer replaces the workspace with the
access page. It is a terminal save error: the workspace stays open
with the failed save notice, report and reload link. When edit rights
are lost, the notice says how many changes could not be saved.
- A library sync marked the library synced three seconds later,
whatever happened to its save. A rejected sync was undone but stayed
marked: copies stayed out of date and the "updates available" notice
never came back. The mark now waits for the save, a rollback of the
sync brings the notice back, and a failed mark (e.g. the user just
became a viewer) is logged instead of leaving the workspace.
- While saves fail the workspace is read-only. The view-mode banner
says editing is disabled because changes can't be saved, with the
Inspect banner's size and bold lead and no Done button. Users can
still switch pages, pan, zoom and inspect. The lock lifts when a save
lands and brings back the mode the user had or picked meanwhile; the
WebGL context lock keeps the workspace read-only if it lasts longer.
The lock lives in app.main.data.persistence.lock.
- A viewer is never locked. A role change that leaves the user a viewer
keeps the edits that lost permission, queued and on screen, and shows
the count notice again. The role change that brings edit rights back
releases them and rolls them back on screen, as a rejected save would.
Any other terminal failure is released by the next role change or file
opened.
The debug faults `io` (socket timeout, 503) and `eof` (500) cover both
sides of the I/O rule.
A rollback reverts whole undo groups, across files and saves, so that
no user action is left half done. Some actions still split:
- An action that commits several times inside one undo transaction
(e.g. applying a token to several shapes) gave each commit its own
group, so a rejection could revert part of it: shapes ended up linked
to a token whose value was never applied. An undo transaction now
opens with a commit group that every commit made inside it takes.
- The commit that undoes saved commits of a rejected action joined an
open transaction's group, so a later rejection there reverted the
undo too. It gets a group of its own.
- Emptying the open transaction during a rollback dropped its group, so
its later commits were no longer one unit. It keeps its group.
- Waiting for a save ended as soon as the queue emptied, while later
commits could still wait in the buffer. Waiters now also wait for an
empty buffer.
A test covers a token action of three commits in one save whose third
commit is rejected.
A save whose changes failed validation was rejected as a whole: autosave
stopped, the edits made after it piled up unsent, and a reload lost
them all, the valid ones included.
The frontend now sends each commit of a save as its own group with its
commit id (:changes-with-metadata). When applying and validating all of
them at once fails, the backend searches by bisection for the first
commit that fails, keeps the commits before it and answers with
:rejected (that commit and every later one). Only the kept changes are
stored, snapshotted and broadcast, so other users never see the rejected
ones. Flat :changes and groups without ids stay all-or-nothing; database
and network errors still fail the whole request. A rejection without an
error type is reported as an internal error.
The frontend rolls the file back to what the server holds: it undoes
every pending commit of that file, applies again the remote changes that
arrived meanwhile, and redoes the kept commits. Undoing only the
rejected commits would write stale values over a peer's later change to
the same attribute. The rejected and later commits leave the queue, the
buffer and the undo history, and a warning that hides after ten seconds
tells the user. The pure bookkeeping lives in
app.main.data.persistence.commits.
The pending notice of a commit the rollback dropped no longer leaves the
save status at pending, which made "Pin this version" hang and fail.
Debug tools drive these cases by hand: debug.commitInvalidChange() makes a
save fail validation, holdSaves() delays the answers, failNextSave() and
failNextRollback() fail them on purpose (backend flag debug-save-faults),
and persistenceState() shows the queue.
A remote commit undoes the pending local commits, applies the remote
changes and replays the local ones, so the local file ends like the
server will: remote first, local last. Only queued commits took part;
the ones still waiting in the persistence buffer (up to ~3s of edits)
stayed under the remote change. When both touched the same attribute,
the remote value won locally but lost on the server.
The buffer now lives in the store ([:persistence :buffer]) instead of
an rx buffer, so the rebase can reach it. A flush merges it per file
into queue entries that keep the list of commits they carry, and the
leave-page guard reads buffer and queue from the store. A buffer left
by a previous session is queued when persistence starts.
* ♻️ Move the dashboard header styles to the DS
The page header of the dashboard and settings pages took its styles from
common/refactor/common-dashboard.scss. Move the rules still in use to
dashboard/common/header.scss, written with DS tokens:
- The page titles become heading* (title-large) with a class instead of
a bare h1; the team section links get a class, and the active class
moves from the li to the link.
- Drop the rules that matched nothing: .element-name, .context-menu,
.icon and .btn-secondary inside the header, and the global .btn-primary
and .btn-secondary (only the fonts page uses them; it keeps the old
import until its open change lands).
- The sidebar and subscription styles imported the file without using
it; drop the import.
The header looks the same (0 px in the new visual test). The title line
height goes from 1.15 to 1.4, which does not move the text.
AI-assisted-by: claude-opus-5-5
* ♻️ Move the dashboard libraries and search styles to the DS
Replace the deprecated spacing, size and border variables with DS
tokens and logical properties, and drop the refactor/ import.
- The search placeholder icon gets a class for its color instead of a
.icon svg rule; its xl size already gives the 32px box.
- Delete the selectors no markup uses: .dashboard-projects in both
files and .dashboard-shared in search.
No visual change (0 px in the new libraries and search visual test).
AI-assisted-by: claude-opus-5-5
* 🔥 Move the fonts page header to the DS and delete common-dashboard
The fonts page was the last user of common/refactor/common-dashboard.scss:
its header now uses the shared DS header styles and a DS heading, like
the other dashboard pages. Nothing else imports common-dashboard, so it
is deleted.
No visual change.
AI-assisted-by: claude-opus-5-5
* 📎 Remove a double blank line in the dashboard sidebar styles
Prettier rejects the two blank lines left after the imports, which made
the frontend lint fail in CI.
AI-assisted-by: claude-opus-5-5
* 📎 Remove a blank line between the subscription style imports
Stylelint (at-rule-empty-line-before) rejects the blank line left between
the @use rules after the common-dashboard import was dropped.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep multi-select variant switches refreshing every instance
Multi variants-switch ended each swap with select-shape, which replaced
the selection, interrupted layout/WASM work, and navigated once per copy
so only one instance looked updated under flex. Skip per-swap select and
layout in the batch, re-read live geometry, coalesce layout ids via a
companion DataEvent, then restore selection once. The same path covers
Assets component-multi-swap. Frontend tests lock select-shape count,
layout coalesce, and mixed no-op selections.
Closes#12829
AI-assisted-by: Composer
* ♻️ Use DS icon buttons in the path editing bar
The path editing bar now uses DS icon buttons (ghost, tooltip below) and
DS icons, like the top toolbar. The toggles (draw/move mode, handler
types, snap) use aria-pressed, and the handler type trigger uses the DS
flyout indicator, with no tooltip because its menu opens on hover.
This also shows the handler and snap icons again: since #12659 their
SVGs draw with currentColor, and the legacy %button-icon set color to
transparent, so they were invisible.
Add a visual test of the bar, its hover and the handler menu.
AI-assisted-by: claude-opus-5-5
* ♻️ Move the path editing bar styles to the DS
Drop the deprecated styles import, resolve --panel-border-color to
--color-background-quaternary and use logical properties, except top and
height of the bar, which its transition names.
Delete the selectors the DS buttons left unused: .handler-trigger and
.flyout-indicator (the DS icon button draws its own indicator).
No visual change.
AI-assisted-by: claude-opus-5-5
* 📎 Add the blank line stylelint expects in the path bar styles
Stylelint (scss/double-slash-comment-empty-line-before) rejects the
comment right after a declaration, which made the frontend lint fail in
CI.
AI-assisted-by: claude-opus-5-5
* ⚡ Keep only the shape data the worker reads
The worker kept a full copy of the current page for its selection and
snap indexes. It now keeps only the attributes the indexes and the
change replay read, and re-indexes only the shapes that changed.
On a 52k-shape page the worker heap goes from 698 MB to 286 MB.
Closes#12740
AI-assisted-by: claude-opus-5-5
* ♻️ Share the worker re-trim with its replay test
The worker and its replay test now share `retrim-objects`.
AI-assisted-by: claude-opus-5-5