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 it.
- 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.
* 🐛 Stop thumbnail subscribers re-rendering on every store change
A thumbnail loaded from the server keeps a media id as :uri, and
workspace-thumbnail-by-id resolves it into a new map on each read. The
ref compared values with identical?, so every root frame, every
component in the assets panel and the interactions layer re-rendered
on every store change, mouse moves included. Compare by value instead.
This does not loop by itself, but it adds work to every commit and
brings other update loops closer to React's limit.
Relates to #12580
AI-assisted-by: claude-opus-5-5
* ✨ Report update loops throttled by the update scheduler
When the scheduler throttles an update loop, it only logs a console
warning, so a loop that no longer crashes leaves no trace. Send an
error report too, with the events in flight and the last events, at
most once per report window.
Relates to #12580
AI-assisted-by: claude-opus-5-5
* 🐛 Report throttled update loops as unhandled exceptions
The throttled update loop report used the handled-exception event, which
only lands in the audit log: error reports and alerts read
unhandled-exception and exception-page. A throttled loop is an
application defect (its cause has :type :internal), and the point of the
report is to find those loops, so it is now an unhandled exception.
AI-assisted-by: claude-opus-5-5
* 🐛 Report raw fetch failures as connectivity errors
A fetch that fails outside app.util.http (zip.js, plugins, schema
loads) rejects with a bare TypeError such as "Failed to fetch". It
reached the global handlers and was reported as an unhandled crash,
with no stack and no URL.
The global handlers now wrap the browser's network TypeErrors as
:network (or :offline when the browser says so), so they get the
connection toast and a compact handled report. Response body reads
in app.util.http also wrap a TypeError as :network with the request
uri, as fetch itself already did.
Relates to #12581
AI-assisted-by: claude-opus-5-5
* 🐛 Treat only network TypeErrors on body read as connectivity errors
process-response-type turned every TypeError from a body read into a
:network error, so a bug such as "body stream already read" showed a
connection toast. It now uses network-failure?, which moves to
app.util.http so that both the http client and the global error
handlers share it. Add Firefox's "Error in body stream" message.
Tests now check :offline and :network separately by setting
navigator.onLine, and read-error builds on async/->promise.
AI-assisted-by: claude-opus-5-5
Setting a library as tokens source did not update shapes that use tokens,
because set-tokens-source never ran the token propagation that every other
token operation runs. Run it after the commit.
Going back to the local tokens, or unlinking the library that was the
source, left no local set active: the file has a single tokens status, and
switching to a library replaced it with the library's one. Keep the status
of the file's own tokens lib aside in a new optional :own-tokens-status
attribute while an external library is the source, and restore it when the
file is its own source again. Exporting without libraries and absorbing a
deleted source library restore it as well.
Closes#12430
AI-assisted-by: claude-opus-5-5
LibraryComponent.restore() did not check that the component belongs to
the current file's library. Called on a deleted component of a connected
shared library, it restored that component and saved the change to the
library file, while remove(), annotation and duplicate() reject such
handles with a validation error.
restore() now rejects them the same way: "Can only restore components
from the current file's library".
Closes#12607
Fixes the problems found after #12326 and listed in #12327.
Mousetrap now tries several names for each key press, in order, and
fires the first one with a binding: letters and digits by keyCode when
a modifier is held, punctuation by position (layout map, or a US table
when the browser has none), the typed glyph, and the typed glyph
without the Shift it needed. With AltGr the typed glyph comes before
the position. The shortcut recorder asks Mousetrap for the key it
stores, so a recorded shortcut always fires.
This makes "?" and "+" work on every layout, Shift and Ctrl+Shift with
punctuation work in Firefox and Safari, Option+letter work on macOS,
and AltGr+º hide the UI on Linux with a Spanish layout.
Chrome on Linux returns the map of the first configured layout, not
the active one. Mousetrap now drops the map when an unmodified
punctuation key types something else.
The layout twins are no longer needed and are removed: "shift+'" made
"?" zoom to the selection on Spanish layouts. The Add node shortcut is
removed too: on US layouts its key is the "+" zoom key, and on Firefox
with a Spanish layout it cannot fire. The Firefox and Safari warning
in the shortcuts settings is gone, since those shortcuts now work
there. The user guide and translations follow these changes.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix restrict per-side stroke width tokens to rectangles in plugins API
* ♻️ Reuse per-side stroke restriction check across plugin API
Move the per-side stroke width restriction into the public
`per-side-stroke-attrs-restricted?` helper, now taking `page-id`, and reuse
it from both `shape.applyToken` and the token application path. Keep the
error message in a single `per-side-stroke-widths-message` def used by all
three rejection sites.
Wait for token resolution in the uniform stroke width regression test
instead of relying on a bare `settle`, which could assert before the
applied token lands.
AI-assisted-by: deepseek-v4.1-flash
---------
Co-authored-by: Luis de Dios <luis.dedios@kaieldos.net>
* 🐛 Skip unknown entries when importing shortcuts
The shortcuts import rejected the whole file when it held a single entry
it did not know, so a profile exported from an older Penpot version, or
edited by hand, could not be imported at all. The spec in #9924 asks to
skip unknown keys, tell the user which ones, and apply the rest.
Importing now keeps the entries that map to a known shortcut with a valid
key combination and skips the rest: unknown contexts, unknown shortcut
keys, and commands that are not a key combination. The import modal
lists the skipped entries. The file is only rejected when it is not a
map or holds nothing that can be applied.
A context whose entries are all skipped is left out: an empty context
clears the user's shortcuts on import, so keeping it would wipe them.
valid-command? reads a command the way mousetrap does, so it accepts
every default shortcut and rejects strings mousetrap could never match.
AI-assisted-by: claude-opus-5-5
* 📎 Sort requires in shortcuts settings test
cljfmt expects the namespace requires in alphabetical order.
AI-assisted-by: claude-opus-5-5
* ✨ Reserve cancel, copy and paste shortcuts
Cancel, copy and paste are no longer customizable, in the workspace and
in the path editor, which shares their shortcut keys. Their key
combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the
shortcut recorder warns and blocks saving one of them for another
action, and the import skips entries that use them.
Reserved combinations come from the non-customizable workspace
shortcuts, so marking another one as non-customizable reserves its key
too. The path editor's cancel also binds V, the move tool key, so path
shortcuts are left out. Combinations compare the way mousetrap matches
them: aliases and modifier order do not matter.
apply-custom-overrides now ignores overrides of non-customizable
shortcuts. An override saved before this change would otherwise still
apply, with no way to reset it from the settings page.
AI-assisted-by: claude-opus-5-5
The exception page compared the screen query param against literals
that never occur ("view", "dashboard"), so viewer and dashboard urls
of inaccessible files and teams fell through to the plain not-found
page instead of the request access modal. Classify the param with a
tested helper that recognizes "viewer" and every "dashboard-*" route.
Closes#11224
AI-assisted-by: muse-spark-1.3-contributor
* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)"
This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2.
* 🐛 Migrate mousetrap to key with sane modifiers
Finish the half-done mousetrap migration from code/keyCode to
key: canonical lowercase tokens on both bind and event sides so
modifiers match again, US-only shift remap and keypress legacy
removed, and AltGr phantom ctrl filtered on Windows.
Record punctuation positionally through getLayoutMap (American
fallback) so Shift+Period stores shift+. on every layout, while
letters and digits stay glyph-based for AZERTY. Drop dead 219
aliases and revert the stray CamelCase escape command.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Rewrite mousetrap as ESM class without behavior changes
Turn the 2012 constructor-plus-prototype shape into a class
with underscore-private methods, const tables and arrow
listeners. Drop dead _belongsTo, fold one-line prevent/stop
helpers into _fireCallback, and let handleKey absorb the old
_handleKey indirection. Public API and matching logic stay
identical; verified by existing tests plus a runtime smoke.
AI-assisted-by: muse-spark-1.3-contributor
* 🎉 Add node:test suite for mousetrap shortcuts
Cover canonical key normalization, modifiers with AltGr,
bind arrays plus aliases keyup reset overwrite, sequences
with fake timers, and the full stopCallback matrix, all
through isolated instances and synthetic events. Hook the
zero-dependency suite into frontend/scripts/test for CI.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Record shortcuts by glyph instead of position
Drop the getLayoutMap positional layer and the
American fallback: punctuation now records event.key
lowercased, the same token Mousetrap dispatches, so
Shift combos match with and without a layout map.
Keeps the Numpad numN branch both sides share.
Updates the pinned positional tests and adds the
Spanish Shift-plus regression.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Resolve shifted digits to the digit itself
Shift+DigitN now resolves to the digit on every layout, so
shift+0 fires on QWERTY ()) as well as AZERTY (0), like the
old keyCode behavior. Applies in the recorder and Mousetrap;
unshifted digits and numpad are untouched. Also stages the
layout-map suite left untracked.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Cover punctuation defaults with layout twins
Some defaults only fired where their glyph is unshifted (+ dead
on US layouts, shift++ dead outside ES/DE, \ dead where it
needs AltGr). Bind the twin token per layout family and freeze
the display with :show-command. No tooltip or translation
changes.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Warn when the keyboard layout API is missing
Shows a warning on the shortcuts settings page in browsers
without navigator.keyboard.getLayoutMap, where Shift with
punctuation may not fire. Uses a synchronous probe, English
and Spanish strings, and documents the layout limitations in
the user guide.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix hide-ui twins and qualify layout docs
The ctrl+alt+\ twin could never fire (the AltGr filter drops
Ctrl), so it goes away; alt+º covers Chrome on Spanish
layouts where AltGr+º resolves to º. The user guide now
states which claims need the layout map and which hold
everywhere.
AI-assisted-by: muse-spark-1.3-contributor
---------
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
* 🐛 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>
Edits wait up to 3s in a buffer before they are sent. Closing the
laptop or switching away right after an edit could leave that batch
unsent, and a crash or a discarded tab then lost it.
The batch is now sent at once when the tab is hidden (another tab, a
minimized window, a closed tab or laptop) or the window loses focus
(another app or a plugin iframe). The 3s window itself is unchanged.
pagehide and beforeunload are left out: they come too late for an
async save, and the tab is already hidden by then.
The .penpot download is built from the server copy. Users who
downloaded a file to keep their work while saves were failing got an
old or empty file, with no warning.
Opening the download dialog now sends the pending changes of the files
and waits up to 10s for them. If some are still not saved, the dialog
says the download will not include them.
A failed export raised the exporter error to the global handler, which
replaced the workspace with the error page. Users then reloaded and
lost the changes that were not saved yet.
- A failed single or multiple export now shows an error toast and
keeps the workspace. Exporter defects still report as unhandled.
- A failed multiple export request ends its progress listeners, so the
leave-page warning it turned on is released.
- Before exporting, the pending changes of the exported files are sent
and the export waits up to 10s for them, instead of 400ms. Exports
are built from the server copy, so if some changes are still unsaved
a toast warns that the export may not include them.
force-persist-files-and-wait waits only for the given files, so a stuck
save of another file does not delay the export.
We only learn how often users lose work from support tickets. Each time
the user is told a save failed, persistence now emits a save-failed
audit event with the cause type, the code, whether the queue is still
retrying and how many changes are waiting.
It follows the same rule as the failure toast: a repeated failure of
the slow retry cycle is not counted again. Failures handed to another
handler, such as an expired session, are counted too, since they also
stop the save.
A save waits for the lock that another save holds on the same file. A
slow save, such as one on a large file, blocks the other saves to that
file. The waiting request also holds the user's only save slot on the
backend, so the user's saves to other files wait behind it too.
Those saves ended in answers that stopped autosave for good:
- a 429 after 60s, when no save slot freed up in time;
- a 504 written by the backend after 300s, with :statement-timeout,
when Postgres cancelled the wait for the lock.
In both cases the backend never applied the save, so sending it again
is safe. transient-error? now treats them as temporary, and the save
takes the retry path. A statement timeout takes 300s per attempt, so
the slow failure limit still bounds it. GET requests and error reports
are unchanged: the check lives in the save pipeline, not in
retryable-types.
Both answers were reproduced in the devenv by holding the file lock
from a separate database session.
* 🐛 Fix silent failure when uploading custom fonts
Custom font upload crashed with re-seq must match
against a string when opentype.js returned nil
subfamily names, leaving the user with no feedback.
Unknown signatures and unparseable files were also
silently dropped.
Make variant parsing nil-safe, harden entry
preparation for missing tables, report every
per-file failure with a bad-font notification, and
show a generic error if the stream itself fails.
Closes#12304
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Rename font mtype helper and mark helpers private
Avoid confusion with app.worker.import/parse-mtype,
which detects binfile signatures. The font helper now
lives as parse-font-mtype, and the upload helpers only
used internally are private, with tests reaching them
through var references.
AI-assisted-by: muse-spark-1.3-contributor
* 💄 Fix formatting in font upload tests
Realign map keys shifted by the var-quote prefix so
cljfmt check passes again. No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix infer warnings in fonts metrics code
Add missing ^js type hints to the hhea and os2 metric lookups in
prepare-font so shadow-cljs can infer the interop targets and the
release build reports zero warnings.
AI-assisted-by: muse-spark-1.3-contributor
Port staging c74d415 (#12252) to main, adapted to this branch.
The :export-shapes modal event carried the full :exports vector,
each export embedding its shape, which could reach megabytes and
wedge the audit archive queue past the proxy body limit. Build a
compact body instead: totals, distinct shapes and per-type counts,
plus the page name. Other modals keep their props.
Move the modal :origin prop to ::ev/origin so make-proto-event
records it as :event-origin; the UI still reads the plain prop.
Bound exception messages at the source: get-hint clamps to
max-message-length (1000 chars) and the CLJS format-throwable
clamps the trace message line and honors :trace-length. The
Context Hint header in generate-report prints get-hint instead
of the raw hint. Also fix first-line on the JVM, where
cuerdas/index-of returns nil and (pos? nil) throws.
Adaptations to main: skip audit-log.md (absent here), skip the
generate-report huge-message test (staging async helpers are
absent), and fit the errors.cljs hunk to main's generate-report.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ 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>
* 🐛 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
Mousetrap matched keydown shortcuts against event.key, the glyph the
key types, but bindings name the key. Modifiers change the glyph: on
any OS with a US layout Shift+0 types ")" and Shift+5 types "%", and
macOS Option changes it further (Option+Shift+5 types "fi", Option+A
types "å"). So shortcuts like Alt+Shift+5 for strikethrough or
Shift+0/1/2 for zoom never fired, on every platform. Read letters and
digits from keyCode on keydown, and record them the same way when a
modifier is held.
Closes#12217
AI-assisted-by: claude-opus-5-5
* 🐛 Raise not-found when the viewer gets no valid file-id
The viewer route parses the `file-id` query param with `uuid/parse*`, so
a URL that lacks the param, or carries a value that is not a uuid, hands
the viewer component a nil file-id. The viewer then emitted
`dv/initialize` with that nil value and its `dm/assert!` on the params
failed, so the error boundary showed the internal error page for what is
in fact a missing file.
`viewer-page*` now runs a `check-file-id!` guard before it renders
`viewer*`. The guard raises `ex/raise :type :not-found :code
:missing-file-id`, which the error boundary already maps to the
not-found page, matching how a missing page id is handled a few lines
above. Rendering the guard before the effect means the viewer never
emits `dv/initialize` with a nil file-id, so no store watcher runs at
all on that path.
Add frontend-tests.ui.viewer-test to cover the guard: a nil id and a
malformed id both raise the not-found error, and a real uuid passes.
Register the namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🐛 Read the screen from the query param on the not-found page
The route name travels in the `screen` query param, and the match map
carries an empty `:path`, so searching the path for the screen name
always failed. Read the query param instead, so the page can tell a
workspace, dashboard or viewer not-found apart.
AI-assisted-by: space-bunny-free
* ♻️ Rewrite browser history in CLJS and guard route ids
`goog.history.Html5History` is a fragment (hash) router. Once fragment
navigation was dropped, its token, URL and popstate logic reduced to
`location.search` plus `pushState`/`replaceState`, while it kept a
`hashchange` listener, gated popstate on a fragment that was then
always `null`, and asserted on `history.pushState` in its constructor.
That last assert made the module unusable outside a browser, so it had
no test at all. The router also kept the instance in the store under
`:history`, and `logged-out` carried it across a sign-out.
Replace it with `app.util.browser-history`: the token is
`location.search`, the URL of a token is the base path plus the token,
and every change lands on a `token-changes` subject the router
subscribes to. `pushState` and `replaceState` never fire `popstate`,
so the module reports its own writes there too, and the router no
longer needs `goog.events`, an `enable!`/`disable!` pair, or the
`rx/create` teardown that wrapped them.
`set-token!` and `replace-token!` keep their deliberate asymmetry.
`set-token!` is a total no-op when the token already is the current
one, so navigating to the screen you are on cannot grow the history
stack. `replace-token!` always writes and always reports, because it
is how the app corrects the URL in place (legacy `#/…` translation, a
cleaned share link) and that correction has to reach the router even
when the token did not change.
Without an instance, `:history` leaves the store and `logged-out`
stops carrying it. The single `popstate` listener now lives for the
whole session; before, every re-init built a new `Html5History` and
left the previous one listening.
Add the History API to the mocked `window` in `app.util.globals`. It
was missing, so a test driving a real `navigate` effect met a nil
instance and silently no-opped; the writes now land on the mock.
Add frontend-tests.browser-history-test for the token read, the URL
composition, the no-op and the set/replace asymmetry, and register
the namespace in the frontend test runner.
Two route entries hardened the same class of problem, a bad URL
reaching a screen that cannot render it:
- `team-page*` raises `ex/raise :type :not-found :code :invalid-team-id`
when the team id is not a uuid, rather than doing nothing and
rendering an empty page.
- The not-found page reloads after `go-to-dashboard-recent`, so a
stale URL left behind by a bad route does not stay in front of the
dashboard. Drop the `:path` binding and the `cuerdas` require that
edit left unused.
AI-assisted-by: space-bunny-free
* 🐛 Clear the caught render error when the screen changes
Two error layers answer to different things. `:exception` in the store
renders `exception-page*` directly and is cleared by `rt/navigated`. A
render error is caught by the React error boundary, which keeps it in
internal React state that only `reset-error-boundary` clears. Every
exit from the error page — the logo, the no-permission dialogs, the SSO
dialog, the feedback link — only navigates, so all of them changed the
URL and left the error page on screen. The user was stuck until a
manual reload.
Give the boundary `:resetKeys` holding the current screen: a navigation
is a fresh attempt at rendering, so changing screen clears the error on
its own. Every one of those exits now works, with no callback to thread
`reset-error-boundary` through `error-container*`.
Three traps in that value, each found by running it:
- It is a real JS array. `react-error-boundary` reads `.length` and
`.some` in `componentDidUpdate`, and a ClojureScript vector has
neither, so it threw `some is not a function`.
- It holds the screen name as a string, not as a keyword. The library
compares entries with `Object.is`, which compares strings by value
and objects by identity, and ClojureScript does not intern keywords:
two equal keywords are two objects. A keyword key therefore read as
new on every route rebuilt, so a param change inside one screen reset
the boundary and replayed the screen that had just crashed.
- It is the screen, not the params. A param change is not a new screen.
The not-found page no longer forces a reload after
`go-to-dashboard-recent`; navigating is enough now. That reload was the
blunt workaround for the same trap.
This does not touch the libraries modal that raises the error, only
whether the app can be left afterwards.
Add frontend-tests.ui.error-boundary-test to pin the three traps: the
value is a JS array, the same screen keeps an `Object.is`-identical key
across a param change, and a different screen changes it. Register the
namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🔥 Remove the viewer test for a guard that does not exist
The test called `viewer/check-file-id!` three times, but no such
function exists: the viewer raises the not-found error inline inside
`viewer-page*`, from an `mf/with-effect`, so there is nothing to call.
Calling the missing name threw a TypeError, `ex-data` on it came back
nil, and the four assertions comparing `:type` and `:code` against nil
failed.
The test was asserting an API that was never built, so it cannot be
made to pass without inventing a function that is not wanted. Remove
it and drop its registration from the frontend test runner.
The suite is green again: 1149 tests, 4442 assertions, 0 failures.
AI-assisted-by: space-bunny-free
Auto-grow text geometry is finalize-only to avoid typing lag, so the
Design tab kept the committed selrect (0.01 for new texts) until Esc.
Patch display W/H from live WASM layout while editing, matching the
viewport outline, without restoring per-keystroke modifiers.
Closes#12244
* 🐛 Fix inner shadow covering the shape in the SVG renderer
The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.
Closes#11967
AI-assisted-by: claude-opus-5-5
* ♻️ Build filter entry props with mf/props
Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.
AI-assisted-by: claude-opus-5-5
* ✨ Add stroke side-width helpers to common types
Add set-width-to-all-sides, set-width-to-single-side and side-width to
app.common.types.stroke, mirroring the border-radius helpers in
app.common.types.shape.radius. They back the per-side stroke width API
and are covered by unit tests.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Round-trip per-side stroke widths through the plugin API
Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so
reading shape.strokes exposes the per-side widths and writing a stroke
back preserves them instead of collapsing to the uniform width.
parse-stroke prefers a live stroke proxy's hidden raw state when present,
so a commit only rewrites the attributes the plugin touched.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add per-side stroke widths to the plugin API types
Add the four per-side members to the Stroke interface and to the
borderWidth and dimension token property lists, and refresh the generated
API surface used by the plugin test suite.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Expose per-side stroke width on the stroke proxy
Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to
the uniform strokeWidth; strokeWidth now writes all four sides, like
borderRadius writes all four corners; each side setter writes only its
own key and the top side also updates strokeWidth.
Reject per-side writes on shapes other than boards and rectangles with a
validation error naming the property, and expose the raw stroke map
through a hidden proxy member so a commit preserves untouched attributes.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Support stroke width tokens per side in the plugin API
Token attribute expansion now accepts the uniform strokeWidth property and
expands it to the four per-side attributes, mirroring the design tab.
shape.tokens exposes the per-side keys and reports strokeWidth when all
four sides share one token, and applyToken routes through the shared
expander.
AI-assisted-by: deepseek-v4.1-flash
* 📚 Document per-side stroke width in the plugins changelog
Note the new Stroke members, the restored strokeWidth token property and
the shape-type validation in plugins/CHANGELOG.md.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix some minor issues
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
The measures menu is memoized and only re-renders when one of the
compared values changes. :layout-item-absolute was not among them, so
switching a flex child between static and absolute kept the X/Y inputs
in their previous disabled/enabled state until another compared value
changed or the shape was selected again.
AI-assisted-by: claude-opus-5-5
Switching sidebar tabs unmounts the layers panel. The search text and
filters lived only in component state, so on return the still open
search bar was treated as a new search and cleared. Keep that state per
file in a session atom, as the assets panel already does.
Closing or clearing the search mounts the tree container again, but the
restore hook only ran on mount or page change, so the tree went back to
the top. Let use-restore-scroll take an active? flag so each list
restores its own position whenever it is shown again, and drop the
saved search position on close so a new search starts at the top.
Closes#11972
AI-assisted-by: claude-opus-5-5
* 🐛 Fix nested libraries vanishing from the libraries modal
The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:
- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
the top level, and both were lost.
The libraries showed while the shared files were loading, and vanished
when they arrived.
Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.
AI-assisted-by: claude-opus-5-5
* ♻️ Move the linked libraries tree to sort-linked-libraries
Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.
AI-assisted-by: claude-opus-5-5
* ♻️ Use connected-to to find library parents when sorting
The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.
Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.
AI-assisted-by: claude-sonnet-5-5
---------
Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
fix-deleted-fonts committed text and typography changes even when no
replacement font existed, so opening a page with a missing custom font
rewrote identical content and cleared position-data on every visit.
Only emit changes when the remapped content or typography actually
differs. Add focused unit coverage for the no-replacement and remapped
paths.
Closes#12151
Template links (#?template=<url>) keep their params in a bare "#?"
fragment. Since routing moved to the query string, only "#/" legacy
hashes are translated, and the root route was an empty query string,
so the template param was dropped and the dashboard opened without the
import dialog. Route a "#?" fragment as the query token, and treat a
query without a screen param as the root, as before.
Logged out users also lost the template: get-teams requires a session
and its 401 ended the flow before the params were kept for after login.
Skip it for the anonymous profile. This part already failed on main.
Closes#12127
AI-assisted-by: claude-opus-5-5