Cancel the full thumbnail upload/render stream on page or workspace
teardown and on matching thumbnail clearing. Deferred library thumbnails
can otherwise query a main missing from the next WASM scene and abort
the renderer (F112).
Complete the upload observable after its callback so successful requests
also release their subscriptions.
Add async regressions for pending current and library frames, late upload
callbacks, clearing, and successful rendering. They fail before the fix.
Document the thumbnail lifecycle in the frontend memory.
Frontend CI passes lint, 1499 tests / 7272 assertions, and formatting.
Live WASM update-main followed by navigation no longer crashes; an
uninterrupted library request refreshes its PNG thumbnail too.
AI-assisted-by: GPT-6
Keep a namespaced file destination on each history change. Replay changes
in order in their target files, including stacked entries and transactions.
Keep payload fields named file-id intact, such as set-tokens-source (F111).
Snapshot the current file and every library in both composable runners.
Name the file in failure signatures; normalize the server synced-at status
stamp alongside modified-at. Update golden cases H and AP for this exclusion.
The library update regression checks both files through undo and redo and
failed against the old code.
Common JVM: 1705 tests; JS: 1633; frontend: 1495; backend: 1610; exporter: 14.
All pass, with lint and formatting checks for both changed modules.
Benchmark vs Phase 1e (lower quantile, ms):
world baseline now ratio
main-10 1.790 0.993 0.55
main-100 15.415 9.038 0.59
main-1000 153.086 90.145 0.59
three-levels 35.215 18.989 0.54
chain 68.931 35.045 0.51
library-500 83.083 85.830 1.03
many-shapes 4.837 3.344 0.69
variant-100 32.052 33.586 1.05
All worlds pass the 10% regression threshold.
AI-assisted-by: GPT-6
Emit changes-applied with the commit's prior file data. Sync each commit
before a later edit can replace its state or separate its undo group.
Allow later commits through while completion barriers finish (F110).
Process layout batches after their buffer closes, so reentrant component
sync cannot lose a pending layout task. Cover three user edits without an
intermediate wait, then every undo and redo. Token and tiny-layout cases
also pass. Re-record golden cases X and AJ for the settled timing.
Frontend: 1495 tests, 7255 assertions pass; lint and format checks pass.
The three-edit regression failed before the fix.
Benchmark vs Phase 1e (lower quantile, ms):
world baseline now ratio
main-10 1.790 0.993 0.55
main-100 15.415 9.038 0.59
main-1000 153.086 90.145 0.59
three-levels 35.215 18.989 0.54
chain 68.931 35.045 0.51
library-500 83.083 85.830 1.03
many-shapes 4.837 3.344 0.69
variant-100 32.052 33.586 1.05
All worlds pass the 10% regression threshold.
AI-assisted-by: GPT-6
Decision (c) of the component contract, after a fix that made the sync of a
library file reach that file at all (Phase 3c).
- Commit the sync of a library file to that file.
An update of the main of a library component, made from a copy in the
file, syncs the copies of the component in the file and in the library.
The changes of a sync carry no file id, so the commit took the current
file's: the library's copies, among them those its other mains nest, were
written to pages the current file does not have, and never changed (F19).
A sync now commits to the file it syncs.
workspace-remote-cascade-test: an update of the main of a library
component reaches the copy of it that another library main nests.
- Sync the components a sync changes in the same commit.
Decision (c) of the component contract: a sync reaches the copies of the
components it changes, until none is left, in one commit. A sync of a file
changed the mains of the components that nest the synced copies, and the
component watcher synced those in a commit of its own, one level per pass,
so the file stayed partly synced in between; the watcher only watches the
current file, so a sync of a library file stopped at its first level
(F19).
common: generate-sync-file-cascade syncs a file as
generate-sync-file-changes does, then the copies of each component of the
file that the sync changed, one per pass, applying each pass to the file
data, until none is left (generate-sync-components-cascade). The
composable runner, the benchmark and the golden master use it.
frontend: sync-file commits the whole cascade at once. When it syncs a
library file, it then syncs the copies in the current file of the library
components the cascade changed.
workspace-remote-cascade-test: an update of the main of a library
component, made from a copy in the file, reaches the file's copy of a
library component whose main nests it. The common golden master records
fewer changes undone in case W: a cascade is one change set.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 17.0 / 0.48
edit-through-a-chain-of-dependents 68.9 / 33.2 / 0.48
library-update-into-copies 83.1 / 43.6 / 0.52
main-edit-among-many-shapes 4.8 / 3.5 / 0.73
main-edit-into-10-copies 1.8 / 0.9 / 0.49
main-edit-into-100-copies 15.4 / 8.1 / 0.52
main-edit-into-1000-copies 153.1 / 83.9 / 0.55
variant-switch-into-copies 32.1 / 19.6 / 0.61
AI-assisted-by: claude-opus-5-5
Decision (f) of the component contract, with a fix it needed and a check of
decision (g) (Phase 3c).
- Return empty modifiers for a scale constraint axis.
The constraint of each axis of a child gives the modifiers that the
parent's resize adds to the child; :scale has no method of its own and
took the default, which returned an empty vector. Adding a vector to
modifiers reads its :last-order as nil: JavaScript adds nil as 0, the JVM
throws, so a child scaled on one axis and constrained on the other (scale
and top, say) could not be resized on the JVM. The default now returns
empty modifiers.
geom-shapes-constraints-test checks the default is empty and adds to
modifiers.
- Keep the children of a transformed copy shape derived.
Decision (f) of the component contract: transforming a copy shape touches
that shape only; its children change as a consequence and stay untouched,
and sync gives them their expected geometry, their main's placed against
their parent and resized through their constraints.
Touch. The placement step decided per shape whether a transform touched it
by comparing it with its copy root, so the children of a resized root that
stretched (scale, left and right) were touched (F10), a derived width or
height always touched (F22), and a resize that scales the content touched
the strokes, radii and the like of every child (F39). The editor now tells
the placement step which shapes the user transformed (the keys of the
modifiers given, or those the stored modifiers were set for); any other
shape of the transform changes only as a consequence of theirs (a child
through its constraints, a board that hugs them) and touches nothing.
Sync. Sync placed every main shape by the offset between the roots only,
so the children of a resized copy root that a resize leaves untouched
(center) went back to their main's place at the next sync of the main
(F10). Now, before it walks the children of a copy shape that keeps its
own geometry, sync resizes, turns and moves the main shape's subtree to
match it, its children following through their constraints and layouts,
and walks those: each child gets that geometry unless it has its own.
comp-expected-geometry-test: a copy root resized as the editor does places
a child constrained left, right, center, scale and left-right, leaves it
untouched and keeps it there through a sync; a resize that scales the
content scales the child's stroke and touches only the root. The frontend
golden master records, in cases AI and the touched child under a main
rotation, the children the resize or rotation of a root no longer touches.
- Check a copy child resize leaves its hug board untouched.
Decision (g) of the component contract: the size of a board that hugs its
content is the layout's, never an override. The layout reflow writes it as
derived, and a transform touches only the shapes the user transformed
(decision (f)), so a resize of a copy child that grows the board it sits
in touches the child only.
workspace-layout-owned-sizes-test pins it.
Benchmark (lower quantile, ms; baseline / now / ratio); the machine
was loaded, and the code before this change, run in between, gave the
same spread (0.44 to 0.59):
deepest-edit-through-three-levels 35.2 / 16.7 / 0.47
edit-through-a-chain-of-dependents 68.9 / 33.4 / 0.48
library-update-into-copies 83.1 / 40.3 / 0.49
main-edit-among-many-shapes 4.8 / 2.4 / 0.49
main-edit-into-10-copies 1.8 / 1.0 / 0.54
main-edit-into-100-copies 15.4 / 8.8 / 0.57
main-edit-into-1000-copies 153.1 / 92.7 / 0.61
variant-switch-into-copies 32.1 / 19.6 / 0.61
AI-assisted-by: claude-opus-5-5
The name a text takes from its content and the size it is measured to are
derived: they never touch a copy (decisions (d) and (g), Phase 3c).
- Let a copy text's automatic name follow its own letters.
The editors name a text after its letters on every edit. In a copy that
rename went through the same write as the content, so it touched
:name-group (F27), against decision (d) of the component contract: only an
explicit rename is an override.
- update-shapes takes :ignore-touched as a set of attributes too, and the
editors' commits (v1 and v3, finalize and keystrokes) and the replace of
text pass #{:name}: the name follows the content, so its write is
derived. Token unapply still runs for such an update.
- Sync gives an untouched copy text the name generated from the letters it
shows after the sync when its main's name is the one generated from the
main's letters (decision (d), refined), and the main's name otherwise.
So a copy that retyped its text keeps that name, and one that did not
follows the main's.
The pure runner's text edit makes the same commit. Case Y no longer pins
F27 and checks the copy's name follows its letters; both golden masters
record the copy without :name-group.
text-sync-test: an untouched copy takes the main's letters and name, a
copy that typed its letters keeps the name they give, and a main named by
hand passes its name on.
- Name a text with several paragraphs without line breaks.
The editors name a text after its letters, with the line breaks of its
paragraphs included, so a text "Hello world" + Enter + "Ends Sunday" was
named "Hello world\nEnds Sunday"; the layers panel showed a space there
(F82). generate-shape-name now turns each line break, with the spaces
around it, into one space.
text-test: the name of a two-paragraph text joins them with a space.
- Keep the size a WASM text grows to from touching the copy.
A text that grows with its content (auto width, auto height) gets its size
from the WASM renderer after a font loads, a style changes, a paste or an
edit ends. That size is derived from the content, yet the resize applied
it as a user change, so a copy's text took :geometry-group each time and
kept that size against its main (F1). The resize and the debounced resize,
the size an edit ends with and its geometry undo now pass ignore-touched.
These paths measure the text in WASM, so no headless test reaches them.
Checked in the browser with the WASM renderer: an edit of a copy's text
touches :content-group only, and a resize after a font size change of 24
grows the text with no :geometry-group.
- Keep the finalize resize of a text out of the undo stack.
When an edit of an existing text ends, the WASM renderer gives the size it
grows to, and the finalize commit stores that size along with its undo.
The resize that shows it on the canvas recorded an undo of its own with no
undo group: a release build kept a second undo entry for one edit, and a
dev build failed the undo entry check (F36). It now records none; a new
text still joins the undo group of its creation.
Checked in the browser with the WASM renderer: an edit of an existing text
adds one undo entry.
- Keep the size a text edit ends with from touching the copy.
When an edit of a text ends, the editor stores its content together with
the size measured for it (the SVG renderer's text modifier, or the WASM
measure in the v1 editor). Only the name was written as derived, so an
edit of a copy's text touched :geometry-group besides :content-group, and
the text kept its size against its main (F1, in the SVG renderer). These
writes now take the size, the position data and the name as derived; the
content still touches.
workspace-text-modifier-test: an edit that ends with a measured width
touches the copy text's content only. Checked in the browser with the SVG
renderer: an edit of a copy's text touches :content-group and
:text-content-text only.
- Measure again on WASM the texts a sync writes.
The size of a text that grows with its content is derived (decision (g) of
the component contract): sync writes the main's, and the renderer measures
the text again. The SVG renderer does so when it draws the text; the WASM
renderer did not, so a copy text with content of its own kept its main's
width and height after a sync (F35). Now, with the WASM renderer, a sync
of the current file measures again the auto-grow texts of the current page
it writes, without a component sync of its own, in the undo group of the
sync; a sync with no undo group gets one for both.
Texts on other pages keep the size sync wrote until they are measured
again.
workspace-sync-wasm-text-test: a sync that writes a copy text with content
of its own measures it again, in the undo group of the sync. Checked in
the browser with the WASM renderer: after the main text grows or shrinks,
two copy texts with content of their own get their own widths and no
:geometry-group.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 18.1 / 0.51
edit-through-a-chain-of-dependents 68.9 / 34.0 / 0.49
library-update-into-copies 83.1 / 40.2 / 0.48
main-edit-among-many-shapes 4.8 / 2.5 / 0.51
main-edit-into-10-copies 1.8 / 0.8 / 0.44
main-edit-into-100-copies 15.4 / 8.5 / 0.55
main-edit-into-1000-copies 153.1 / 83.5 / 0.55
variant-switch-into-copies 32.1 / 18.1 / 0.56
AI-assisted-by: claude-opus-5-5
Fixes in when the workspace syncs copies and propagates tokens, and in what
a layout wait covers (Phase 3c).
- Sync the copies when a shape is dragged inside a main.
The component watcher dropped every commit flagged :translation? (the WASM
drag commits only moves) so dragging whole mains around the canvas stays
fast. A drag of a shape inside a main is flagged the same, so its copies
never followed until some other edit of that main made them jump (F70).
The watcher now drops a translation commit only when it moves shapes
outside mains or whole mains: every shape it moves inside a main moves
with the main's root (decision (q)). Otherwise the commit goes through
component sync like any edit.
workspace-sync-barrier-test: a translation of a whole main is dropped; one
of a main's child syncs the copy. fill-commit in the interpreter takes
several labels.
- Propagate the file's tokens after any library update.
Updating a library from the notification synced the file and then
propagated its tokens, so the copies take the token values of the file's
token status, not the library's. The update button of the libraries
dialog's Updates tab only synced the file (F83).
dwl/update-libraries syncs the file from the given libraries and then
propagates its tokens; the notification and the Updates tab both use it.
workspace-library-updates-test: updating a library syncs it and then
propagates the tokens.
- Sync copies whether or not component thumbnails are on.
The component watcher's whole pipeline, the sync of the copies included,
ran only when the :component-thumbnails flag was on or the WASM renderer
was active. The flag is on by default, but an install that turned it off
never synced copies on the SVG renderer: editing a main left its copies as
they were (F8). The flag now gates only the thumbnail streams.
workspace-sync-barrier-test: with the flag off, a main edit reaches the
copy.
- Hold a token's shapes pending until the token is applied.
A plugin's waitForLayoutUpdate waits until no shape has layout work
pending. Applying a token resolves the tokens first, asynchronously, and
opened no pending work meanwhile, so the wait found nothing pending and
returned before the token reached the shapes or their copies (F54). The
shapes are now pending, as a :token task, from the start of the resolution
until the update is emitted, and the layout, text and sync work it starts
takes over from there.
The composable interpreter no longer waits on the token itself: case AY
applies a token and checks its copies after the same wait a plugin uses,
and fails without this change.
AI-assisted-by: claude-opus-5-5
Two derived commits that left the copies' sync in an undo entry of its own
(Phase 3c).
- Move frame guides in the undo group of their transform.
A transform of a board sends the move of its guides before the shapes'
update. The guide move committed every guide of the board, moved or not,
each in a commit with an undo group of its own, so when the board was a
main with a guide, its transform and the copies' sync landed in different
undo entries: one undo reverted only the copies (F6).
Both transform paths now give the shapes' update an undo group up front
and pass it to the guide move, which commits only the guides that move,
all in one commit in that group.
workspace-frame-guides-undo-test: one undo reverts a rotation of a main
with a guide, its copy included, and a move of a main together with its
guide.
- Reflow layouts in the undo group of the step that asked for it.
A layout reflow runs a moment after the commit that needs it: the
:layout/update events are buffered for 100 ms and reflowed per page. The
reflow lost the undo group of the step (F43):
- the buffer passed no undo group to the reflow;
- update-shapes asked for a reflow without the group of its commit;
- the reflow's transform opened an undo transaction, so it made an entry
of its own instead of stacking on the latest one, and the sync that a
reflow of a main starts took that entry's fresh group.
So one undo after a sync that reflows (a flex main's margin edit, a nested
sync with minimal frames, a swap) reverted only the reflow, or only part
of the step.
update-shapes now gives a commit that needs a reflow an undo group up
front and passes it on; the buffer reflows each page in the group an
update of that buffer names (those that name none come from the same step,
like update-layout-child's); the reflow stacks on the latest entry with no
transaction of its own.
Cases AJ and AN no longer pin F43: one undo after a nested sync reverts
the remote change and the float noise of the reflow, and one undo after a
flex main's margin edit reverts the margin, the sync and the reflow. The
frontend golden master records that.
AI-assisted-by: claude-opus-5-5
Fixes in the variant events of the workspace and in the component calls of
the plugin API (Phase 3c).
- Rename a variant in one commit.
Renaming a variant renamed its board, then each of its components and
mains, as separate commits inside an undo transaction. The transaction
kept the undo group of its first commit, the board's, while the sync that
the renamed mains started took the group of their commits and made an
entry of its own: one undo reverted only the copies' names (F6, case AV).
clv/generate-rename-variant builds the board, component and main renames
in common, and the event commits them once, so the rename and its sync are
one undo entry, like transform into a variant.
Case AV no longer marks its round trip as a known failure; the frontend
golden master records it without failures.
- Keep overrides in a plugin swap only within a variant set.
The plugin's swapComponent swapped with keep-touched for any component,
while the UI keeps a copy's overrides only on a variant switch: the same
swap kept different overrides by route (F33). It now keeps them only when
the copy's component and the target are variants of one set (decision
(p)), as swap-keeps-touched? says.
shape-bugfixes-test: two variants of one set keep the overrides; of
different sets, or plain components, do not.
- Look up a plugin variant head's component in its own file.
The plugin's isVariantHead looked the shape's component up in the file of
the shape, so it was false for copies of a library's variants; and it
looked it up before checking the shape is a head, so on a shape with no
component it failed the lookup's assert (F91). It now checks the head
first and looks in the shape's :component-file.
shape-bugfixes-test: a copy of a library variant is a variant head, and a
plain rect is not.
- Add a variant with the shortcut when a variant set is selected.
The context menu of a variant container offers "Add variant" and shows the
create-component shortcut for it, but the shortcut's event did not know
containers: with one selected it fell through to creating a component,
which the container's own rules refuse (F95). With a single variant
container selected, the shortcut now adds a variant to it, as the menu
entry does.
workspace-variants-test: the shortcut on a variant container gives the set
a third member.
- Refuse plugin variant edits whose variant is on another page.
The variant events the plugin API calls (add a variant, add, remove or
rename a property, set a property value) build their changes on the
objects of the page the workspace shows. For a variant whose mains sit on
another page, they updated the components but not their mains, so the
mains kept their old :variant-name and the file failed validation (F94).
The plugin now refuses these edits, as it refuses edits of shapes on
another page, unless the variant is in the current file and on the current
page.
library-test: each edit goes through with the variant on the current page
and is refused, with nothing emitted, with it elsewhere.
- Check every component of an assets group before combining it.
The assets panel offers to combine a group of components as variants when
they can be: more than one, none a variant, all with their main on one
page. It counted every component of the group, subgroups included, but
checked the other two rules only on the components directly in it, while
the combine takes them all; with none directly in it, those rules always
held (F98). can-combine-group? now checks every component of the group.
assets-components-test: a group whose only components are in a subgroup
can be combined; a variant or a main on another page in a subgroup blocks
it; one component cannot.
AI-assisted-by: claude-opus-5-5
- The UI offers swap and reset only on copy heads, and detach only on
copies that are not inside another copy. The plugin's swapComponent
and resetOverrides accepted any shape inside a copy (F92), and its
detach any shape at all (F93), and sent the same events, whose
changes expect a head. swapComponent and resetOverrides report "The
shape is not a component copy head" for anything else; detach
reports a shape that is not a copy, or a copy inside another copy,
by ctn/detachable?, which the detach event checks too. swapComponent
looked up the target file before checking its argument, so a value
that is not a component failed an assert instead of reporting
"Component not valid".
- The plugin's switchVariant did not check whether the switch would
nest a component in itself. The switch event checks that and calls
the :on-error of its params' metadata; the plugin passed none, so
the default, rx/throw, built an error observable that nothing used,
and the call did nothing and reported nothing (F53). The plugin
passes an :on-error that reports "The switch would create a
component nesting loop", and the event's default does nothing.
- Choosing a property value in the design tab with several members
selected sent, for each member, an event that set the value and
another that cleared its error, each its own commit and undo entry.
The plugin's setVariantProperty set the value and left the error as
it was (F94). dwv/update-property-value takes the components, and
sets the value and clears the error of each in one commit; the
design tab passes the selected members, the plugin its component,
and the composable interpreter the one member of its edit.
shape-bugfixes-test checks each plugin case on the proxy, and
workspace-variants-test sets a value on two members with errors.
AI-assisted-by: claude-opus-5-5
Several actions built their changes in frontend events, step by step,
each step its own commit, so the pure runner could not run them and
one undo could revert half an action.
- Renaming a variant member in the layers panel sent three events,
each in its own undo transaction: update the properties from the
typed formula, remove the properties no member gives a value, and
set or clear the member's error. One undo reverted only the last.
clvp/generate-rename-member builds the three steps in one changes
(with generate-update-properties and
generate-remove-empty-properties), and dwv/rename-member commits it
once with the same analytics events.
update-properties-names-and-values goes; remove-empty-properties and
update-error stay for the design tab.
- Transforming a component into a variant sent about fourteen events,
and combining components as variants that transform and eight more.
clv/generate-transform-in-variant and
clv/generate-combine-as-variants build those steps as one changes,
from the generators common already had, and the events commit them
once. The flex layout initializer moves to common as
cls/layout-initializer. Combine still sizes the board with two
frontend dimension updates inside its undo transaction: the
frontend applies them through modifiers, which common has no step
for.
- update-component split the changes of an update main (the copy's,
marked :local-change?, from the main's, which may belong to a
library) with two transducers; cll/split-local-changes does it. Its
undo-group argument, never passed, goes, and the main's library is
the copy head's :component-file, which it looked up through the
file data only to read its id back.
- dwv/valid-variant-switch? read the libraries from the state and
repeated the lookup of clv/find-switch-target. clv/valid-switch?
takes the libraries and asks find-switch-target; the plugin's
switchVariant and the frontend test call it.
Behavior changes:
- One undo after a rename of a member, a transform or a combine
reverts all of it. After a transform, it reverted only the sync of
the copies' names (F6 in case AT); its pins go.
- The transform no longer sends the "create-board" analytics event of
the board it creates around the main.
In the composable suites, renaming a member in the layers panel,
removing the empty properties and setting a member's error are
variant property edits both runners run, and case AW joins the JVM
set. Its rename node drives the real layers-panel rename, which
removes the empty properties too, so the step that removed them goes.
The golden masters are recorded again for AW (the JVM one gains the
case, the frontend one loses that step) and for AT (its final states
no longer list the F6 undo failures).
AI-assisted-by: claude-opus-5-5
remove-empty-properties, run after a rename in the layers panel, found
the position of each property by grouping all the properties of the
variant's components by name and numbering the groups. A map of more
than eight keys does not keep their order, so with nine or more
properties the numbers did not match the positions and the wrong
property was removed: with ten properties and the sixth empty in every
component, it removed the first (F86).
The positions now come from the properties themselves: a property is
removed when its value is empty in every component. The work moves to
common as clvp/generate-remove-empty-properties, and the event only
commits what it builds.
workspace-variants-test checks the case with ten properties.
AI-assisted-by: claude-opus-5-5
The clear-thumbnail-batch tests mock the RPC and count every call
recorded while the mock is installed. A thumbnail deletion batch left
over by an earlier test of the suite (a component sync clears the
thumbnails of the frames it changes) can call it in that window, so
the tests failed in the full frontend run from time to time and passed
alone.
- The batch tests count the calls about their own object ids.
- The test that clears an empty queue expected no call at all; a call
made by the event under test could only carry no object ids, so it
checks that none of the recorded calls is such a call.
AI-assisted-by: claude-opus-5-5
Sync and the variant switch can take a copy text's family from one side
and its weight from the other. The variant id goes with the weight, so
it can name a variant of the other family: the WASM renderer then picks
the closest variant, but the SVG renderer falls back to the regular one.
The frontend now gives each text content that a sync or a component swap
commits variants of its own families: a run whose variant is not one of
its family's takes the family's closest to its weight and style
(cttx/update-font-variants, fonts/find-closest-variant). The pure runner
has no fonts, so case BA leaves variant ids out of its comparison.
The merge also leaves alone a run that already shows the placed style,
so a main edit no longer rewrites the variant ids of a copy whose
family is its own; with a new family, the run still takes the placed
variant id.
Benchmark (lower quantile, ms; baseline / now / ratio):
deepest-edit-through-three-levels 35.2 / 35.7 / 1.01
edit-through-a-chain-of-dependents 68.9 / 65.2 / 0.95
library-update-into-copies 83.1 / 75.1 / 0.90
main-edit-into-10-copies 1.8 / 1.5 / 0.86
main-edit-into-100-copies 15.4 / 15.1 / 0.98
main-edit-into-1000-copies 153.1 / 151.4 / 0.99
variant-switch-into-copies 32.1 / 30.4 / 0.95
AI-assisted-by: claude-opus-5-5
Both binfile importers stored a file without the components/v2 feature
as a v2 file, though its component data was never migrated. Such a file
is now rejected with :file-in-components-v1, the code that opening a v1
file already raises, when it has components or shapes linked to them
(:component-id, :shape-ref or :main-instance). A file without component
data takes the feature and imports as before.
Since every imported file now has the feature before the pending
migrations are registered, the no-op components/v2 migration goes.
The import dialog explains the rejection: the entry says that the file
uses an old type of components that Penpot no longer supports, with its
own text, as errors.deprecated talks about opening a file.
AI-assisted-by: claude-opus-5-5
A review found tests that would pass with what they test broken.
- Runners: both judge one variant at a time, fail a variant with no
assertion and a known-failure mark that applies nowhere, and give
the asserter the state the operations produced. The pure runner
validates the final file and rejects operations of unknown kind.
The frontend interpreter fails a step when the store reports an
event error (Potok only logs it), reports asserter errors against
their case, and stops the watchers that install! starts.
- Nodes drive the real code: a main root rename goes through
end-rename-shape, and the clipboard's advance of nested copies is
one common function, cll/advance-copied-objects, that copy-selected
and both test paths call.
- Cases N, AH, AI, AK, AD, AE, K, C, H, S, AY, I, Y, AX and F check
what they claim; O, R and T get real sizes.
- Stricter common unit tests found F60 (a repair detaches healthy
copies), F61 (a switch drops a fixed-sizing size), F62 (the ref
chain never stops at its target) and F63 (latent); all pinned.
- Frontend library tests: the RPC double records on subscribe, the
dropped commits check that nothing syncs, and the link and share
tests check the events sent.
- Plugin suite: OpSwap and OpDetach throw when the API call did
nothing, and case AB checks that the reset head is an instance of
the original component.
- The composable suite memory records the new rules.
The component helpers of types/file and types/container and the
library events of the workspace had the lowest coverage left.
- common-tests.types.file-test: the export detach, the reuse of the
library page, the original ref of a swap, deleted components and
used-in?. Pinned: F52 (a library copy nested in a local component
loses its ref in every copy), F56 (the original ref of a swap is nil
when the mains are on different pages) and F57 (library color refs
inside :fills survive the export detach).
- common-tests.types.container-test: the placement rules for moves
and pastes (copies, mains, variant containers, deleted components,
component loops) and small helper branches. A plain shape moved
into its own child passes the rules (F58); relocate-shapes drops it
first.
- frontend-tests.data.workspace-libraries-test: the events on several
shapes at once, each one undo entry; update of a library main; a
blank rename; placing with an id ref.
- workspace-library-updates-test: how a file learns that a library
changed, and the sync dialog with its update and dismiss, with the
RPC mocked.
- workspace-library-links-test: linking, unlinking and sharing.
The variant events had almost no frontend tests, and several switch,
paste, token and restore paths never ran.
- Variant property edits (AR) and adding a variant (AS), in both
runners, through the common generators and the dwv events.
- A frontend-only node for the variant management events: transform
(AT), combine (AU), rename (AV), rename in layers, removal of empty
properties and member errors (AW), and the shortcuts (AX). Each
case has copies of the members; direct tests cover the remaining
branches. AV and AT confirm F6: the copies' name sync lands in an
undo entry of its own (pinned).
- Tests for valid-components-for-variants? and valid-variant-switch?.
- The WASM variant switch test is registered in the runner; it never
ran.
- Token apply and remove nodes and case AY. Its undo confirms F55, an
empty :applied-tokens map after undo (pinned). The interpreter waits
for the token, since apply-token opens no pending work (F54).
- A paste-to-root node and case AZ: F17 does not show through paste.
The frontend copy helper merges touched groups as the clipboard
does.
- Switches that carry real geometry, path and text-structure
overrides, resized through a transform.
- Restoring a component whose page was deleted.
A coverage run found sync paths that no test ran. New cases, in both
runners:
- AK: update main into a grid main. With a swapped child the main's
cells keep the copy child's id (F49, pinned).
- AL: update main after renaming the copy root and its child.
- AM: update main of a swap inside a group. The copy child keeps a
swap slot that names a removed main child (F16, pinned).
- AN: a copy flex child's margin override through a main edit, a
reset and update main. The frontend round trip of the main edit
shows F43 with real sizes (pinned).
- AO: a copy dropped into a grid cell of a main. Its undo shows F41
(pinned).
- AP: a library main deletes a middle child.
- AQ: a main change reaches a copy nested in a sub-board.
Also: the frontend interpreter sends the real reset event instead of
installing a file; a test undoes and redoes an undo entry without a
group, which only release builds store; a test confirms F50, a copy
text touched only in :content-group losing its override (pinned).
A review of the suite after the new operations:
- Dead helpers and duplicate nodes go; the lineage reset and swap
nodes are constructors of reset and swap-shape.
- Repeated helpers are shared: grid boards (tho/add-grid-frame,
tho/place-in-grid-columns), n/nested-head-under, n/child-shape,
r/sync-cascade (the benchmark uses it too), the frontend fixture and
fill helpers, and the plugin getMainComponent.
- Runner tests that repeated cases B, C and D go.
- Comments say what the code does, and the wrong ones are fixed.
- Case K's remote edit painted red on red and its unedited variants
asserted nothing; it starts grey and every variant asserts.
- F43 is pinned in one case, AJ, by the value one undo leaves. L, M, Q
and K get real frame sizes, since their pins depended on when the
100 ms layout buffer flushed.
- make-nested-component takes a level count and depth-sweep gives each
depth once, so K, Q, R, T and W stop running depth 1 twice.
- The completion-contract tests no longer depend on fixed pauses or
on how the watcher batches its events.
Benchmark against the baseline (lower quantile): ratios 0.92 to 1.03,
no world flagged.
Applying a commit locally rebuilds the object indices of every page its
changes name. Changes that touch no page, such as :mod-component, name
none, and their nil page id was rebuilt too: the file data in the store
gained a page under a nil key holding {:objects nil}. Every component
touch after an edit to a main added it, and undo never removed it.
Rebuild the indices only for the page ids the changes carry.
The composable interpreter treated a step as done after a 60 ms gap
with no commits, and treated its 2 s timeout as done too. Slow sync or
reflow work could finish after the step, so a case could assert on a
half-synced file.
Each event step now waits, as the plugin waitForLayoutUpdate does,
until every pending work tracked by the reflow registry has drained:
the watcher's sync wait, the sync it starts, and layout and text
reflow. A step that does not drain within 2 s fails the test and names
the work still pending, and the next variant starts with nothing
pending.
The tests cover an edit with a deliberately slow sync, undo and redo
steps that must wait for the watcher's touch-component, translation
and derived commits, a sync that never finishes, and a restart of the
edit watchers, which must keep the undo entries of an edit the same.
The component watcher opened its :sync-file wait only for commits saved
to the undo stack. Undo and redo commit without it, so a wait such as
the plugin waitForLayoutUpdate returned before the watcher had handled
them. Translation and derived commits were filtered out before the wait
opened.
Open the wait for every local commit of a file and close it once the
watcher has decided what to do: sync, only touch the component, or
drop the commit. Dropped commits close it right away.
Add wrf/pending, a snapshot of the pending work, for tests and
diagnostics.
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-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
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
* ✨ Run binfile import and export as durable jobs
Import and export of `.penpot` files become durable jobs: two commands
create them and answer at once with a job id the caller follows, while
a worker of the `binfile` queue does the work. Each job keeps its own
ledger (owner, params frozen at creation, result, progress history and
an expiry), and the legacy commands and their SSE keep working.
Why: both operations ran inside the request. A large import held it
open for minutes, progress only existed while the caller kept the
stream open, and a client that went away left work nobody recorded,
retried or could cancel. The unified `job` substrate already dispatched,
leased, retried and logged events, but it had no user-facing ledger.
How:
- The substrate gains the pieces a user-facing job needs: optional
job-def metadata (family, resource-role), an expiry on submit, the
owner in the runner context, and job resources in their own bucket,
written with the profile of the job and never deduplicated.
- One generic command per family: the caller names the job and sends its
business params, the registry checks the family, the job-def decodes
and validates, and permission and quota are checked first.
- Progress taps of the core become milestones of the job: a shared stage
vocabulary with self-contained counters, throttled heartbeats, and an
interrupt that stops the run inside its own transaction on cancel.
- Cancel reaches pending and running jobs from both modals, and closing
a modal mid-run cancels the job it was following.
- The export re-checks the read permission at run time and answers with
the package descriptor; the import re-checks the edition permission,
runs the core in a transaction so a cancellation rolls it back, and
releases the consumed package and its temporary copy.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Handle externally-cancelled binfile jobs end to end
The `cancelled` outcome of a job nobody followed no longer hangs its
screens: the data layers emit an explicit terminal message for it.
- The export emits `{:cancelled true}` per file and the modal paints a
neutral cancelled row instead of success; a message without an
artifact never reaches the download branch.
- The import closes every entry with a dedicated cancelled message
through the existing error rendering, so the wizard completes.
- Both flows are pinned by data-layer tests, including the red state
they replace.
Also in this change: the progress-contract comment of the management
API, early business-params validation in create-import-job (before
assembling the upload), a `release-resource` helper without fabricated
job ids, a note on the heartbeat write cost, and a memory cleanup.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Pre-translate the cancelled import message
The cancelled entry of the import wizard carried its message as a key
for the UI to translate, which needed a rehash marker comment. Like
its neighboring errors, it now translates eagerly in the data layer:
the literal call keeps rehash clean with no marker, and the tests
assert through the same call instead of the raw key.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Move cancelled strings under generic labels keys
The dashboard-specific cancelled key never shipped: before use it moves
under `labels.*` next to the generic strings both flows already use,
with Spanish translations following the established wording.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Add minor improvements
* ♻️ Rename export job command to create-export-binfile-job
Export now has one command per job type, the shape the :export-assets
command needs: create-export-binfile-job freezes exactly the
:export-binfile job, so :name no longer travels in the body and only
:params does. Import keeps the generic envelope.
submit-job reads the queue from the job-def metadata ::jobs/queue-name
(falling back to :binfile), read by the command and never by the
substrate, so a job-def can route its work to another queue without a
change in the command.
The rename reaches the frontend and the tests: the binfile export is
asked for under its own name, and the exporter service keeps
create-export-job.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Polish the import and export status rows
The status row of a file already carries what the file is doing, so the
generic "Uploading file…"/"Downloading file…" lines are gone: the
import wizard drops its own (the export one went with the jobs
refactor), along with the now-unused style.
In the export, every file is queued up front, even the ones whose job
is created only after the previous one is over, and a file stays queued
until its first milestone: a worker picking the job up no longer ends
the queue. The queued text is a step smaller than the milestone.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Show the cancelled export and fix the import error
The cancelled export was unreachable: mark-file-cancelled sets :loading
false and the label lived inside the loading branch, so a cancelled
file only painted a neutral notification. The terminal state now
carries the label, and the export flags lose the `?` suffix
(predicates keep it; a data prop is not a question).
The import wizard rendered (:error entry) through tr, but every
producer writes user-facing text (a hint or a translated label), never
a key: it renders the text as-is now, and the dynamic-key ignores and
the stale comment are gone.
The total-text-limit comment no longer references the 200MiB it
replaced; the value is 500MiB in develop already.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Reflect the real job params in the creation commands
The creation commands declared their params as an open empty map, so the
RPC contract and the generated docs said nothing about them. Each command
now imports the schema from the job namespace
(`export-binfile/schema:params`, `import-binfile/schema:create-params`),
and the RPC validates the business params before the command runs. The
job-def still owns the params and re-validates the assembled ones at run
time. The commands no longer decode the params again: the RPC did it.
Import joins export as one command per job type: `create-import-job`
becomes `create-import-binfile-job`, its body no longer carries `:name`
and the job-def is resolved by name like export's. Its creation schema
keeps the version optional, because the command reads it from the package
header when the caller does not say it, and fills the manifest metadata
kept for audit.
A version out of range is now refused by the schema (`:params-validation`)
instead of the command's own `:unsupported-version` check, which is gone.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Stop the test system from running job runners
The test system is main/system-config plus main/worker-config with a
few components dissoc'ed. The runners of default, webhook and cron were
listed by hand; the binfile runner added with the durable binfile jobs
was not, so a real penpot/job-runner/binfile/0 thread ran during the
whole suite.
That runner races the tests that drive the loop by hand (run-batch plus
run-worker-loop): it takes the job the test just dispatched and
completes it, and the test then reads a state it did not produce. That
is the reported flake of binfile-jobs-integration-test, whose middle
assertion waits for a runner of another queue to not take the job. It
only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant
baked into main/worker-config differ from the one the test builds, so
the two used different queue keys locally.
Drop every [<profile> :app.worker/runner] by key shape instead of
listing the known queues, so a new queue cannot bring the race back.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Upload the package one chunk at a time and as a percentage
The upload milestone counts chunks, not units of work, so the row wrote
"Uploading the package 3/9" over a file the user had uploaded once: it
read as if nine packages were on their way. Write the stage as a
percentage of the file instead ("Uploading file… (33%)"), and make the
English text of the stage say file, not package.
The chunks also go one at a time now (concat-all instead of merge-all
with two in flight), so the percentage moves in a straight line instead
of jumping. upload-blob-chunked is the generic uploader, so media and
font uploads become sequential too.
AI-assisted-by: deepseek-v4.1-flash
* 🌐 Complete the Spanish catalogue
es.po was 25 keys short of en.po: the 15 that the durable binfile jobs
introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps
that predate them (errors.connection-error, errors.save-retrying, the
find shortcuts, workspace.header.retrying,
workspace.tokens.stroke-width and the mcp session labels).
sync -l es then sorts the entries, syncs the #: comments from en and
drops the 7 dashboard.import.progress.* keys en no longer has. The
msgid sets of en and es are now identical (0 missing, 0 extra).
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Move the job status keys into the jobs namespace
The three status words the durable import and export rows render were
filed under labels.*, next to the generic labels bucket, while their
progress stages live under jobs.progress.stage.*: the strings of one
feature sat in two places.
Rename them so the whole job vocabulary is one namespace:
labels.queued -> jobs.queued
labels.export-cancelled -> jobs.export-cancelled
labels.import-cancelled -> jobs.import-cancelled
No other locale has these keys yet, so the rename costs no translation.
The convention (a key names the feature that renders it; labels.* holds
the generic reusable words) is written down in the frontend/translations
memory.
AI-assisted-by: deepseek-v4.1-flash
* 🔥 Remove the translation keys nothing references
Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...)
call site left in frontend/src or common/src, and no literal reference
either:
labels.uploading-file a duplicate of jobs.progress.stage.upload
since the upload stage says "Uploading file…"
labels.downloading-file dead since the durable jobs replaced the old
import and export progress rows
inspect.attributes.stroke.alignment.center
inspect.attributes.stroke.alignment.inner
inspect.attributes.stroke.alignment.outer
workspace.toolbar.mcp-connect-here
The first two are leftovers of the durable binfile jobs, the other four
predate them. Both catalogues keep an identical msgid set afterwards.
AI-assisted-by: deepseek-v4.1-flash
* 🎉 Add multi-file session routing to the MCP server
Session routing:
- Track each user's Penpot connections by session ID.
- Dispatch explicit sessions directly; otherwise discover the sole session.
- Return a retryable error when discovery fails or is incomplete.
Plugin and tools:
- Initialize connections with a fresh session ID and file metadata.
- Share the optional sessionId schema and place it last in tool inputs.
Validation:
- 84 tests, server type checking, and MCP formatting checks pass.
- Disable test logging to avoid the logging worker shutdown hang.
AI-assisted-by: gpt-6
* 🎉 Support independent MCP connections
- Require explicit connection intent for each integrated workspace.
- Derive short session IDs from the Penpot app instance and file.
- Show and copy session IDs in the MCP menu and standalone plugin UI.
- Stop stale callbacks and reconnect attempts after disconnect.
Validate with frontend and plugin tests, type checking, live multi-tab
checks, and standalone UI checks with a simulated connection. Server
tests pass with --test-force-exit; the normal runner can hang on shutdown.
AI-assisted-by: gpt-6
* 📚 Document multi-file MCP sessions
- Explain independent connections, session selection, and copying IDs.
- Describe session ID lifetimes for integrated and standalone plugins.
- Correct browser focus, connection, and recovery guidance.
Validate with the documentation site build and diff checks.
AI-assisted-by: gpt-6
* ✨ Change behavior on session ID duplication
* ✨ Open MCP toolbar menu on hover and match menu style
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Co-authored-by: elhombretecla <delacruzgarciajuan@gmail.com>
* ♻️ Scope organization notifications to specific WebSocket topics
Publish team/org notifications to team-id and organization-id topics
instead of broadcasting to all connections via uuid/zero. Dashboard and
workspace now subscribe to their current team and organization on
initialization, receiving only relevant events.
Backend: added subscribe-organization/unsubscribe-organization WebSocket
handlers and updated :close to clean up organization subscriptions.
Modified notify-team-change, notify-organization-deletion, and
notify-organization-change-sso to publish to specific topics.
Frontend: dashboard and workspace now subscribe to team-id and
organization-id (when applicable) on initialization, with nil-guard
in topic filters.
Closes#11455
AI-assisted-by: longcat-2.0
* 🔧 Remove unused session-id binding in unsubscribe-organization
Clj-kondo lint fix.
AI-assisted-by: longcat-2.0
* 🐛 Fix permission check on team ws connection
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
The event was written twice per accepted organization invitation. The
backend submitted it, and the browser then re-submitted a copy of the
props that the backend had already put in the response under
`:organization-invitation-audit` (`handle-token :team-invitation` in
`verify-token.cljs`). Both rows carried the same name with different prop
vocabularies, and the browser copy only existed when the browser finished
the flow.
Emit the event from the backend only. It now also carries the three props
that lived in the browser copy: the organization member count before the
add, the add source, and whether the invitee also joined a team. The
origin moves to the event context as `:event-origin`. The response no
longer includes `:organization-invitation-audit`, so the browser stops
emitting the event and `verify-token.cljs` drops its
`app.main.data.event` require.
The `accept-*` events of this command now share one prop vocabulary:
`:profile-id` for the accepting profile, `:invited-by` for the inviter
and `:profile-email` for the email, replacing the mix of
`:user-id`/`:user-who-send-invitation` and `:email`.
Audit consumers of `accept-organization-invitation` now see one row per
acceptance instead of two, and must read the new prop names.
AI-assisted-by: space-bunny-free
Creating a typography from a text whose font is no longer installed
baked the broken font-id into a new asset. With several texts selected
there is no single style to capture either. The add-typography event
now does nothing in both cases, and the "Add typography" button in the
local library is disabled with a label that explains why.
The button lives in its own component so selection, shape and editor
changes re-render only the button, not the typography list, and shared
libraries do not subscribe to that state at all. The event and the
button read the editor data through the new `editor-text-options`, so
both see the same font for the wasm, v2 and v1 text editors.
AI-assisted-by: claude-opus-5-5
* 🐛 Allow invitation-based registration when disable-registration is set (#5178)
Per documentation, disable-registration 'disables registration
(still enabled for invitations only)'. Two bugs prevented this:
1. verify_token.clj: when processing an invitation token for a
non-logged-in user with no member-id, the redirect included
registration-disabled? in its condition, sending invited users
to the login page instead of the register page.
2. auth.clj validate-register-attempt!: the registration-disabled
check fired unconditionally before the invitation-token check,
rejecting the actual register RPC even with a valid invitation.
Fix: in verify_token.clj remove registration-disabled? from the
redirect condition for new-user invitations. In auth.clj restructure
the check as an if/else: with an invitation token, validate the token
and allow registration; without one, enforce the flag as before.
* 🐛 Allow registration with disabled public registration
Allow valid team invitations to create new profiles when public
registration is disabled, while keeping password login and invitation
validation required.
Add backend regression coverage for flag combinations and verify-token
redirects, frontend route coverage, and configuration documentation.
Closes#5178
AI-assisted-by: space-bunny-free
* 🐛 Revalidate active invitation during registration
Require a live, unexpired team invitation before using the
registration exception, and recheck it before creating a profile.
Reuse the same lookup in invitation token verification.
Add regression tests for canceled and expired invitations, the
registration race, and explicit redirect contracts. Update docs
and backend auth guidance.
AI-assisted-by: Space Bunny Free
* 🐛 Lock and normalize invitation registration checks
Lock active invitation rows during transactional registration and
acceptance so cancellations cannot race with profile or membership
creation.
Normalize invitation emails before comparisons and database lookups.
Add concurrency, email casing, and final flag regression tests.
AI-assisted-by: Space Bunny Free
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
* ✨ Add deployment info to events
* ✨ Add get-environment-data RPC method
Add a single public RPC method returning the deployment type and
the enabled environment flags. It replaces get-deployment on the
management API and get-enabled-flags on the main API.
get-enabled-flags stays as a deprecated alias returning only the
flags, so existing callers keep working until it is removed.
The frontend event initialization now reads the flags from the new
method. The exposed flags stay limited to audit-log and telemetry
to avoid leaking internal backend flags.
AI-assisted-by: deepseek-v4.1-flash
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix organization/team switcher issues from UX review
* 🐛 Let members leave an organization without SSO credentials
* 🐛 Fix style from previewed organization in the team switcher
* 🐛 Fix leave-organization modal test stubs
* ♻️ Build organization invitation audit event in frontend
* ♻️ Align invitation token profile-id with created-by
The invitation token carried the minter in :profile-id while the
invitation row tracks the creator in :created-by. Both mean the
inviter, so re-sends or re-requested links made them disagree and
forced a second response key, :user-who-send-invitation.
Mint :profile-id from :created-by in both token creators, backfill
it from the row on accept (covers stale in-flight tokens), and drop
the duplicate response key. The frontend maps :profile-id to the
unchanged :user-who-send-invitation audit prop.
AI-assisted-by: Muse Spark 1.3 Free
* ♻️ Reuse token ids in invitation accept response
Backfill :member-id with the accepting profile and drop the
:user-id duplicate from the verify-token response, mirroring the
:profile-id/:user-who-send-invitation cleanup. The frontend maps
:member-id to the unchanged :user-id audit prop.
AI-assisted-by: Muse Spark 1.3 Free
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Classify save failures as transient or terminal (`transient-error?`
over the repo retryable types plus `:invalid-save-response`).
Transient failures keep the head commit queued under a new `:retrying`
status and resend it with backoff (2s/8s/20s, then terminal):
stamp rotation reuses the same `:commit-id`, the in-flight guard
prevents double-sends, and episode tokens silence stale timers.
One tagged reconnect notice per episode (hidden on save and on
terminal failure, silent recovery) plus a `:retrying` save-indicator
state; the browser `online` event and new edits resume the episode.
Terminal failures keep the exact `:error` path. Covers tasks 4, 6
and 7 with 31 persistence tests; updates the persistence memory.
Relates to #11724
AI-assisted-by: muse-spark-1.3-contributor
Connectivity and gateway failures (network, offline, 502/503 and
nitrate configuration) are not application defects, but offline fell
through to :default and 502/503 rendered exception-page, so they
reached the internal error reports and alerts with the full payload
(stack plus the last events). They are now classified as environment
failures and reported as audit-only handled-exception events.
generate-report accepts an explicit :format, as keyword arguments or as
a trailing map. :compact keeps the context header plus type, code and
uri, and skips the stack, the ex-data dump (which may contain request
headers) and the last-events list. flash derives the payload format from
the cause, so environment failures get a compact report; the audit event
name stays the canonical one requested by the caller
(handled-exception/unhandled-exception) because external tooling filters
on those names. Environment fingerprints drop the stack frame, so
grouping does not depend on the internal call site.
submit-report now requires an exception cause: a report without one is
ignored instead of using a separate fallback fingerprint, so a single
fingerprint format governs every report.
:offline gets its own handler and both connectivity handlers show the
new errors.connection-error message instead of the generic toast.
Closes#11743
AI-assisted-by: deepseek-v4.1-flash