* 💄 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>
fix-deleted-fonts committed text and typography changes even when no
replacement font existed, so opening a page with a missing custom font
rewrote identical content and cleared position-data on every visit.
Only emit changes when the remapped content or typography actually
differs. Add focused unit coverage for the no-replacement and remapped
paths.
Closes#12151
Template links (#?template=<url>) keep their params in a bare "#?"
fragment. Since routing moved to the query string, only "#/" legacy
hashes are translated, and the root route was an empty query string,
so the template param was dropped and the dashboard opened without the
import dialog. Route a "#?" fragment as the query token, and treat a
query without a screen param as the root, as before.
Logged out users also lost the template: get-teams requires a session
and its 401 ended the flow before the params were kept for after login.
Skip it for the anonymous profile. This part already failed on main.
Closes#12127
AI-assisted-by: claude-opus-5-5
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>
* 🐛 Show the size badge on paths and straight lines
The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.
Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.
The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.
AI-assisted-by: claude-opus-5-5
* ♻️ Extract the size badge layout and add tests for it
Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.
Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.
The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.
AI-assisted-by: claude-opus-5-5
* ✨ Show the length of straight lines in the size badge
A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.
The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.
Relates to #12064
AI-assisted-by: claude-opus-5-5
* 🐛 Hide the path size badge when the path is lower than it
Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.
AI-assisted-by: claude-opus-5-5
In list view the height of each component row depended on its
thumbnail, which renders deferred. When the Assets tab mounted again,
rows were shorter until the thumbnails appeared, so the restored
scrollTop landed several rows further down and scroll anchoring kept
it there. Reserve the thumbnail height in the row grid so rows keep
their size from the first render.
Closes#11971
AI-assisted-by: claude-opus-5-5
The tokens list container stays mounted when another token set is
selected; only the restore key changes. With no saved position for the
new set, use-restore-scroll did nothing, so the list kept the scroll
position of the previous set. Scroll to the top when nothing is saved
for the key. Freshly mounted panels are already at the top, so Layers
and Assets do not change.
Closes#11973
AI-assisted-by: claude-opus-5-5
Renaming a token with "Remap tokens" grouped the references by container
and wrote the union of their attributes onto every shape in it. A shape
with a stroke width token on one side got the token on all four sides
when another shape in the same container had it on every side.
Group the references by shape, so each shape is only renamed on the
attributes it already had the token on.
Closes#12075
AI-assisted-by: claude-opus-5-5
Since stroke widths can be set per side, stroke width tokens are stored
in the four per-side attributes, not in :stroke-width. The inspect stroke
panel still read :stroke-width for Border width and had no token lookup
for the per-side widths, so it showed the raw value instead of the token.
Read the token of each side for the per-side widths, and the token shared
by every side for Border width. Files that are not migrated yet keep the
token in :stroke-width, so Border width still falls back to it.
Closes#12029
AI-assisted-by: claude-opus-5-5
* 🐛 Show the top stroke width in the old render
With the old render, the stroke width input showed "Mixed" when the four
sides had different widths or tokens. The old render only draws the top
side, and the input must show it as the global stroke width.
Move the input value and token logic into two pure functions. When per
side strokes are disabled, both return the top side: its width and its
token, or no token when the top has none.
Closes#12063
AI-assisted-by: claude-opus-5-5
* 🐛 Add e2e tests for the stroke width input in both renders
Cover the global stroke width input when the four sides differ. With the
new render it shows Mixed. With the old render it shows the top side:
its width, or its token when the top has one.
The old render cannot create per side data from the UI, and switching
the render reloads the page and drops the test state. The tests patch
the existing stroke token fixture instead, appending the per side fields
to a rectangle and a board.
Relates to #12063
AI-assisted-by: claude-opus-5-5
The nested group wrapper only turned into a wrapping flex row when it
had a direct button child. Since the pills are wrapped in the DS
tooltip (#11900), the button is no longer a direct child, so pills in
nested groups were stacked one per row. Match pills as "any child that
is not a group" instead, so the rule no longer depends on the pill's
own markup.
Also drop the .token-pill rules of this module: CSS modules prefix
them with the token_tree scope, so they never matched the pill button.
Closes#12099
AI-assisted-by: claude-opus-5-5
When the import modal content moved to import-files-stage* (#9958), the
"error list or file rows" if became a when followed by rows that always
render. A non-.penpot file yields an analyze error entry with no name,
so the modal showed an empty error row below the error list. Render the
file rows only when the error list is not shown, as before.
Closes#12116
AI-assisted-by: claude-opus-5-5
* ✨ Add size limits to profile props and plugin registry
Bound the total serialized size of profile settings to 2 MiB
(:profile-props-max-size), checked on the merged result before
persisting, with a controlled :props-too-large error. Profiles
that already exceed the limit can still shrink but cannot grow.
Cap plugin registry entries in the shared schema (code 1 MiB, 50
plugins max, bounded name/host/description/icon) and restore rate
limiting on the plugin RPCs (profile-mutations bucket, one write
at a time per profile). The plugin manager now asks for
confirmation before removal and ignores repeated clicks while a
persist request is in flight.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Enforce plugin count cap, byte sizes and removal guard
Enforce the declared 50-plugin cap in add-profile-plugin with a
specific :too-many-plugins error (updates of existing entries
still pass); the cap lives in a shared max-plugins constant.
Measure profile props size in UTF-8 bytes instead of chars so
multibyte content cannot slip past the limit.
Cover install/remove persist logic with mocked-RPC frontend tests
(release semantics, in-flight dedupe, validation vs rollback
split) and add the missing boundary tests in common.
Expose the in-flight persist set from the plugin registry and
disable the remove button of entries being saved.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix rollback loops and restore paths in plugin registry
Restore the previous plugin version instead of dropping the entry
when a validation error rejects an update of an installed plugin.
Make compensating writes one-shot with terminal callbacks so a
persistent failure cannot ping-pong between install and remove.
Restores keep the original list position; the unused public
plugin-persisting? predicate is removed.
Pin count-before-size precedence with a dedicated test and fix
translation source refs to their canonical lines.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Guard notifications write and fix restore ordering
Route update-profile-notifications through check-props-size! so
oversized profiles cannot grow through that path; document the
exempt system writers. Remove the duplicated stale entries in
en.po, keeping the canonical translation refs.
Restore rejected plugin updates at their original list position
instead of leaving the optimistic move in place.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Skip no-op plugin removal and clarify size comments
Return early from remove-profile-plugin when the id is absent:
no wasted write, no size check, and no manufactured :plugins key
that could spuriously fail on oversized profiles.
Clarify that per-field string caps count chars while the byte
budget is enforced by profile-props-max-size.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 📎 Fix formatting in rlimit.edn for profile operations
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 📎 Fix formatting of import-binfile/global entry
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ♻️ Simplify props size check and tighten plugin entry caps
Measure props with transit bytes directly instead of the
PGobject string roundtrip.
Rename check-props-size! to check-props-size: single hard limit
on the merged props, no growth comparison, and return props so
writers thread the check into the update.
Move the 2 MiB default into default-props-max-size on the
profile namespace, still overridable with the optional
:profile-props-max-size config entry.
Tighten registry-entry :code and :icon to 500 chars: they hold
manifest paths, not content.
Closes#11592
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix compatibility problems
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ♻️ 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>
* 🐛 Gate pastes on page load and harden base-shape lookup
Pasting while the workspace is still opening crashed the
session: the layer-order lookup called rseq on a missing
root children list.
Ignore paste events until the page objects are loaded (the
clipboard keeps its content, so retrying works), return empty
instead of throwing from the shared layer-order helpers, and
fall back to pasting at the pointer position when the selection
is detached from the shape tree. Selecting the page root keeps
working as before through the frame branches.
Closes#11666
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Cover props paste and root-plus-other in paste guards
Address review follow-ups on the paste-before-init fix: gate
props pasting on page readiness like the shape entries, and
route root-plus-other selections without a base shape to the
pointer fallback instead of the unguarded else branch. Pin
single-root selection to the frame path with a regression test.
Closes#11666
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Hoist page lookup out of paste-shapes gate
Bind page and page-objects once in an outer let instead of
calling lookup-page twice (once for the readiness gate and
once inside the body). No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Reuse bound ids in paste-shapes page lookup
Bind file-id and page-id once and pass them to the lookup-page
arity that takes both, instead of resolving the page twice and
rebinding file-id in the inner let. No behavior change.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Stop browser logging from crashing on empty levels
Stop level->int crashes from taking down the dashboard when a
nil or unknown level reaches the browser logger. Invalid levels
now warn and are ignored in enabled?, setup! and the console
handler, which renders unknown records with a neutral fallback.
Alias the schema-legal :fatal level to :error in the browser
mappings and validate the JS-exported debug.set_logging, which
previously threw on missing arguments and wrote unreachable
keyword keys into the loggers map.
Closes#11690
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Guard logger args and strengthen logging tests
Close the residual throw paths next to the empty-level crash:
guard non-string loggers in enabled? and setup!, coerce
set_logging arguments safely, and validate logger keys.
Strengthen the regression tests so the fatal alias cannot
regress silently: enabled-logger filtering, JVM fatal and bogus
cases, setup! skip proof, and invalid-logger cases.
Related to #11690
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Address low findings from logging review
Validate the logger before the level in console-log-handler,
share a public valid-logger? predicate with debug/set-logging,
and keep warn formatting consistent across boundaries.
Document the fail-soft-FE/strict-BE split on enabled? and the
valid-level? contract on set-level!. Cover safe fallbacks, bad
logger keys, handler logger skips, and loggers-map isolation in
common tests, and add a frontend test for debug/set-logging.
Related to #11690
AI-assisted-by: muse-spark-1.3-contributor
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
* 🐛 Migrate openUIApi schema to Zod v4 function syntax
Zod 4 removed z.function().args(), which broke the
plugins-runtime build with implicit-any errors on every
openUIApi parameter and knock-on possibly-null errors on
the modal in plugin-manager.
Declare the inputs with z.function({ input: [...] }) so the
parameter and return types infer again; behavior is unchanged.
Add a regression spec covering delegation, optional args and
rejection of invalid theme and title values.
AI-assisted-by: muse-spark-1.3-contributor
* 📚 Fix deprecated markdown-it-anchor permalink option in docs
Migrate docs Eleventy config to the markdown-it-anchor v10 API.
Replace the deprecated boolean permalink option with
linkInsideHeader, keeping the same symbol and class.
Bump markdown-it-anchor to v10 and related docs deps.
AI-assisted-by: muse-spark-1.3-contributor
* ⬆️ Update deps
* ⬆️ Update base docker images
* 📎 Fix mcp tests
Bump the devenv and image Dockerfiles to pnpm 12.6.0
(latest stable; 12.8.0 is still on the next tag) with
fresh SHA256 pins, and opencode to 1.18.33 plus
opencode2 to 2.0.18 with fresh checksums.
Stamp all 35 package.json files via
scripts/sync-pnpm-version and refresh the 11 lockfiles;
diffs are metadata-only, with no dependency re-resolution.
Drop the last corepack calls from the media-processor
build script and its generated image setup: pnpm now
ships as a system binary and self-heals version drift.
AI-assisted-by: muse-spark-1.3-contributor
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
* 🐛 Update canvas background when its color token changes
Token propagation only walked the shapes of each page, so a canvas
background linked to a color token kept its old value after switching
the active set or editing the token. Propagation now also updates the
background of every page whose `:background-token` resolves to a new
color, inside the same undo transaction.
AI-assisted-by: claude-opus-5-5
* 🐛 Select the dragged token set by id instead of by path
Starting a drag on an unselected token set stored its path as
`:selected-token-set-id`. The sidebar then crashed on the
`(uuid? force-set-id)` assert of `get-tokens-in-active-sets-force`.
This could happen when toggling a set checkbox with a slight mouse
move.
AI-assisted-by: claude-opus-5-5
* 🎉 Add playwright test
* ✨ Enable stroke-per-side flag
* 🐛 Fix stroke per side tests for the enabled flag
The default flags now include :enable-stroke-per-side, so
frontend tests and Playwright specs that assumed the flag
was off need to turn it off explicitly.
Update the token context menu test expectations: rects and
boards now expose the stroke-width submenu, and force the
flag off in the "per-side is disabled" cases.
Pass disable-stroke-per-side in the two Playwright specs
that check the flag-off behavior, since app.config always
merges the default flags.
AI-assisted-by: deepseek-v4.1-flash
When render-wasm/v1 is active, also enable text-editor-wasm/v1 so
the WASM text editor turns on with the renderer. Keep forcing
text-editor/v2 as before; the viewport still prefers the WASM
editor when both features are set. Classic unchanged.
Closes#11934
Relates to #11935