The pace was set when a message was queued, so messages held up by a stalled
connection left together afterwards and could trip the server's rate limit. One
queue now counts it as they go, for the desktop and the browser alike.
Leaving while changes are unconfirmed now says so: an undo waits for its edit, so
what was still on its way may not all reach the others.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Queued sync messages wait in a list the session loop drains, so Leave, a lost
connection, presence and pings are never held up behind them. A text frame from
the host stays final. A session that ended stays on its tab after a Save as:
ending it there could turn a tab into a plain local file with no word about it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joining again after a session couldn't go on opened your offline changes in a
copy beside the file. They now go on top as one undoable edit, through the
three-way merge branches use. The rejoin asks to resume as the same client, so
layers made offline are yours to send. What can't go in (the same thing changed
both ways, or layers a new client can't make) leaves the server's version, and
your whole version opens beside it so nothing is lost. A server that no longer
knows you merges the same way instead of discarding unconfirmed work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session you joined could end for reasons that pass, or after about 85 s of
retries, and its tab then looked like an unsaved local file. Work done there
reached no one, with only a 3 s toast to say so.
A live connection now reconnects until you leave, pausing a minute after each
12 attempts. Only refusals that are for good stop it, so "too many messages",
"busy" and the like retry. Messages queued while offline go out paced so a long
queue can't trip the server's rate limit. A session that ended stays on its tab:
the tab shows a red mark and a strip across the canvas offers Retry now or Try
again, Save a copy and Dismiss. Joining again uses the same tab, and what changed
offline opens beside it as an unsaved copy. A session that gets out of step
joins again by itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(engine): replace linear scans that made document-sized inputs quadratic
enum_of re-classified the whole string for every character; widget names,
comment mentions and devcompare's common siblings each searched a growing
Vec; data.fill's export names and codegen's class names retried every
suffix from 2 against a list, cubic in the number of copies of one name;
and a batch rename template had no length cap although it is expanded
again for every match.
* address review: report the pattern in its own length error
* perf(vectordrawable): resume each repeated name's suffix search where it stopped
* address review: continue from a longer stem when the name suffix counter is exhausted
* perf(collab): memoise layer depths in diff and skip the sibling scan for a new child
* address review: bound the depth walk by the document's node count
* fix(engine): hold a plugin's text.preview to the permissions of the command it previews
* address review: check PREVIEWABLE before recursing into a preview's inner command
* fix(doc): bound the string bytes an expression holds and stop concatenating at the cap
* address review: count only appended characters; keep validating operands past the cap
Every shape painted with a raster pattern hashed the pattern's whole
encoded image again to find its document key, so one embedded image reused
by many shapes multiplied the hashing work without adding any image data.
The key is kept per buffer for the import.
Every missing $ref rendered the batch's whole name table into its error,
and with onError: continue each such error was kept, so a compact batch
cost its name count per failed step. The hint names twelve steps and
counts the rest.
* fix(render): check the nesting limit for mask content and bound GIF frame buffers
A mask's content is drawn straight from mask_pixels, not through
draw_layer, so masks nested inside masks recursed past the 256-level limit
and down the stack. GIF decoding budgeted frames by the logical canvas but
left the decoder's allocation limits off, so one frame descriptor could ask
for a gigabyte before the frame came back to be checked.
* address review: give the GIF decoder one frame of working room beyond the animation cap
Each argument was substituted into the already rendered text, so a value
holding placeholders was expanded again by every later replacement and a
short request could grow the prompt without bound. The template is now
scanned once and argument text is never read as syntax.
One gzip layer is unpacked within the import budget, but usvg gunzips what
it is handed again with no limit, so a doubly compressed document or
embedded image expanded outside the budget. A compressed embedded image
that failed its budget also left the shared allowance untouched, so every
sibling image could repeat the work; a refusal now spends what was left.
Each iteration froze the children whose clamping passed a 1e-9 tolerance,
but it compared the aggregate violation against the same tolerance: several
clampings each under it could add up past it, so no child froze, the
pending set never shrank and the loop never ended. An iteration that
freezes nothing now settles the remaining children as clamped.
Loading accepted a page id repeated in the pages list and repair kept the
repeats, so import, Fig and Penpot export and every page walk visited and
retained that page's subtree once per listing. validate now reports a
repeated page and repair drops the repeats.
The topic list was validated but not deduplicated, and it is scanned for
every retained event while the log's lock is held, so a request repeating
one topic thousands of times multiplied that work. Repeats collapse and a
list naming all is just all.
A .fig message looked up each tag's field with a linear scan of its
definition, so a wide schema and many tags cost their product. Schema
decoding capped fields per definition but not in total, and inflated the
schema chunk under the 512 MiB message cap. Empty non-byte arrays spent
nothing from the decoded-value budget while still growing the output.
Field ids (and enum values) are now found through a per-definition map
built on first use, keeping first-match semantics. A schema holds at most
200 000 fields together and its chunk may inflate to 16 MiB. Every array
field spends one value for itself before its elements.
* M4: wrap style on a text layer: Auto, Balance and Pretty decide where its lines break (#1827)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Docs: no table pipe inside the wrap-style row's code span
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Wrap styles: no new emergency breaks, hanging punctuation in Pretty's width, wrap style as an override
Balance and Pretty reject a re-break with more mid-word breaks than the greedy lines; Pretty narrows a line by its text less its hanging marks; wrapStyle joins the textStyle override group; docs wording. Tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M8: a click with the Bend tool curves a sharp point, its handles along the line between its neighbours (#1875)
A release without movement on a sharp point between two segments runs ops::auto_smooth instead of cancelling; a curved point still sharpens, a drag still sets the handles by hand, an endpoint is left alone. Tests; docs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Vector edit hit test: the nearer of a handle's end and a point wins
A short path's handles end within the point's hit tolerance; a click on the point is on the point, one on the handle's end still takes the handle. Test short_handles_leave_their_point_clickable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Vector edit hit test: the nearer of a handle's end and a point is judged on the page
Candidates stay within the tolerance as before; ranking by page distance keeps a non-uniform scale from favouring the wrong one. Test a_handle_and_a_point_are_compared_on_the_page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Vector edit hit test: a handle wins a tie with a point, as before
A handle snapped onto another point stays selectable there. Test a_handle_on_another_point_is_still_the_handles (fails with the strict comparison).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M7: a file tab's menu and the board bar's shape menu read in the light theme
Both open on the theme's popup, white in the light theme, but painted what
is in them with `menu_text`, which is white in both themes because it
belongs to the dark in-app menus (#972 was the same mistake in the panel
dropdowns):
- A file tab's right-click menu drew Open in new window with the menu
bar's row (`menus::item`), so it showed only while hovered. It now uses
`right::context_popup` and `right::menu_item`, like the tab strip's
overflow menus: the theme's text, white on the green highlight.
- The board bar's shape menu outlined every shape but the picked one in
white. They now take the theme's icon colour, white only on the green
highlight.
The in-app menus (main menu, right-click on the canvas) stay dark in both
themes, as decided on #843.
Tests (menu_contrast_tests.rs): a file tab's menu opened with a right
click and the board bar's shape menu opened from its button, headless in
both themes: the label at 4.5:1 and every shape's outline at 3:1 against
the popup. Without the fix both fail at 1.00:1 in the light theme.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M8: contributor credits: a real name for aleksei-suholita
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M8: an ellipse whose start has gone round many turns keeps its sweep in .fig
The importer clamped the starting and ending angles to ±1000 rad each
before taking the sweep, so an arc starting near or past 1000 rad lost
its sweep: a whole ellipse starting at 999 came back sweeping 1 rad, one
starting at 1500 swept nothing. The sweep is now the difference of the
angles as written, and a start past a turn is brought into [0, 2π) the
way the renderer's sine and cosine see it. The .fig export writes the
start within a turn too, where a 32-bit angle still holds it and the
sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M8: an arc far from zero keeps its sweep rather than closing into a whole ellipse
Review follow-up on #1891. The whole-turn tolerance grows with the
angles, so once a start can lie far from zero it swallowed real arcs: an
arc of 6.1 rad written at 1e6 rad read as a whole ellipse, and at 3e7 rad
even a sweep of nothing did. The tolerance now only applies while it is
at most 1e-3 rad (about 0.06°), which holds up to a start of about 4,000
rad; further out the sweep is kept as written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M8: an arc with no end stays whole however far out it starts
Review follow-up on #1891. With no endingAngle the importer took the end
as start + 2π and the sweep as the difference, which far from zero
rounds away from a turn: from a start of 1e15 it came out 6.25, drawn as
an arc with its radius edges now that the snap to a whole turn is
bounded. No end now means a sweep of exactly 2π.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
drop_dangling_aliases rebuilt the id set and rescanned every token each
round, and an alias chain lost one link per round: quadratic in the
chain. Dependencies are now indexed once and invalidation propagates
through a queue, counting how many tokens make each variable id, so each
token is visited a bounded number of times with the same result.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Gradient::sample cloned, sanitised and sorted every stop on each call,
and the diamond texture sampled it for each of its 65 536 pixels, so a
file's gradient with many stops cost that sort 65 536 times.
sample_sorted samples stops already sorted, finding the segment by binary
search; sample delegates to it and the diamond texture sorts once.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Reject invalid dashes in node.set instead of dropping entries
Found while testing crates/engine/src/cmd/props.rs:580
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Reject boolean dash entries in node.set (review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Found while testing crates/engine/src/cmd/layout.rs:46-48 (set_track)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Reject fractional index in proto.interaction.remove and set
Found while testing crates/engine/src/cmd/proto.rs:37 (proto.interaction.remove) and :412 (set)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Name the upper bound in the interaction index error (review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(collab): place keyed-list puts through an index within a batch
Each Put of a comment, style, variable or collection scanned the whole
list for its key (cloning String keys on the way) before appending, so a
batch of N new entries cost N squared comparisons on the hub every
session shares. Removing an image also rebuilt the document's image-use
set before checking the image existed.
Replica::apply now keeps a per-batch key index for each keyed list; it
is rebuilt when the list's length or the entry at a key no longer
matches, and dropped on whole-list sets, reorders and real removals.
Removing an absent entry or image returns without scanning.
* fix(collab): keep the keyed-list index current through removals
Build the index without dead code outside tests, drop the wall-clock
assertion from the large-batch test, and shift the index down after a
single-entry removal instead of discarding it, so a run of removals in
one batch does not index the list again each time. Staleness checks for
puts and removals share one helper. Tests cover removals, duplicate keys
from a whole-list set, and absent keys.