Add a battery of tests for text overrides in copies.
- A short notation for text content in tests (thtx): paragraphs of
runs with short style names, read back with equal runs joined,
edits in the notation, set-text and set-content through the change
path, and the override rule for texts (override-units), which says
which overrides a copy must hold against its main.
- Sync matrix: every whole-text copy edit against every main edit,
plus the cards of the worked examples, the "Hello world" table, a
token on a copy, typographies and a library color sync. Where the
code differs from decisions (k) to (o), the result is pinned
against F72 to F80.
- Switch matrix: copy edits against variants that differ in letters,
size, color, a new line or a bolded word; F76, F77, F79 and F81
pinned.
- Composable case BA: EditText also edits fills, font family and a
whole text; the copy and the main change different styles, then
reset or update main, in both runners; F72 pinned.
- Editor output: the content the WASM and v1 editors commit when a
copy text is typed into, retyped, split with Enter and has a word
bolded, recorded in the workspace and replayed on a copy. Typing
sets only the text groups and a style change no text override;
F79 pinned on both editors' content.
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.
Fill the unit test gaps in common and frontend component code:
- One test per component error code: break a file in one way, check
the codes validation reports, repair it as the backend does (until
valid, at most ten passes) and check what the repair changed.
Pinned: F46 (a dangling ref in a copy child is never reported), F47
and F48 (two repairs that never converge). :component-not-main,
:should-be-component-root and :not-component-not-allowed have no
test, as no file can produce them.
- Library color and typography sync must not touch copies. Both do
(F3): the color sync touches :fill-group, the typography sync
:content-group and :text-content-attribute, and a later main edit of
that attribute then no longer reaches the copy. Pinned.
- Twenty live thf/dump-file calls in common tests go; they printed
whole file trees on every run and asserted nothing.
- The disabled copy-nested-3 frontend test runs again: a stray
backquote quoted its run-store call, so it never called done.
The composable runners could edit, add, remove and move children, swap
and switch, but not run the other component operations. New nodes run
each one through the production generator in the pure runner and the
real event in the frontend interpreter:
- Detach, UpdateMain, DeleteComponent and RestoreComponent;
- Duplicate, and NestCopy (a copy moved into a main);
- EditText, with the automatic rename the text editor applies;
- MoveShape (frontend only, through the sidebar) and MoveToCell;
- swap-shape and reset on any copy head;
- Undo with a step count, and an edit undone before its sync (frontend
only).
New cases, all with the undo/redo round trip:
- Q, R, S, T, U: detach, update main, delete and restore, duplicate
and nest, at nesting depths 0 to 2;
- V, W, X: a new edit after undo, three undos over nested cascades, an
undo before the edit's sync;
- Y: text overrides against main text edits;
- Z: a main child move;
- AA to AF: swap outcomes on values and structure, and the grid cell
of a swapped child;
- AG, AH, AI: the plugin suite's slot alignment cases.
The plugin suite gets OpDetach, OpSwap and OpReset, names that keep
the roles of two nestable components apart, Assert.knownFailure, and
ports of cases Q, AA, AB and K's reset.
Supporting changes: nodes can record what they created
(IAppliedResult); create-component takes a size, since with 0.01
frames the frontend reflows split undo entries at points that depend
on timing (F43); the interpreter ends the text measurement headless,
so text steps settle; tm/known-failure pins the wrong value a known
bug gives in a case assertion; the interpreter no longer moves its
undo baseline after a skipped optional, which cut the end-of-variant
round trip short.
Confirmed and pinned: F12 (resetting a nested swap clears the top
copy's overrides), F27 (the automatic text rename touches
:name-group), F29 (a second switch loses an override) and F44 (a
swapped grid child misses the main's cell changes). F43 also shows
through a plain sync at depth 2 (case Q).
The composable cases tested undo only where a case asked for it, so a
change generator with a wrong inverse, or a step that splits into
several undo entries, went unnoticed.
Both runners now run an undo/redo round trip on every variant, unless
the case turns it off with a reason. After each user step that adds an
undo group, undo must give back the file before the step and redo the
file after it; a user step that adds no group must change nothing. At
the end of the variant, undoing back to the last assembly step and
redoing everything must give back both states. Files compare through
a shared normalizer that drops :modified-at and, on request,
:position-data, and keeps :touched and :remote-synced.
- The pure runner gets a redo stack and a Redo node; a user operation
that pushes a group empties it. Case P undoes and redoes a main edit
in both suites.
- The frontend interpreter runs the round trip through the real undo
and redo events, and checks that each step moves the workspace undo
index by one entry. Each variant starts with an empty undo stack.
A case can mark a failure that a known bug causes. The mark pins, per
phase, the signature of the failure (the shape, page and component
attributes or the undo index that differ). It covers no other failure,
and it fails the case once the bug stops happening.
The check found two bugs, both pinned:
- F41: when sync moves a copy child to an earlier position, its undo
change puts the child back at the same index, so undoing a reorder
in a main leaves the copies reordered (case F).
- F43: the reflow after a swap or variant switch loses the step's undo
group, so its commits land in undo entries of their own and one undo
leaves the step half reverted (cases L and M).
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.
The composable test interpreter wired its own copy of the undo stack
subscription and started only the component watcher, so it reacted to
edits differently from the app, and a missing subscription failed
silently.
Gather the subscriptions that react to edits while a file is open
(undo stack, layout updates, text reflow, WASM text measurement) in
initialize-edit-watchers, stopped by finalize-edit-watchers, and the
per-page component and token watchers in watch-library-changes. The
workspace and the interpreter both start them.
Starting the edit watchers again replaces the running ones. A file
reload starts the workspace again without finalizing it, which left two
undo stack watchers running, so an edit added an extra undo entry.
The composable test engine, nodes, setups and cases lived in the
frontend test tree, so only the frontend interpreter could run them.
Move them to CLJC test helpers under app.common.test-helpers.composable
and add a pure runner (comp.runner) that does what the workspace does
around each operation, without the frontend store:
- after a user operation it syncs the changed components until none is
left, as the frontend watcher does with one pass per sync commit;
- the operation and its syncs form one undo group, and the undo node
reverts the latest one;
- it validates the file once the sync or the undo has settled.
Each node declares its kind: assembly, user, undo or frontend-only.
User nodes record the production changes they apply, and the runner
throws when a node breaks that. The swap and switch nodes build their
changes with the production clv/generate-component-swap-in-place.
Rotate and ChangeHeight need the frontend placement step, so the pure
runner rejects them.
The cases are zero-arg functions in comp.cases, shared by the frontend
interpreter and common-tests.logic.composable-sync-test. The latter
runs cases B to M and the new case O, a switch of an overridden copy,
on the JVM and in JS. Case N and the rotation case stay frontend-only.
The variant switch event picked its target variant, and the component
swap event worked out where the new head goes, inside the frontend
events. Code that runs without the frontend store, such as the pure
composable runner, could not do the same switch or swap. The swap and
the switch also checked for nesting loops with two different
functions.
Move them to app.common.logic.variants:
- find-switch-target picks the nearest variant with the requested
value.
- generate-component-swap-in-place builds the swap changes keeping the
old head's index, grid cell and swap-keep-attrs, plus the
keep-touched step when asked.
- swap-nesting-loop? checks that a swap or switch does not nest a
component inside itself. The swap menu, the plugin swapComponent and
the variants-switch event all use it.
The frontend events behave as before, with two exceptions. Switching
to a value no other variant has does nothing, where it threw in
min-key. A swap also updates the layout of the shapes the keep-touched
step adds.
Ctrl+click (Cmd+click on macOS) on a dashboard file card, a project
title, the "Show all files" button, or a project or Drafts item in the
sidebar now opens it in a new browser tab. It reuses the same
`::rt/new-window` navigation as the "Open in new tab" file menu option.
On file cards the modifier click does not change the selection, and a
modifier double click does not navigate the current tab. Files in the
trash are not opened.
Closes#12228
AI-assisted-by: claude-opus-5-5
The theme selector showed "You need to be an editor to use themes"
whenever tokens could not be edited. That is also the case for editors
when the tokens source is a library, and they can still switch themes.
Base the message on file edit permission instead.
Closes#12177
AI-assisted-by: claude-opus-5-5
* 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 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
* 🐛 Translate the This file tab texts in the libraries modal
The tab name, the section title and the empty state button of the
This file tab were hardcoded in English. Add translation keys with
English and Spanish texts.
Closes#12178
AI-assisted-by: claude-opus-5-5
---------
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
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
* ♻️ Move some constants out of app.render-wasm.api
- Moved to aapp.render-wasm.wasm where they can be imported
without pulling half the editor with them
- Renamed one for consistency
* 🔥 Delete unused function, update docstring
* ✨ 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>
Changing position, size or rotation from the measures menu triggered
the bounding box cloaking, which sets the transform to :move for one
second. While it lasts the selection outline and handles are hidden,
and since the size badge is now shown during :move, only the badge
stayed on screen. Stop cloaking from these inputs so the selection
stays visible while its geometry is edited.
Closes#12162
AI-assisted-by: claude-opus-5-5
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
A triple-click can leave the browser selection outside the hidden input
surface (it reads as "\n"). That selection is not editable, so typed
keys fired keydown but no input, and never reached the WASM editor.
Move the selection back into the surface before plain keys reach the
browser, and after double and triple clicks.
Closes#12187
AI-assisted-by: claude-opus-5-5
The Strikethrough button tooltip showed Alt+Shift+Ctrl+5, but the
shortcut is Alt+Shift+5. The tooltip used meta-shift, which adds
Ctrl (Cmd on Mac). Build it with shift so it matches the command.
Closes#12217
AI-assisted-by: claude-opus-5-5
* 💄 Unify copy in the shortcuts settings, modals and sidebar
Use one term per concept across the customizable shortcuts UI: "Custom"
and "Customize" for user changes, "shortcut" for the keys, and "Reset"
with "default" to go back to the defaults.
Rewrite the conflict message so it says what saving does, fix typos and
grammar, unify the empty states and rename the font size actions.
Give the reset all button, the search placeholder, the import column,
the invalid import error and the edit button aria-label their own keys,
and update the e2e tests to the new copy.
Closes#12173
AI-assisted-by: claude-opus-5-5
* 💄 Reword the shortcuts sidebar link and custom empty state
The sidebar link no longer names a settings section that does not exist
in Penpot. The empty state of the custom tab now says there are no
custom shortcuts and points to the All tab by name.
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>
* 🎉 Copy v3 editor text with its styles in a payload
Copy and cut in the v3 text editor put the styled selection in a
hidden span of text/html. Add its codec and the clean-up for pasting
into another file or team.
Refs #12047
AI-assisted-by: claude-opus-5-5
* 🎉 Paste text copied in Penpot at the v3 editor caret
Text copied in Penpot keeps its styles when pasted at the caret. The
first paragraph keeps the target's attrs; later ones bring their own.
The context menu paste now shares the keyboard paste path.
Refs #12047
AI-assisted-by: claude-opus-5-5
* 🎉 Paste text copied in Penpot on the canvas with its styles
Refs #12047
AI-assisted-by: claude-opus-5-5
* 🎉 Paste copied text shapes at the v3 editor caret with styles
Copied shapes now record their team. Shapes that are not texts paste
nothing into the editor.
Refs #12047
AI-assisted-by: claude-opus-5-5
* ✨ Replace custom fonts in shapes pasted across teams
Refs #12047
AI-assisted-by: claude-opus-5-5
* ♻️ Factor GL context initialization out of canvas init
- fixes setting of gl-context-handle to be after _init so an exception
does not leave invalid state behind.
- api.init-canvas-context uses a new webgl.init-context!
- this decouples config from init, so we can reuse it in benchmarks
- some helpers move to the webgl ns
AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3
* 🐛 Release acquired GL context when renderer init fails
- Properly release resources if init fails
- Rethrown exceptions with the original cause
- Fix vacuous check in clear-canvas
AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3
* ✨ Test webgl context init and release
AI-assisted-by: muse-spark, glm-5.3
* 💄 Address review comments
* 💄 Drop `!` from some function names
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
* 🐛 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
A plugin with "scope": "global" keeps running across the dashboard
and files and can find and open the user's projects and files. Any
manifest could ask for this without the user being told.
Add the allow:global permission. The plugin registry adds it to
every manifest with global scope, so the install dialog lists it,
and a plugin that becomes global in a later version shows the
permissions update dialog again. The permission is valid in the
shared schema, which the backend checks when saving, and in the
runtime manifest schema. The built-in MCP plugin declares it too.
Document the scope property and the new permission for plugin
developers.
Add penpotMgmt.createProject({name, teamId?}) and
penpotMgmt.createFile({projectId, name}). Both require content:write,
trim the name and require 1 to 250 characters, and return the same
metadata as listProjects and listFiles without changing the current
view. When the new project or file belongs to the current team, the
dashboard state is updated so it shows up right away.
listProjects, listFiles and the creation methods share the summary
builders, so fileCount is now 0 instead of missing for new projects.
Update the plugin types, the MCP API docs and the MCP instructions.
Show the MCP menu next to the layout toggle in the header of the
projects, files and deleted pages, instead of in the sidebar. It
uses the secondary button style there and a visible dot colour
while disconnected.
Clicking the MCP button now toggles the menu instead of only
opening it. Dashboard events report "dashboard:header" as their
origin.
Discovery:
- Add listProjects and project-scoped listFiles with content:read checks.
- Return metadata from existing RPCs in dashboard and workspace contexts.
- Reject invalid IDs and preserve backend access errors.
Documentation:
- Keep usage in API type comments and include regenerated MCP API docs.
- Remove README implementation details and consolidate the changelog.
- Add usage information to the MCP server's initial instructions
Validation:
- Pass 8 frontend tests, 81 runtime tests and the runtime build.
- Verify both APIs through live MCP calls in dashboard and workspace.
- Pass type, lint, formatting and diff checks.
AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
Connection status:
- Replace the dashboard's action-only link with an explicit connected
label and indicator, using the theme's existing accent colour.
- Reflect the actual connection state independently of connection intent.
Shared controls:
- Extract the toolbar's MCP menu and styles into a shared component used
by both the dashboard and workspace.
- Provide connection status, session ID, copy, connect and disconnect
actions in both locations, preserving hover and keyboard behaviour.
- Keep dashboard and toolbar event origins distinct and update the
dashboard connection instructions.
Validation:
- Confirm dashboard disconnect/reconnect, menu opening, keyboard
navigation and Escape dismissal in the running app.
- Pass live compilation, targeted ClojureScript and SCSS lint,
formatting checks and git diff checks.
AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
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
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
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.
Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.
Closes#12026
AI-assisted-by: GPT-6
Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>