Render Japanese annotations in HTML and SVG and expose their styles
in CSS code generation. Preserve vertical position data from WASM,
while keeping the legacy SVG renderer on its horizontal layout path.
Cover generated markup, styles and copied SVG output.
Lay out horizontal and vertical Japanese text with kinsoku, ruby,
emphasis, warichu, tate-chu-yoko and proportional font features.
Carry the layout through glyph paths, SVG export and text selection.
Update the Rust and ClojureScript binary formats together, including
the editor bridge and headless exporter. Resolve CJK fallback fonts
and add renderer tests and font fixtures.
Add schemas, defaults and helpers for vertical writing, ruby, emphasis
and warichu. Keep readings and notes tied to their own characters when
copying styles, and preserve base text when pasting HTML ruby.
Cover the model, property copying, clipboard and library file builder.
* 🐛 Fail MCP tasks at once when their plugin connection closes
A response only counts when it comes from the connection the task went
to, so a task sent over a connection that then closes can never finish.
It used to wait for its full timeout. The plugin bridge now fails such
tasks as soon as their connection closes.
Connections also log their close code and reason, to tell a page
closing its socket from a browser or network failure.
* ✨ Announce the MCP session to a browser extension
Let a browser extension learn which tab holds an MCP session, so it can
keep the browser from discarding that tab. While the MCP plugin runs,
the page listens for the extension's hello message, and says it is
ready once it listens, so that a content script that said hello too
early says it again. After a hello, the page posts its session ID and
connection status, and posts them again on every status change. When
the plugin stops, it reports the session as disconnected.
The plugin also reports "disconnected" while it reconnects on its own,
for instance after a page resume, so the page announces "connecting"
then and "disconnected" only once the user has stopped the connection.
Otherwise the extension would forget the session at every reconnect.
The page only accepts messages posted on its own window by its own
origin, posts to its own origin only, and sends nothing before the
extension asks. It reaches the window through app.util.globals, whose
stand-in for the unit tests accepts postMessage.
* ✨ Keep Penpot tabs connected to MCP from being frozen
Chrome freezes some background tabs, for instance busy ones while
Battery Saver is on, and a frozen page cannot run the MCP plugin.
Chrome does not freeze a page that holds a Web Lock, so the page holds
a lock named penpot-mcp-session from the moment the user asks to
connect until the connection stops: on disconnect, logout, MCP being
turned off or the MCP runtime starting again. The lock is shared, so
several tabs can hold it at once. Browsers without Web Locks skip it,
and when the lock request fails, the next connection request tries
again.
Firefox does not freeze background pages, so the lock changes nothing
there.
* ✨ Add a browser extension that keeps MCP tabs from being discarded
A browser may discard a background tab to save memory. A discarded
Penpot tab reloads when the user returns to it and loses its MCP
session.
Add mcp/packages/browser-extension, a Manifest V3 extension for Chrome
and Firefox, built from one source into dist/chrome and dist/firefox.
It is written in TypeScript, bundled with esbuild and imports the
protocol types from mcp-common. The package joins the mcp workspace, so
the build, test and fmt scripts of mcp/ cover it; the server release
build skips it.
Its content script runs on design.penpot.app, on the test environments
under penpot.dev and on local development instances. It asks the page
for its MCP session on load, again when the page says it is ready and
when the page returns from the back/forward cache, and forwards each
valid session announcement to the background. It accepts only messages
the page posted on its own window from its own origin, and posts its
hello to that origin only. It also tells the background when the page
is hidden.
The background records which tab holds each MCP session. A tab holds
the session it last announced as connected, until its page announces a
disconnect, is hidden, or the tab is closed or replaced. The map lives
in chrome.storage.session, so it survives restarts of the background
and clears when the browser closes. While a tab holds a session, the
browser may not discard it automatically.
The extension asks only for the storage permission. Chrome runs the
background as a service worker; Firefox runs it as an event page and
needs the add-on ID, mcp-extension@penpot.app, and Firefox 140. The
toolbar popup lists the connected tabs. The icon is the Penpot logo,
white on a black rounded square, with the SVG source next to the PNGs.
A README explains how the extension works and how to build and load
it.
* 🐛 Ask MCP plugins for heartbeats so hidden tabs do not look frozen
The server refuses to send a task to a plugin whose last heartbeat is
older than 30 s, taking its tab for frozen. The plugin sent heartbeats
only from a 10 s timer, and Chrome runs the timers of a hidden page as
rarely as once a minute: without Energy Saver after about a minute
hidden, and at once with it. A Penpot tab hidden for a minute therefore
failed that check about half the time, although it could run tasks.
Chrome handles socket messages of a hidden page at once, and a frozen
page cannot handle them at all. The server now sends each plugin a
heartbeat request every 10 s, and the plugin answers it with a
heartbeat, so the heartbeat age again tells a frozen tab from a hidden
one. The plugin keeps its timer, so it still sends heartbeats to a
server that does not ask. A plugin that does not know the request
passes it to the plugin code, which ignores it.
* 🐛 Report raw fetch failures as connectivity errors
A fetch that fails outside app.util.http (zip.js, plugins, schema
loads) rejects with a bare TypeError such as "Failed to fetch". It
reached the global handlers and was reported as an unhandled crash,
with no stack and no URL.
The global handlers now wrap the browser's network TypeErrors as
:network (or :offline when the browser says so), so they get the
connection toast and a compact handled report. Response body reads
in app.util.http also wrap a TypeError as :network with the request
uri, as fetch itself already did.
Relates to #12581
AI-assisted-by: claude-opus-5-5
* 🐛 Treat only network TypeErrors on body read as connectivity errors
process-response-type turned every TypeError from a body read into a
:network error, so a bug such as "body stream already read" showed a
connection toast. It now uses network-failure?, which moves to
app.util.http so that both the http client and the global error
handlers share it. Add Firefox's "Error in body stream" message.
Tests now check :offline and :network separately by setting
navigator.onLine, and read-error builds on async/->promise.
AI-assisted-by: claude-opus-5-5
* 🎉 Add OAuth support for remote MCP connections
Add MCP-specific discovery, client registration, and approval endpoints.
Exchange single-use authorization codes with PKCE for the fixed MCP user
token, enabling MCP and creating the token on approval when needed.
Use self-contained client IDs without new database tables or stored client
records. Keep temporary authorization state in Redis/Valkey.
Accept bearer credentials while preserving query-parameter connections.
Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the
environment variable in the README and implementation details in Serena.
Explain protocol and concurrency decisions in the Clojure code. Cover token
provisioning, OAuth state, client registration, HTTP flows, and the approval
page with tests.
AI-assisted-by: gpt-6
* 🐛 Wait for devenv initialization before starting builds
Docker reports a container as Running as soon as its main process starts,
while the entrypoint may still be preparing the development environment.
manage.sh used that status to launch tmux jobs, allowing frontend builds
to race with ownership changes to /opt/rustup and Cargo setup.
On a fresh container, rustup could fail with Permission denied before the
frontend watcher started. Nginx would later serve the existing JavaScript,
leaving source changes absent from the running app despite restarting
devenv.
Wait for nginx's local health endpoint before launching the jobs. Nginx
starts after entrypoint setup, so its response confirms that setup has
finished. Allow up to 120 seconds and report a timeout without starting
the jobs if initialization does not complete.
Validation: shell syntax and whitespace checks pass.
AI-assisted-by: gpt-6
* ♻️ Share the login modal with MCP authorization
Extract the viewer login modal and its styles into a shared auth
component, retaining the viewer wrapper and its close behavior.
Use the modal on the MCP approval page and preserve the pending request
through login. Closing it clears the saved return URL and shows a
cancellation message without further actions.
Cover return URL handling and cancellation with frontend tests.
AI-assisted-by: gpt-6
* ✨ Show client icons in MCP authorization
Preserve HTTPS logo URLs from client registration and published metadata
and show the icon beside the application name on the approval page.
Ignore unusable URLs and hide failed images without blocking approval.
Update the access description to refer to MCP connections established
from Penpot. Cover logo metadata and approval responses in backend tests.
AI-assisted-by: gpt-6
* 🐛 Follow the system theme when no preference is configured
Default to the system theme in the shared resolver used by the app and
plugins. Preserve explicit light and dark choices, including the legacy
default value that means dark.
Reuse the reactive theme hook in progress widgets and show the system
preference in settings for accounts without a configured theme.
Verify preference resolution with regression tests and browser checks.
All 1,474 frontend tests pass.
AI-assisted-by: gpt-6
* 🐛 Replace expired MCP tokens during OAuth approval
Settings can give an MCP token an expiry date. OAuth approval reused
the newest MCP token without checking it, so an expired token reached
the client and never matched a plugin connection.
Approval now treats an expired token as missing and creates a new one.
Token exchange rejects a code whose token expired after approval with
invalid_grant.
AI-assisted-by: claude-opus-5-5
* 🐛 Log unexpected errors on MCP OAuth endpoints
The OAuth routes sit outside the shared HTTP middleware, so the shared
error handler never saw them, and their own handler turned unexpected
failures into a 503 without logging anything. Failures in the session
middleware of the approval endpoints had no handler at all.
Unexpected failures now also go through the shared error handler for
its logging, while clients still get an OAuth error body. The OAuth
error handler also wraps the session middleware of the approval
endpoints.
AI-assisted-by: claude-opus-5-5
* 🔧 Add devenv script to run the MCP server with OAuth
start-mcp-devenv-oauth starts the MCP server in multi-user mode with
PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in
the devenv. Agentic tooling still uses the single-user
start-mcp-devenv.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix failing test case
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
When render-wasm is active, generate SVG for the Code inspector and
Copy as SVG from the WASM export path instead of classic SVG codegen.
Add multi-root render_shapes_svg so multi-select stays one document.
Closes#12567
Text copied in Penpot carries its content in a data attribute of the
clipboard HTML, which plugins and other apps can rewrite. The v3 editor
and canvas paste used it as is, so bad data could reach WASM and the
file and break the shape.
The payload is now dropped, and the paste falls back to plain text in
the caret style, unless its content passes the text shape schema, its
span geometry holds only safe numbers and its file and team ids are
uuids. Payloads in HTML over the paste size limit are not decoded.
Copied text shapes pasted into the editor get the same checks, plus a
numeric position; when they fail, nothing is pasted.
Closes#12167
AI-assisted-by: claude-opus-5-5
* 🐛 Keep text gradients in place when pasting at the caret
Text gradients are stored as fractions of the whole text box. Paste
copied those fractions unchanged, so in a box of another size the
handles and colours moved away from the pasted text.
Copy from the v3 editor now records where each span with a gradient or
image fill starts in its text box, and the box size, in the clipboard
payload. A new WASM export, text_editor_get_range_origin, gives the
start of any text range.
Paste at the caret finds where those spans start in the target and
rewrites their gradients so the handles keep the same pixel offset from
the span. Auto-sized texts use the box size they get when the editor
closes.
AI-assisted-by: claude-opus-5-5
* 🐛 Rebuild text gradient shaders when the text box resizes
Text gradient and image shaders are built into the cached Skia
paragraphs against the text box size. A resize with no content change
reused those paragraphs, so the colours stayed matched to the old box
until a reload.
The text now remembers the box size its shaders were built for and lays
itself out again when that size changes and a span has a gradient or
image fill. Text with only solid fills keeps its cache.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep text gradients in place when pasting on the canvas
A text selection pasted on the canvas becomes a new text whose box
differs from the source, so its gradients moved like they did for caret
paste.
After adding the new text, lay it out, find where its gradient spans
start and move their gradients to keep the same pixel offset from the
span, in the same undo step. The range origin export now takes a shape
id, so it works on texts that are not being edited.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep text gradients in place when pasting copied texts at the caret
Text shapes copied whole, rather than as an editor selection, reached the
caret paste without any geometry, so their gradients still moved away from
the pasted text.
A shape copy now records where the gradient and image spans of each
selected text start, and the box size, by shape id. Paste joins them in
the order of the joined paragraphs, with no origin for texts copied
without one.
Copied texts each have their own box, so every span origin now carries
its own box size instead of one box for the whole payload.
AI-assisted-by: claude-opus-5-5
* 🐛 Raise not-found when the viewer gets no valid file-id
The viewer route parses the `file-id` query param with `uuid/parse*`, so
a URL that lacks the param, or carries a value that is not a uuid, hands
the viewer component a nil file-id. The viewer then emitted
`dv/initialize` with that nil value and its `dm/assert!` on the params
failed, so the error boundary showed the internal error page for what is
in fact a missing file.
`viewer-page*` now runs a `check-file-id!` guard before it renders
`viewer*`. The guard raises `ex/raise :type :not-found :code
:missing-file-id`, which the error boundary already maps to the
not-found page, matching how a missing page id is handled a few lines
above. Rendering the guard before the effect means the viewer never
emits `dv/initialize` with a nil file-id, so no store watcher runs at
all on that path.
Add frontend-tests.ui.viewer-test to cover the guard: a nil id and a
malformed id both raise the not-found error, and a real uuid passes.
Register the namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🐛 Read the screen from the query param on the not-found page
The route name travels in the `screen` query param, and the match map
carries an empty `:path`, so searching the path for the screen name
always failed. Read the query param instead, so the page can tell a
workspace, dashboard or viewer not-found apart.
AI-assisted-by: space-bunny-free
* ♻️ Rewrite browser history in CLJS and guard route ids
`goog.history.Html5History` is a fragment (hash) router. Once fragment
navigation was dropped, its token, URL and popstate logic reduced to
`location.search` plus `pushState`/`replaceState`, while it kept a
`hashchange` listener, gated popstate on a fragment that was then
always `null`, and asserted on `history.pushState` in its constructor.
That last assert made the module unusable outside a browser, so it had
no test at all. The router also kept the instance in the store under
`:history`, and `logged-out` carried it across a sign-out.
Replace it with `app.util.browser-history`: the token is
`location.search`, the URL of a token is the base path plus the token,
and every change lands on a `token-changes` subject the router
subscribes to. `pushState` and `replaceState` never fire `popstate`,
so the module reports its own writes there too, and the router no
longer needs `goog.events`, an `enable!`/`disable!` pair, or the
`rx/create` teardown that wrapped them.
`set-token!` and `replace-token!` keep their deliberate asymmetry.
`set-token!` is a total no-op when the token already is the current
one, so navigating to the screen you are on cannot grow the history
stack. `replace-token!` always writes and always reports, because it
is how the app corrects the URL in place (legacy `#/…` translation, a
cleaned share link) and that correction has to reach the router even
when the token did not change.
Without an instance, `:history` leaves the store and `logged-out`
stops carrying it. The single `popstate` listener now lives for the
whole session; before, every re-init built a new `Html5History` and
left the previous one listening.
Add the History API to the mocked `window` in `app.util.globals`. It
was missing, so a test driving a real `navigate` effect met a nil
instance and silently no-opped; the writes now land on the mock.
Add frontend-tests.browser-history-test for the token read, the URL
composition, the no-op and the set/replace asymmetry, and register
the namespace in the frontend test runner.
Two route entries hardened the same class of problem, a bad URL
reaching a screen that cannot render it:
- `team-page*` raises `ex/raise :type :not-found :code :invalid-team-id`
when the team id is not a uuid, rather than doing nothing and
rendering an empty page.
- The not-found page reloads after `go-to-dashboard-recent`, so a
stale URL left behind by a bad route does not stay in front of the
dashboard. Drop the `:path` binding and the `cuerdas` require that
edit left unused.
AI-assisted-by: space-bunny-free
* 🐛 Clear the caught render error when the screen changes
Two error layers answer to different things. `:exception` in the store
renders `exception-page*` directly and is cleared by `rt/navigated`. A
render error is caught by the React error boundary, which keeps it in
internal React state that only `reset-error-boundary` clears. Every
exit from the error page — the logo, the no-permission dialogs, the SSO
dialog, the feedback link — only navigates, so all of them changed the
URL and left the error page on screen. The user was stuck until a
manual reload.
Give the boundary `:resetKeys` holding the current screen: a navigation
is a fresh attempt at rendering, so changing screen clears the error on
its own. Every one of those exits now works, with no callback to thread
`reset-error-boundary` through `error-container*`.
Three traps in that value, each found by running it:
- It is a real JS array. `react-error-boundary` reads `.length` and
`.some` in `componentDidUpdate`, and a ClojureScript vector has
neither, so it threw `some is not a function`.
- It holds the screen name as a string, not as a keyword. The library
compares entries with `Object.is`, which compares strings by value
and objects by identity, and ClojureScript does not intern keywords:
two equal keywords are two objects. A keyword key therefore read as
new on every route rebuilt, so a param change inside one screen reset
the boundary and replayed the screen that had just crashed.
- It is the screen, not the params. A param change is not a new screen.
The not-found page no longer forces a reload after
`go-to-dashboard-recent`; navigating is enough now. That reload was the
blunt workaround for the same trap.
This does not touch the libraries modal that raises the error, only
whether the app can be left afterwards.
Add frontend-tests.ui.error-boundary-test to pin the three traps: the
value is a JS array, the same screen keeps an `Object.is`-identical key
across a param change, and a different screen changes it. Register the
namespace in the frontend test runner.
AI-assisted-by: space-bunny-free
* 🔥 Remove the viewer test for a guard that does not exist
The test called `viewer/check-file-id!` three times, but no such
function exists: the viewer raises the not-found error inline inside
`viewer-page*`, from an `mf/with-effect`, so there is nothing to call.
Calling the missing name threw a TypeError, `ex-data` on it came back
nil, and the four assertions comparing `:type` and `:code` against nil
failed.
The test was asserting an API that was never built, so it cannot be
made to pass without inventing a function that is not wanted. Remove
it and drop its registration from the frontend test runner.
The suite is green again: 1149 tests, 4442 assertions, 0 failures.
AI-assisted-by: space-bunny-free
* 🎉 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
* ✨ Add rich HTML paste to the v3 text editor
Pasting HTML into the v3 text editor keeps bold, italic, decoration and
text-transform, and drops fonts, colors and links. Gated behind
text-editor-wasm/v1-html-paste.
Refs #10465
AI-assisted-by: claude-opus-5-5
* ✨ Paste HTML on the canvas as a styled text shape
With v3 HTML paste on, pasting HTML on the canvas creates a text shape
with the same emphasis. Clipboard options are now read at paste time.
Refs #10465
AI-assisted-by: claude-opus-5-5
* ♻️ Derive per-side stroke widths from the side values
The per-stroke `:stroke-per-side` boolean only gated whether the
renderer looked at the four side widths, and the CSS generator used it
to decide whether to emit one `border-width` or four. Comparing the
sides is enough, so drop the attribute from the shape schema and from
the stroke attribute list.
The WASM property and upload bridges and `stroke-per-side-widths` now
derive the per-side widths from the values alone.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Declare per-side stroke width token attributes
Replace the single `:stroke-width` token attribute with
`:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom`
and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map
the new attributes to the strokes shape attribute and to the
dimensions token type.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Unapply only the token of the changed stroke side
A stroke change that reports a single per-side sub-attribute now
resolves to that side's token only. A plain `:stroke-width` change
still resolves to every side, and a change with no sub-attribute
resolves to all width keys plus the color.
Add a regression test that tokens on untouched sides survive a change
to another side.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add a predicate for per-side stroke shapes
Boards and rectangles support independent stroke widths per side.
Expose `per-side-stroke-shape?` so callers can gate the per-side UI,
and cover the supported and unsupported shape types with a test.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add stroke side width materialization helper
`materialize-stroke-side-widths` concretizes the four per-side width
keys from a stroke: edited sides take the new value, the others keep
their current width (0 when there is no stroke), and `:stroke-width`
mirrors the top side for legacy consumers.
This pulls the logic already duplicated in the token apply path into a
shared helper, ready for the direct-edit path.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Apply a stroke width token to every side
`update-stroke-width` now writes the four per-side width keys, both
when the shape already has a stroke and when it gets a new default
one, so the applied-token bookkeeping matches the stroke attributes.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Apply a stroke width token to a single side
Add `update-stroke-width-side`, which changes only the sides named in
`attributes` on the first stroke of each shape. The remaining sides
keep their current width (0 when the shape had no stroke) and all side
keys are materialized through the shared helper, so consumers never
fall back to `:stroke-width`.
Route the per-side token keys to the new function and update the
apply, remap and component tests.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Complete a partially applied per-side token on toggle
When explicit attributes come from an input or a plugin call, toggle
the token off only if it already covers every target attribute on every
selected shape. A partial per-side application is completed instead of
removed. The token pill keeps the previous any-attribute behavior.
Add tests for both the completion and the full removal.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Offer per-side stroke width actions in the token menu
Boards and rectangles get a stroke width submenu with an all-sides
action and one action per side; other shapes keep the single global
action. The global action targets every per-side attribute so the
design tab keeps showing the token on each side.
Add the pill labels for the new attributes, the menu test, and the
`workspace.tokens.stroke-width` string.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Persist the per-side stroke preference
Add `:stroke-per-side` to the user profile props schema and expose it
through a derived `stroke-per-side` ref. The design tab will read the
preference from here instead of a per-stroke attribute.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add per-side stroke width helpers to the stroke menu
Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which
checks the feature flag and that a single board or rectangle, or a
uniform multi-selection of them, is selected. Use it instead of the
inline shape-type check and drop the old per-side toggle handler.
Cover both helpers with a test.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add per-side stroke width token inputs to the design tab
Turn the four side width fields into token inputs with detach actions
and a `:multiple` mixed value when the sides differ. The per-side
toggle now reads the persisted profile preference instead of a
per-stroke attribute, so previous per-side edits survive.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Materialize stroke sides on direct width edit
The stroke menu per-side handler only wrote the edited side key and,
for the top side, the global `:stroke-width`. A stroke holding just
`:stroke-width` made every consumer fall back to the global value, so
editing one side changed all the others.
Add a `change-stroke-side-width` event that materializes the four side
keys through the shared helper and reports only the edited key as
changed, so tokens on untouched sides are not unapplied. Route the menu
handler through it.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Make stroke width fields non nullable
Drop `:nillable` from the global and per-side stroke width inputs and
use `:multiple` for the mixed state, so an empty field no longer
represents a null width.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix the numeric-input props schema key
The schema declared `:applied-token`, but the component body and every
caller use `:applied-token-name`, so the prop was never validated.
Rename the schema entry to match.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add token-disabled support to the numeric input
The design-system numeric input accepts `:token-disabled` and
`:token-tooltip`; the token button is disabled and shows the reason.
Scope the disabled input style to `input:disabled` so a disabled token
button no longer dims the whole field. Pass both props through the
token wrapper.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Disable token controls below the first fill or stroke
Design tokens only apply to the first fill or stroke of a shape. Add
`tokens-allowed-position?` and mark the fill and stroke lists with
`tokens-first-only`, so later entries disable their token controls and
explain why. The colorpicker opens on the direct color tab and disables
the token tab for those rows.
Cover the helper with a test and add the new translation.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Refactor colorpicker style switcher to DS radio buttons
Replace the legacy `components/radio-buttons` markup in the colorpicker
with the design system `radio-buttons*`, using its declarative options
API. Switching between direct color and token mode now passes string
values, as the DS component expects.
The previous keyword values broke the round trip back to color mode:
the DOM stringifies keywords with a leading colon, so the value never
matched `:direct-color`. Using plain strings keeps the conversion
clean.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add playwright tests
* ✨ Scope per-side stroke controls to each stroke
Give every stroke row its own expanded state instead of sharing one
profile-wide preference. The state lives in `:workspace-local`, keyed by
`[ids index]`, so it survives selecting another shape and coming back but
resets on reload.
Remove the `:stroke-per-side` profile prop and its ref. The ref now derives
from `:workspace-local`.
Update the Playwright spec to expand the controls per stroke through the
toggle, and assert that strokes toggle independently, that the state resets
on reload, and that it survives switching shapes.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Keep stroke tokens when editing or removing later strokes
The token unapply logic decided which tokens to clear from the shape
using only the changed sub-attributes, without knowing which stroke was
edited. Since stroke tokens only live on the first stroke, editing or
removing a later stroke cleared the first stroke's tokens.
Add a `:changed-item-index` option to `generate-update-shapes` and skip
unapplying fill/stroke tokens when the changed item is not the first.
The stroke color, attrs, side-width and remove events now report the
index they touch.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Ignore token shortcuts when tokens are disabled for input
The numeric input opened the token dropdown on `{` regardless of
`token-disabled?`, so inputs that cannot hold tokens (for example,
strokes after the first one) still opened it, and typing `{token}` plus
`}` could apply a token there.
Extract the key handling into `token-shortcut`, which returns nil when
tokens are disabled, and use it for both `{` and `}`.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Gate per-side stroke tokens on the WASM renderer
The token context menu offered per-side stroke width actions whenever
the feature flag was on and the shape was a board or rectangle, without
checking the renderer. The classic renderer only draws the single
`:stroke-width`, so applying a per-side token there wrote inert data,
the token pill reported it, and the stroke changed appearance when the
WASM renderer was later enabled.
Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from
both the design tab and the token context menu. Thread the renderer
flag into the context menu through `:render-wasm`.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Keep first-stroke tokens when reordering later strokes
Fill and stroke tokens only ever live on the first item of the
collection. When a stroke update arrives without a changed item
index (for example reordering the second and third strokes), the
unapply logic assumed the first item had been edited and removed
every stroke token from the shape.
Compare the first item before and after the update instead: when
no item index is given, unapply only if the first item actually
changed. Reordering later strokes now leaves the first stroke and
its tokens untouched, while moving the first stroke away still
detaches them. Explicit item edits keep their previous behavior.
AI-assisted-by: deepseek-v4-flash
Drop 277 keys nothing references from en.po (verified against
frontend/src and common/src) and let sync propagate the
deletions to every locale. Clear all 10 fuzzy entries: fill
the 5 empty translations, keep the 4 valid ones, and drop the
duplicated max-quote-reached in favor of max-quota-reached
(the backend code stays, the UI maps it to the quota text).
Recover 22 used-but-missing keys with translations: the 19
shortcuts section/subsection labels plus connected-to,
pixel-grid-color and tokens.add-set. Make the rest
statically visible to rehash instead: :label fns on shortcut
commands, sections and subsections (one debug-only and one
colorpicker-local id exempt); case branches in place of
dm/str-built keys (export modal, text decoration and
transform, undo history with raw-key fallback); hoist
conditionals out of tr calls; pre-translate modal props and
role labels; replace the lone (i18n/tr ...) site with tr.
Turn static :error/code data into eager :error/fn calls in
the common schemas and the auth/password forms. Rename the
two keys containing spaces and point team leave at
max-quota-reached. Backend-driven keys stay dynamic by
design, declared with (tr ...) comments: the five
weak-password details, team and organization notifications.
Tooling: rehash also scans common/src and no longer treats
a missing -l as no locale; new clj-kondo tr-dynamic warning
flags non-literal tr args (lint scripts use --fail-level
error so it never fails CI); tr docstring states the
literal-only rule. Tests cover the shortcut label wiring,
the undo-history fallback and the :error/fn schemas.
Translations memory rewritten to match; es check word list
gains three entries.
Rebased onto develop: adopt the register field-error UX
(the weak-password declarations move onto the :options
code), keep develop's newer keys (connection-error,
account-locked, save-retrying, tokens-source strings) with
fresh references, and reword the shortcuts.cljs prose
comment so rehash does not invent a "literal" key.
AI-assisted-by: muse-spark-1.3-contributor
Move SPA routing out of the URL fragment into the normal query
string. The screen travels in a reserved `screen` key holding
the route name (`?screen=workspace&team-id=…`); every other
param keeps its name. `rt/nav` and `rt/resolve` keep their
signatures.
This deletes the fragment-mirroring URL surgery, simplifies
link-preview (the server sees everything) and nginx (single
path, no SPA fallback rules needed), and migrates OIDC
redirects, email links, e2e helpers and plugin test utils to
the new format.
Legacy `#/…` URLs translate client-side for one Penpot version
(`legacy-routes`, marked TODO(next-version)); non-SPA paths
are untouched.
AI-assisted-by: muse-spark-1.3-contributor
* 🎉 Add a context menu to the v3 text editor
Right-clicking while editing a text shape now opens a menu with Cut,
Copy, Paste and Select all, so those actions stop being reachable only
from the keyboard. Cut and Copy are disabled when nothing is selected,
and with no selection the caret first moves to the click position.
The menu is a new `:text` kind of the workspace context menu, so it
inherits the existing positioning, styling and shortcut hints. Its
entries emit events that drive the WASM editor through the same calls
the keyboard path uses, so a menu edit commits exactly like a
keystroke: same undo step, same layer rename.
A secondary click has to leave the text alone until the menu acts on
it. The pointer handlers now ignore it, both the right button and the
macOS Ctrl+Click that reports button 0, so it can no longer start a
drag-selection over what was selected. Pressing the mouse on the menu
would blur the capture surface and end the session, so the menu
cancels the default action of `mousedown` while a text is being
edited.
Paste reads the system clipboard through `navigator.clipboard`, since
a menu click carries no clipboard event.
Closes#10914
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ Add Playwright tests for the text editor context menu
Eight tests under "Text Editor context menu" in the v3 editor spec.
They load the existing lorem ipsum fixture rather than drawing a shape,
enter edit mode, and drive the menu with a real right click.
They cover the four entries, that the right click keeps the selection
(copy returns the whole text), that a right button drag no longer
replaces it, that moving the caret abandons a staged typography, and
that cut and copy are disabled at a collapsed caret. Typing right after
a menu action also shows the editor kept the focus while the menu was
open.
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ Use a test id instead of a DOM id in the v3 editor spec
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 Fix font preview in assets breaks the font row
* 🐛 Fix font height problem also in the font dropdown
* 🐛 Fix a small bug within the changes
---------
Co-authored-by: Eva Marco <evamarcod@gmail.com>
* ✨ Materialize font-preview flag to make it permanent
* 🐛 Fix font change regression when WebGL is disabled
* 📎 Add fixes for e2e tests
* 🐛 Fix font change from legacy render
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
* 🐛 Fix editor v3 quitting when changing typography options
* 🎉 Apply text styles to collapsed caret
* 🐛 Fix not persisting the new selrect
* 🐛 Fix selrect not being recomputed on caret style changes
* 🐛 Fix quitting the editor when changing typography on empty texts
Penpot itself and plugins used different default theme selection
strategies when no theme is explicitly selected in Penpot by the user:
- Penpot itself defaults to dark.
- Plugins defaulted to whatever is configured in the user's
system/browser, which could be light.
So with no explicit theme chosen in the profile — the common state —
every plugin could be told the theme was light while Penpot itself
showed dark, both on open (penpot.theme) and on themechange. For every
explicit choice (light, dark, system, and the legacy default) the two
resolutions already agreed; only the unset case diverged.
Extract the app's resolution into app.util.theme/resolve-theme (system
follows the system theme; default and unset mean dark) as the single
source of truth, and use it in the app's own use-initialize as well as
in the plugin runtime's getTheme and themechange handling. The
themechange handler's now-dead default-to-dark remapping is removed.
Fixes#10676
AI-assisted-by: claude-fable-5
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone
The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.
- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
window) with tm/schedule (setTimeout 0) and return a rx/dispose!
cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.
AI-assisted-by: opencode-go/mimo-v2.5-pro
* 🐛 Fix leaked focus timers in dashboard sidebar navigation
Six sidebar navigation handlers scheduled setTimeout callbacks to mutate
tabindex/focus on React-managed title elements without cancelling prior
pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts)
the stale callbacks fired against unmounted DOM, desyncing React fiber tree
and triggering "removeChild" NotFoundError.
- sidebar-project*: cancel prior timer in on-key-down
- sidebar-search*: cancel prior timer in on-key-press
- sidebar-content*: cancel prior timer in go-projects-with-key,
go-fonts-with-key, go-drafts-with-key, go-libs-with-key
Each handler now stores the timer handle in a component-level ref and
disposes any pending handle before scheduling a new one.
AI-assisted-by: opencode-go/mimo-v2.5-pro
* ♻️ Refactor sidebar focus timer handling into helpers
Extract the repeated dispose-before-schedule focus idiom into
schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable!
(app.util.dom). Replaces the six duplicated blocks and adds
mf/use-effect unmount cleanup to dispose any pending timer in the
three sidebar components, closing the remaining leak noted in the
original fix.
AI-assisted-by: opencode/hy3-free
* ♻️ Extract use-focus-timer-ref hook for sidebar components
Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in
sidebar-project*, sidebar-search*, and sidebar-content* with a shared
use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref
and disposes any pending timer on unmount via mf/with-effect, reading the
ref with mf/ref-val instead of deref. mf/use-effect is now a body-level
hook call rather than a let binding.
AI-assisted-by: opencode/hy3-free
* 📎 Add pr feedback fix
* 🐛 Fix nil getData crash dropping ZIP without manifest.json
Add nil-guard in read-as-text to raise typed :invalid-entry error instead of calling (.getData nil writer) which produced a raw TypeError.
Made read-zip-manifest public (was defn-) with explicit detection of missing manifest.json, raising typed :invalid-penpot-file validation error. The existing catch path surfaces this hint as a friendly user error instead of the raw TypeError text.
Add regression tests for both paths. 374 users were affected, 704 occurrences across 2.17.0-RC2/RC3/RC4.
Fixes#10709.
AI-assisted-by: minimax-m3
* 🐛 Harden dnd/get-data against missing dataTransfer
When the sortable hook or any caller passes a synthetic event
without a dataTransfer property (e.g. a dragend fired after a drop
that has already cleared the transfer), the previous implementation
called .getData directly on the nil/undefined result and threw
"Cannot read properties of undefined (reading 'getData')".
Wrap the body in when-let so get-data returns nil cleanly when
dataTransfer is missing. All three current callers
(hooks.cljs:164, viewport/actions.cljs:531 and :562) already treat
the return value as optional via when-let / when, so no caller
breaks.
Add a regression test covering both the missing-dataTransfer case
and a real dataTransfer roundtrip.
AI-assisted-by: minimax-m3
* 🐛 Harden paste handler against missing clipboardData in forms
When a paste event arrives without a clipboardData property (e.g.
a programmatically dispatched ClipboardEvent in some browsers, or
edge cases like dragging a file with no text content), the previous
implementation called .getData directly on the nil/undefined
clipboardData and threw "Cannot read properties of undefined
(reading 'getData')".
Wrap the body in when-let so the paste logic is skipped entirely
when clipboardData is missing. The existing (string? paste-data)
guard in the inner when already tolerates nil; no other caller
behavior changes.
AI-assisted-by: minimax-m3
* 🐛 Harden paste handler against missing clipboardData in components/forms
Same defensive pattern as the main/ui/forms.cljs paste handler: wrap
the body in when-let so the .getData call is skipped when the
clipboardData property is missing on the paste event. Prevents the
raw "Cannot read properties of undefined (reading 'getData')"
TypeError for programmatic / edge-case paste events.
AI-assisted-by: minimax-m3
* 🐛 Harden v3 text editor paste and styles-fn against undefined receivers
Two related fixes for the "Cannot read properties of undefined
(reading 'getData')" family of bugs in the workspace text editor:
- v3_editor.cljs: wrap the paste body in when-let on clipboardData
so .getData("text/plain") is never called on a nil receiver. The
existing (when (and text (seq text))) guard already tolerates nil
text; only the outer .getData call was unprotected.
- editor.cljs: add (and content ...) to the if branch in styles-fn
so .getText and .getData are never called on a nil content. The
else branch (legacy.txt/styles-to-attrs) is already the correct
fallback for missing content.
Add a regression test that mirrors the fixed patterns and verifies
they no longer throw on synthetic events with no clipboardData or
nil content.
AI-assisted-by: minimax-m3
* 🐛 Harden get-editor-block-data and get-editor-block-type against nil block
getCurrentBlock from Draft.js can return undefined for an empty
selection (e.g. before any block is created). The previous
implementations called .getData / .getType directly on the result
and threw "Cannot read properties of undefined (reading
'getData')" / "...reading 'getType')".
Wrap both functions in (when (some? block) ...) so they return nil
cleanly. Callers in editor.cljs and text_editor.cljs already handle
nil results (render-block short-circuits via the case on type; the
text-data caller in text_editor.cljs lets nil flow up), so no
upstream change is required.
Add a regression test covering both functions with nil and
js/undefined input.
AI-assisted-by: minimax-m3
* 🐛 Harden draft-js block-data helpers against nil block
Three related fixes in the vendored draft-js package:
- mergeBlockData: early-return undefined when block is falsy.
Without this, the first line (block.getData()) throws for callers
that pass a nil block.
- splitBlockPreservingData: guard the blockMap.get(...) lookup. If
the start key is stale (e.g. after a Modifier.splitBlock that
doesn't actually produce the expected key), .get() returns
undefined and the subsequent .getData() throws. Fall back to an
empty Immutable Map for the block data.
- updateBlockData: short-circuit (return state unchanged) when
mergeBlockData returns undefined. Without this, the chain
newBlock.getData() would throw on the same nil-block case that
mergeBlockData now guards.
These match the defensive nil-handling pattern used elsewhere in
the frontend (.getData callers) and protect against stale
selection keys in the Draft.js content state.
AI-assisted-by: minimax-m3
* 🐛 Fix clipboard crash when copying as SVG
clipboard.write with an image/svg+xml payload throws an unhandled DOMException on browsers that do not support the type. Fall back to writeText for that specific failure.
Fixes#10596
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
* 📎 Update Kaleidos Copyright
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
---------
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Relative operators were accepted in operand position, so "10+*3" silently evaluated to 310 instead of being rejected. Make negation a first-class operand so legitimate negative operands ("10 + -3") keep working.
Fixes#9581
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Position-data entries for text shapes could omit the :fills key when the
element had no explicit fills, causing backend malli validation failures.
- Use get-default-text-attrs as base in WASM calculate-position-data
- Default fills when CSS --fills property is absent in DOM calc path
AI-assisted-by: deepseek-v4-pro
Event targets can be DOM text nodes (nodeType 3) which lack the
.closest() method. Add a get-element helper that normalizes text
nodes to their parent Element before calling .closest(), matching
the existing pattern in dom/get-parent-with-data.
Fixes#10640
AI-assisted-by: mimo-v2.5-pro