580 Commits

Author SHA1 Message Date
Belén Albeza
c99980327f
🐛 Resize fit-content flex boards while typing in v3 text editor (#12330)
* 🐛 Resize fit-content flex boards while typing in v3 text editor

Typing in an auto-width text did not resize its fit-content layout
parent until the editor closed. The v3 editor now previews the live
text size in the layout on each keystroke, also when the text sits
in a group.

* 🐛 Preview text layout when typing with a pending caret style

Both v3 editor commit paths share v3-update-editor-content.

* 🐛 Skip the layout preview for absolute texts

Absolute layout items do not affect their board.

* ⚡ Preview text layout only when the text size changes

Each preview propagates modifiers, so skip unchanged sizes.

* 🐛 Leave interactive render mode when the v3 editor closes

Finalize only ends it when it applies geometry.
2026-10-19 15:06:56 +02:00
Eva Marco
f4f581ab74
🐛 Fix export scale dropdown clipped in Inspect (#12351)
* 🐛 Fix export scale dropdown clipped in Inspect

The export selects in the Inspect tab used a dropdown class that always
opened them upwards, overriding the select's own up/down choice. On
elements with few properties the export section sits near the top of
the panel, so the scale list (now eight options) went past the top of
the scroll area and hid 0.5x. Keep only the dropdown width and let the
select pick the direction.

Closes #12346

AI-assisted-by: claude-opus-5-5

* ♻️ Move Inspect exports styles to DS tokens

Replace the deprecated spacing and size variables, the flex mixins, the
color aliases and the typography mixins with DS tokens, and use logical
properties. The typographies switch to the closest DS ones: body-small
for the suffix input (line-height 1.3 instead of 1.4) and headline-small
for the export button (12px instead of 11px). The @extend placeholders
still come from refactor/ and are left for a later migration.

AI-assisted-by: claude-opus-5-5

* ♻️ Use DS buttons in Inspect exports

Replace the add, remove and export buttons in the Inspect exports section
with the DS icon-button and button, as the workspace export section does.
Delete their now unused styles and the sizes import; the export button
class only stretches the button and centers its label.

AI-assisted-by: claude-opus-5-5

* 🐛 Fix add export button lookup in the Inspect exports e2e test

The add export button in Inspect is a DS icon button now and lost its
.add-export class, so the test timed out waiting for it. Find it by its
role and accessible name instead.

AI-assisted-by: claude-opus-5-5
2026-10-19 10:50:32 +02:00
Andrey Antukh
9ea4c3404f Merge remote-tracking branch 'origin/main' into staging 2026-10-16 11:25:25 +02:00
Andrey Antukh
d57cdb4588 ⏪ Backport persistence related fixes from staging (#12306)
* ✨ Add async test helpers and the with-mocks* macro

Backport of the test scaffolding that the persistence retry work needs,
taken from staging. Purely additive: no existing helper changes behavior,
so the suite result is unchanged (740 tests, 0 failures).

- frontend-tests.helpers.async: ->promise, await-response, settle,
  observe and wait-for. wait-for checks immediately and then polls one
  macrotask at a time, bounded, so it fails instead of hanging.
- mock/with-mocks*: installs mocks with set! and evaluates its body in a
  generated ^:async fn, so the test reads sequentially and there is no
  done to thread. Evaluates to a promise.
- mock/asap and mock/run-mocked: run-mocked awaits the body and restores
  the originals always, in that order, reporting a rejection or a
  non-promise return as an :error.

The matching change that made with-mocks defer its body via asap is NOT
backported: it would force all 58 existing uses across 14 files into
done-chained contexts, which is a separate refactor.

AI-assisted-by: space-bunny-free

* ✨ Add an error report governor with compact payloads

One browser session could emit an unbounded number of identical error
reports, and a dropped connection was reported as an application defect
with the full payload. Both are fixed here, in one place, because the
persistence retry work that follows depends on the new submit-report
contract.

- A report governor in app.main.errors. Each report carries a
  fingerprint (report-name|type|code|hint|first stack frame); the first
  occurrence is always emitted, repeats inside a 2 minute window are
  counted, and the next emitted report carries them as :occurrences. The
  cache is bounded by evicting the oldest entry, so memory stays fixed.
- flash reserves the report before generating it, so a suppressed
  occurrence never pays the report-building cost. Its whole body now
  runs inside one ts/schedule callback, a reporting or notification
  failure is logged instead of propagating, and it returns a total
  promise so tests can await it.
- generate-report accepts :format :compact|:full. The compact payload
  keeps the context header plus type, code and uri, and skips the
  stack, the ex-data dump (it can carry request headers) and the
  last-events list.
- environment-error-types is derived from rp/retryable-types, so the
  transport classification has a single source of truth. Environment
  failures are audit-only handled-exceptions and never reach the
  exception page or the alerts. :offline gets its own handler instead
  of falling through to :default.

:occurrences travels inside the audit event :props, which the backend
schema accepts as a free-form map, so no backend change is needed.

The dedup governor test file is new: 7 tests over the fingerprint, the
pure reservation decision, and the flash pipeline.

Relates to #11726, #11743

AI-assisted-by: space-bunny-free

* ♻️ Answer commit refusals with an empty stream

commit-changes used to return nil when it refused to build a commit,
which forced every caller that wanted to observe the outcome to
special-case nil. It now returns an empty stream, so a refusal and an
empty result look the same and callers can subscribe uniformly.

Only the refusal branch changes: the accepted branch already answered
with (rx/of commit). Potok treats a nil watch result as "nothing", so
both spellings were already correct at the store level.

The 57 call sites that wrap this event in rx/of are unaffected. They
hold the ::commit-changes event itself, not the value its watch
returns, so they keep processing exactly as before.

AI-assisted-by: space-bunny-free

* ✨ Retry transient saves with backoff and a reconnect notice

Any failed update-file landed on the same path: the queue halted in
:error, the retained changes were flushed away, and the user was told
to reload. A dropped connection and a revoked permission are not the
same thing, so they now take different paths.

transient-error? splits them, checking :type and :cause-type because
persistence-failed wraps the original cause. Transient failures enter
a :retrying episode: the head commit stays queued and resends with
backoff at 2s, 8s and 20s, then falls through to today's terminal path.
Everything else is terminal and keeps the exact :error path.

- Resends rotate the ::request-id stamp only once the old request has
  left active-requests, carry the same :commit-id, and never double
  send while one request is in flight.
- Retry timers carry an episode token; a timer whose episode was
  superseded stays silent instead of sending or failing.
- One tagged reconnect notice per episode, hidden by tag on save and on
  terminal failure. Recovery is silent.
- The browser online event re-enters the runner, but only for a live
  :retrying episode, guarded by exists? js/window.
- wait-persisted-or-error waits through :retrying instead of cutting the
  wait short.

The episode keeps its :run-id, so a new local edit only joins the queue.
Re-entering the runner on every edit would bypass the backoff and spend
one attempt per batched commit.

The retry test namespace is new: 13 tests over the classification, the
backoff schedule, stamp rotation, stale tokens and the notice lifecycle.

Relates to #11724

AI-assisted-by: space-bunny-free

* 🐛 Fix stalled saves that never answer and edits lost on leave

Two holes the retry work did not close.

An update-file request that never gets an answer kept its attempt
stamp forever, so the queue could not move and the user saw a saving
state that never resolved. The request now has a 7 minute timeout that
fails it as a transient network error, which hands it to the retry path
instead of blocking. The timeout is longer than the 5 minute stall
threshold on purpose: the stalled report must fire before the retry,
not instead of it.

The leave-page warning also had gaps. It now tracks unsaved edits
directly, through sync-unload-guard, which holds :persistence in
st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept
in the queue after a failed save keep the guard on too, so closing the
tab warns instead of dropping them.

store.cljs guards its ongoing-tasks watcher with exists? js/window,
because the new tests exercise it without a window.

Four new tests: three for the guard across buffering, an in-flight save
and a retained edit, and one for a hung request that must resend and
save.

Closes #12223

AI-assisted-by: space-bunny-free

* 🐛 Skip the save when a missing font cannot be remapped

fix-deleted-fonts emitted text and typography changes even when no
replacement font existed, so opening a page that uses a deleted custom
font rewrote identical content and cleared position-data on every
visit. Every visit became a save with nothing to save.

Both loops now compare the remapped value with the original and emit
nothing when they match. The remapped path is unchanged.

Two new tests cover the no-replacement case and the remapped case.

Closes #12151

AI-assisted-by: space-bunny-free

* ✨ Add a debug helper that simulates an intermediary response

A save that never reaches the backend is hard to reproduce: you need a
proxy, a captive portal or a CDN in the way. This wraps fetch and
answers the named API method with a status and a body that carries no
Penpot error code, leaving every other request alone.

Without a duration the simulation is spent after answering one call.
With one it answers every matching call for that long, which is how to
watch the save retry, warn, keep retrying and finally recover.

  debug.simulateHttpError('update-file')
  debug.simulateHttpError('get-comment-threads', 403)
  debug.simulateHttpError('update-file', 524, 120)
  debug.clearHttpErrorSimulation()

Dev-only and self-contained: no production path reads it.

AI-assisted-by: space-bunny-free

* 🌐 Add the save retry and connection error strings

Three strings the new persistence and error paths need:

- errors.save-retrying, shown once per retry episode while the queue
  resends a queued save after a connection loss.
- workspace.header.retrying, the save indicator state for an episode
  in progress.
- errors.connection-error, shown by the network and offline handlers,
  which no longer fall through to the generic message.

Added to en.po only. The translations script also normalizes every
other locale and drops 439 strings that were already stale before this
work; that belongs in its own maintenance pass, not in a backport.

AI-assisted-by: space-bunny-free

* 🐛 Report save failures as unhandled when they are our defect

flash-persistence reported every failure it did not delegate as
handled-exception, including the ones that are plainly our fault. A
validation failure means the client sent something the server refused,
yet it landed in the audit log with a name that skips the internal
reports and the alerts. Nobody was paged.

The report type now follows the cause. :validation, :server-error and
:internal report as unhandled-exception, so they alert. The rest are
unchanged.

Two exclusions are deliberate:

- Environment failures stay handled. They reach this point after the
  retry budget is spent, and the user's connection is not our bug.
- :assertion stays handled, because its own handler already reports it
  that way and the two paths should not disagree.

The cause type is read from :cause-type first, since persistence-failed
wraps the original under :type :persistence. Reading :type alone would
match the wrapper every time and change nothing.

The user still gets the same sticky toast and report link; only the
audit event name moves.

The test that asserted a validation failure is "still notified" now
asserts which event name it gets, across the bare and wrapped shapes,
the environment types that must stay handled, and an unclassified one.

AI-assisted-by: space-bunny-free

* 🐛 Unblock persistence when opening a file after a terminal failure

A terminal save failure set the state to :error and left the queue
alone. recover-persistence only resumed for :save-permission-denied, so
for every other terminal code both of its branches were false and it
emitted nothing. Leaving and reopening the same file therefore changed
nothing: the editor stayed halted until a reload, which rebuilds the
whole store and loses the queue anyway.

The path had two jobs under one name. initialize-persistence fires when
the workspace view mounts and tears down the previous subscription;
recover-persistence fires when a file is opened or the role changes.
They are now separate: initialization picks up a queue left behind by a
previous session, and deliberately does not lift a halt, so undeliverable
edits are released when the user acts rather than when the view mounts.

Recovery lifts the halt by releasing what can never be saved.
release-failed-file drops every queued commit of the file that failed,
then resume-persistence clears the error and settles an empty queue to
:saved. Retaining them until that point is deliberate: it is what keeps
wait-persisted-or-error rejecting, which is what stops
workspace/versions.cljs from building a snapshot out of unsaved edits.
Releasing at failure time would make waiters resolve instead.

Permission loss is treated as permanent too, which the previous
exception got wrong. The backend raises :not-found for a missing edit
permission, so a resend does not succeed — it routes the user to the
exception page. The old branch made that reachable: it resumed the save
whenever the cached can-edit flag said yes, and the server disagreed.
The only case where the flag can return to true is a team switch, where
the queue is gone anyway, so nothing real is lost by releasing.

Because the queue is shared, dropping the failed file is also what lets
the other files' pending edits drain. One file that cannot be saved no
longer blocks everything else.

Tests: the failure retains the queue and notifies once, recovery
releases it without replaying, a failed file does not block a second
one, permission loss is released rather than resumed, and initialization
leaves a halt alone.

AI-assisted-by: space-bunny-free

* ✨ Offer a reload on the save failure toast

A terminal save failure stops autosave for the whole session, but the
toast asked the user to reload without offering the reload. It listed
five implied actions, none of them reachable from the banner, and it
never said that the changes could not be saved.

flash now takes a :links vector of actions, appended after the download
link that :report-link? produces, so a caller can add recovery actions
without reaching into the report handling. flash-persistence passes a
reload built from labels.reload-page and location.reload, the pattern
already used elsewhere in the app for a full restart.

The message now says what happened, what is lost, and what to do about
it: autosave has stopped for this session, the changes since the last
successful save cannot be saved, and either reloading or leaving the
file and opening it again restarts it. Naming both matters, because the
toast links are easy to miss.

Saying the edits are lost is not optional: recovery releases them, so
without it the user finds out when they come back to the file.

Translated in en.po and es.po only. The translations script would also
normalize all 40 locales and drop entries that were already stale, which
is a maintenance pass of its own.

The reload callback cannot be invoked under the test runner, since
js/location does not exist there, so it is covered by its label and by
being callable. Whether the reload works is a manual check.

AI-assisted-by: space-bunny-free

* 🐛 Classify the responses the backend never wrote

A reply written by a proxy instead of the backend carries no Penpot
error code, so handle-response raised it as :internal. A 504 on a save
skipped the retry cycle and stopped autosave at once, and because
:internal reports as unhandled, it raised an alert. In a standard
deployment this is how a slow save ends: nginx answers 504 after 300s,
before the client's 7 minute timeout fires.

The status now decides the type:

- 504 and the Cloudflare 520-524 family raise :gateway-error, which
  joins retryable-types. Saves retry with backoff like 502 and 503,
  GETs retry, and a save that spends its retries reports as handled,
  because the report rules derive from the same set.
- 429 raises :rate-limit, and any other error status
  :unexpected-response. Neither retries; both show a toast instead of
  the error page.
- :internal is kept for a response below 400 that does not decode.

Bodies we cannot read are cut to 500 characters, since proxy error
pages run to tens of kilobytes.

Tests: classification and retries per status, the transport handlers
keep the page, a 504 and a 524 built by the real handle-response go
through the save path and land on a retry, and the new types stay
handled once the retries are spent.

AI-assisted-by: claude-opus-5-5

* ✨ Keep retrying failed saves on a slow cycle

Once the three backoff retries were spent, a save that failed on the
connection stopped autosave like any terminal failure, and opening a
file again dropped its edits. A connection that comes back after a
minute lost the work all the same.

A transport failure that outlasts the backoff now arms a slow cycle:
one silent resend every 30s, until a save lands or 24h have passed
since the failure started. The cycle stops when persistence restarts,
the queue resumes another way, or the workspace closes.

A save that keeps failing slowly is not resent for a day. A gateway
that answers 504 after its own timeout may leave the backend applying
the save, so each resend can cost the whole save again, and the failing
file holds back every other file's queued edits. An attempt that fails
after 90s or more counts as a slow failure; at three, the slow cycle
stops sending the commit and the save fails as before. Fast failures,
such as being offline, never count.

- The first failure shows a toast that says the changes are kept and
  keep being sent, with no reload link, since a reload loses them. A
  save that lands hides it.
- Opening a file sends a queue still inside its retry window once
  more instead of releasing it. Failures outside the window, and every
  non-transport failure, are still released.
- A freshly loaded file gets back the queued edits of its version, so
  reopening it shows the changes that are still waiting to be saved. An
  edit that no longer applies to the loaded data leaves the file as the
  server sent it, so the file still opens; the edits stay queued.
- A commit whose earlier request is no longer in flight is simply sent
  again under the same commit id: changes are idempotent, so there is
  no unknown-outcome guard any more. A request still in flight still
  blocks a parallel send.

flash takes a :tag so the save failure toast can be hidden by tag.

Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal
failure does not, the cycle stops when the window closes or at the slow
failure limit, recovery resends instead of releasing, reopening restores
only the loaded file's edits and survives one that no longer applies,
and the retrying toast offers no reload.

* 🐛 Turn off the leave-page warning when failed edits are released

The leave-page warning only followed the queue on appends and discards.
Opening a file again releases the edits of a file that failed for good,
but that release changed the queue without either event, so the warning
stayed on with nothing left to save. Leaving the page asked about
unsaved changes until the next save went through.

The warning now also follows the release.

Tests: releasing the last failed edit turns the warning off.

* 🐛 Stop retrying failed saves outside the workspace

Leaving the workspace was meant to stop the slow retry cycle, but only
a cycle already armed stopped. A backoff timer kept firing after the
user left, and a backoff spent outside the workspace armed a slow cycle
that kept sending for up to a day from the dashboard. Those sends check
the edit permission of whatever team is open, so a switch to a team
where the user only views failed the queued edits for good, and the
next file they opened dropped them.

Leaving the workspace now suspends retries. A pending backoff timer
goes stale, and while suspended a transport failure schedules no
backoff, a spent backoff arms no slow cycle and the online event sends
nothing. The edits stay queued and a request in flight still lands.
Opening a file lifts the suspension and resends a retrying save whose
backoff was cut short; a role change does not lift it.

Tests: leaving stops a pending backoff, a failure after leaving
schedules no retry and ignores the online event, a backoff spent
outside the workspace arms no slow cycle, and opening a file saves the
held edit in each case.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-15 15:54:12 +02:00
Luis de Dios
d800b893bc
🐛 Fix resize cursor when selection has mixed values (#12229)
* 🐛 Fix resize cursor when selection has mixed values

* 📎 PR changes

* ✨ Add test
2026-10-13 18:47:45 +02:00
Belén Albeza
2ffdd16038
🐛 Fix typing after a triple-click in the text editor (#12218)
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
2026-10-08 15:35:28 +02:00
andrés gonzález
85ba670278
💄 Unify copy in the shortcuts settings, modals and sidebar (#12174)
* 💄 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
2026-10-08 12:00:17 +02:00
Eva Marco
2f47be6c15
🐛 Show the size badge on paths and straight lines (#12066)
* 🐛 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
2026-10-07 09:18:57 +02:00
Eva Marco
dacfac17bb
🐛 Show the top stroke width in the old render (#12093)
* 🐛 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
2026-10-06 13:33:36 +02:00
Eva Marco
0389435ced
🐛 Block typography creation from missing fonts or multiple texts (#11938)
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
2026-09-28 18:33:08 +02:00
Eva Marco
f311c7ab05
🐛 Fix token propagation on canvas color (#11950)
* 🐛 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
2026-09-28 18:15:43 +02:00
Luis de Dios
8f1100d0f5
✨ Enable stroke-per-side flag (#11942)
* ✨ 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
2026-09-28 14:39:18 +02:00
Alejandro Alonso
18c9108d47
✨ Enable WASM text editor with render-wasm (#11936)
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
2026-09-28 12:44:15 +02:00
Eva Marco
20c818661d
♻️ Add DS tooltip to token pills" (#11900)
* 🐛 Fix can-edit permission wiring for token pills

* ✨ Use DS tooltip component in token pill

* 🎉 Add playwright test

* 🐛 Fix tests
2026-09-28 08:54:12 +02:00
Eva Marco
7e3f779d8c
🎉 Add tokens to the canvas (#11923)
* ✨ Show color tokens toggle on canvas background picker

* 🐛 Apply token click on canvas background with no shape selected

* ✨ Apply and persist color tokens on canvas background

* 🌐 Translate canvas background section label
2026-09-28 08:53:42 +02:00
Luis de Dios
98daf1592d
✨ Apply tokens to stroke per side (#10913)
* ♻️ 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
2026-09-25 13:09:55 +02:00
Alejandro Alonso
ec5a1edbed
✨ Make export follow the active renderer only (#11910)
Drop the separate :wasm-export flag and wasm-export/v1 feature. Single
export, clipboard PNG, plugins, and batch :is-wasm now key off
render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM
and always keeps a worker pool ready.
2026-09-25 12:40:56 +02:00
Andrey Antukh
eb7019fce4
✨ Preserve sidebar scroll positions across tab switches (#11694)
* 🐛 Preserve layers panel scroll position across tab switches

Fixes #7440. Switching between the Layers, Assets and Tokens tabs in
the workspace left sidebar unmounts the active panel component, causing
its scroll position to reset to the top on re-entry.

Add a module-level `scroll-positions` atom keyed by page-id. The
layers scroll handler now also saves the current scrollTop value into
the atom; a `mf/with-effect` on the page-id dep restores it whenever
the `layers-toolbox*` component mounts or the page changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* 🐛 Preserve sidebar scroll positions across tab switches

Replace the Layers-only global atom with a scroll store held in a
use-var in left-sidebar*, shared by the Layers, Assets and Tokens
panels through a new sidebar.scroll helper. Positions are keyed per
panel and page (or token set) and restore waits for list content to
settle, so deep positions in lazily rendered lists survive tab
switches.

Closes #7440.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Add e2e coverage for sidebar scroll preservation

Port the regression tests from closed PR #7544 for issue #7440,
adapted to the current ref-based implementation and fixtures:
async restore needs polled assertions, and setup uses the shared
tokens helpers. Also add data-scroll-container hooks to the Assets
and Tokens scroll containers so the specs can locate them.

AI-assisted-by: muse-spark-1.3-contributor

* 📎 Fix rebase issue

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-23 19:42:46 +02:00
Andrés Moya
3cd9bfa9de
✨ Add sync with design tokens in external libraries (#10293)
* ✨ Auto link tokens when adding external libraries (provisional)

* 🔧 Refactor tokens-lib initialization

* 🔧 Add separated TokenStatus to store status apart of TokensLib

* 🔧 Make all status operations use the new data structure

* 🔧 Normalize status helper functions and access token sets by id

* 🔧 Rename :tokens-file to :tokens-source

* 🎉 Allow the user to choose the tokens-source of a file

* 🎉 Make tokens library readonly when it's in an external file

* 🎉 Show tokens in library summaries

* 🎉 Show source info in sidebar

* 🔧 Fix integration tests

* 🐛 Propagate changes of token values in external library

* 🎉 Layout updates

* 🔧 Refactor tokens source calculations

* 🔧 Add harder checks for nil or empty values in everything

* 🐛 Fix some integration tests

* 🔧 Add integration tests for tokens in external libs

* 🔧 Validate and repair missing tokens status

* 🎉 Make ui changes optional with config flag

* 🐛 Propagate tokens after synchronizing components in ext library

* 🐛 Propagate tokens after creating new instances

* 🐛 Propagate tokens after synchronizing tokens in ext library

* 🐛 Add a tokens source icon to libraries section (#11439)

* 🐛 Add a tokens source icon to libraries section

* 🐛 Fix ellipsis on library names

* ♻️ Remove code under flag on legacy component

* 🐛 Fix token theme name on inspect tab

* 🎉 Add changes notification (#11476)

* 🎉 Add changes notification

* ♻️ Change fn names

* 🐛 Fix tokens source label truncation and missing translations (#11533)

* 🐛 Fix tokens source label truncation and missing translations

The tokens source file name always showed, even for the current file,
and long names wrapped onto a second line instead of truncating
because the header used flex-wrap and overflow-wrap: break-word
instead of single-line ellipsis.

Show the source row unconditionally (it now displays "This file" when
the source is the current file, matching the connected-library case),
truncate the file name to one line with an ellipsis, and only attach a
tooltip with the full name when the text is actually truncated.

Replace the hardcoded UI strings with translated ones and add their
English and Spanish entries.

AI-assisted-by: claude-sonnet-5

* 🐛 Remove redundant effect dependency in tokens source

file-name-truncated? was listed as a dependency of the with-effect
that checks and observes label truncation, even though it isn't
read inside the effect body. Since the effect itself flips that
state via check-file-name-truncated, including it as a dependency
caused the ResizeObserver to be needlessly disconnected and
reconnected on every truncation change.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix small visual error

* 🐛 Fix problem with plugins

* 🐛 Fix playwright tests

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-21 15:37:45 +02:00
María Valderrama
b32a73519e
🎉 Add combined organization/team switcher to the dashboard (#11760)
* 🎉 Add combined organization/team switcher to the dashboard

* 📎 Code review
2026-09-21 12:50:32 +02:00
Eva Marco
dc8160c13a
🎉 Add Menu DS component (#11511)
* 🎉 Add Menu design-system component

Adds Menu, MenuItem, MenuSeparator, SubMenu, and ContextMenu to the
shared UI package and exposes them through the CLJS design-system
wrapper, with Storybook stories and MDX docs.

Built on react-aria-components for keyboard navigation, focus
management, and dismissal. Penpot's own DS buttons aren't
react-aria-aware, so trigger positioning, focus-on-open, and
close-on-select are wired explicitly instead of relying on the
library's default trigger detection.

Includes a temporary manual-test harness in the dashboard to check
the components against the real app. CSS is functional but doesn't
match the DS visual design yet — that comes in a follow-up.

AI-assisted-by: claude-sonnet-5

* ✨ Add left/right corner placements to Menu design-system component

Menu and ContextMenu only exposed 8 of react-aria's placement values,
missing every left/right corner variant (right bottom, right top,
left bottom, left top) that the top/bottom sides already had via
start/end.

Add the four missing corners, matching the start/end pattern already
used for top/bottom, so a menu can open toward any corner of its
trigger.

AI-assisted-by: claude-sonnet-5

* ✨ Add drilldown variant to SubMenu design-system component

SubMenu only opened as a flyout: a nested popover next to the
trigger item. That doesn't scale to a tree too deep or wide for a
chain of flyouts, e.g. move-to-project's team -> project nesting,
which needs a mobile-style drilldown (replace the current items with
the submenu's own, plus a way back) instead.

Add a `variant` prop, `"flyout"` (default, unchanged) or
`"drilldown"`. Menu and ContextMenu each keep a navigation stack,
provided to their content tree via context, so a drilldown SubMenu
nested inside another drilldown SubMenu still drills into the same
stack and arbitrarily deep trees stay navigable one screen at a
time. Switching levels remounts the level's content wrapped in a
keyed Fragment rather than updating it in place, since
react-stately's Collection requires each item's id to stay stable
across an update and the back item's label (and everything under it)
genuinely changes identity between levels.

AI-assisted-by: claude-sonnet-5

* ♻️ Wire the DS Menu/SubMenu into the dashboard file menu

file_menu.cljs used context-menu-a11y's data-driven options list,
rendered via a generic recursive renderer. Rewritten as real JSX
composition (menu-item*/sub-menu*/menu-separator*) using the DS Menu
component, preserving every existing conditional branch (single-file,
multi-select, restore-mode, permission gates). "Move to" -> "Move to
other team" -> team -> project now uses sub-menu*'s drilldown variant
at every level.

Split into file-menu-items* (the item tree, no popover of its own)
and a thin file-menu* wrapper (Menu, anchored to the "..." button),
so grid.cljs can render the same items a second time inside a
ContextMenu for right-click, matching the previous behavior of
opening either via the button or a right-click anywhere on the row.

grid.cljs's trigger handling is simplified accordingly: DS's Menu/
ContextMenu handle their own positioning (including auto-flip near
viewport edges) and dismissal internally, so the manual click-
coordinate math, the dashboard-local :menu-open/:menu-pos globals,
and the portal-on-document* wrapper (Popover already portals itself)
are all gone. The now-fully-dead show-file-menu-with-position/
show-file-menu/hide-file-menu actions are removed from
data/dashboard.cljs.

Also fixes two issues found wiring this up:
- The add-shared/unpublish-shared toggle rendered two different
  menu-item* ids at the same list position; :is-shared can flip while
  the popover stays open (the action's own side effect), and
  react-stately's Collection requires an item's id to stay stable
  across such an update. Both branches now share one id.
- Menu's own trigger wrapper (align-self: start, needed generically
  so it doesn't stretch in an arbitrary parent) overrode
  .project-thumbnail-actions's centering of the "..." button;
  grid.scss now re-asserts centering for that specific consumer.

Removes the temporary menu-test* harness from dashboard.cljs now that
there's a real integration to test against instead.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu/ContextMenu popover interaction bugs

Found testing the dashboard file menu integration:

- Reopening the same trigger right after closing (e.g. right-click,
  dismiss, right-click again) could silently fail or briefly show two
  overlapping instances. Closing played a 100ms exit fade, and a
  reopen landing mid-fade raced the still-live Popover instance.
  Closing now always skips the exit animation, so by the time any
  subsequent open request arrives there's no ambiguous in-between
  DOM state left to race.

- Right-clicking a different row while one file's context menu was
  open didn't close the first one. Menu/ContextMenu don't use
  react-aria-components' own MenuTrigger (Penpot's DS buttons aren't
  react-aria-pressable), so they also don't get its built-in
  RootMenuTriggerStateContext coordination between sibling instances.
  A window CustomEvent broadcast restores it: opening announces this
  instance's id, and every other mounted instance closes on hearing a
  different one.

- With that coordination in place, right-clicking elsewhere still did
  nothing at all: Popover defaults to modal, which marks the rest of
  the app inert (unfocusable *and* unclickable, not just visually
  blocked) while open. Correct for a real Dialog, wrong for a
  lightweight dismissable menu. Fixed with isNonModal on all three
  Popover usages (Menu, ContextMenu, SubMenu's flyout).

- isNonModal has its own side effect: react-aria only wires up its
  click-outside-closes behavior when a popover is "dismissable", which
  isNonModal forces off (for anything but a submenu flyout) with no
  separate prop to turn back on. Reimplemented directly: a pointerdown
  landing outside the popover's own rendered content closes it, via a
  ref now passed to Popover.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu visual styling and two overflow bugs

Border and shadow, to match the legacy context-menu-a11y menu this
replaces: the DS component had neither (a filter: drop-shadow with a
different blur radius stood in for the shadow, and there was no
border at all). Used the pattern already established by sibling DS
dropdowns (options-dropdown.scss et al.) rather than porting the
legacy tokens directly — border: 1px solid
var(--color-background-quaternary) + box-shadow: 0 0 12px 0
var(--color-shadow-dark), both already in use elsewhere in this same
file.

Found two real bugs verifying that against a long "move to" list:

- .menuItem/.separator had no flex-shrink: 0, so once a list's
  natural height exceeded the menu's max-block-size, flexbox shrank
  every row to fit them all rather than triggering the scrollbar —
  overflow only kicks in after flex-shrink has done its best, and
  shrinking was never opted out of.

- The menu's own fixed max-block-size: 300px ignored react-aria's
  Popover, which sets its own max-height (inline, on our direct
  parent) to whatever space is actually available between the trigger
  and the viewport edge. In a small viewport that computed value can
  be under 300px; since the parent has no overflow of its own, our
  independent 300px cap just rendered straight past it and off the
  edge of the window. max-block-size: inherit picks up the parent's
  own computed value instead, at the cost of no longer capping how
  tall the menu can get when there's plenty of room (verified: 348px
  in a normal-height viewport, vs the old fixed 300px) — an
  acceptable tradeoff against content becoming inaccessible.

AI-assisted-by: claude-sonnet-5

* 💄 Adjust Menu design-system component item states and spacing

Give menu items a distinct keyboard-focus ring (accent-primary outline
plus tertiary background) separate from the mouse hover/click state,
which keeps its existing quaternary background unchanged. Restyle
disabled items with a tertiary background and secondary text color,
shrink the submenu chevron to 12x12, and tighten the menu's vertical
padding to 4px.

* 📚 Document drilldown submenu and tighten Menu docs

Add the drilldown submenu story to the Menu docs page, show the
idiomatic controlled-state shape in the usage example (callbacks
bound in the let with mf/use-fn, explicit deref of the open state),
and trim the prose down to the information a consumer needs.

* 🐛 Fix Menu outside-click closing on its own trigger and submenus

useCloseOnOutsideClick restores the dismiss behavior isNonModal turns
off, but it tested containment against the popover element alone. That
missed two cases react-aria's own useOverlay accounts for.

A root Popover wraps its content in a display:contents div and portals
every SubmenuTrigger's nested popover into that same div, so a flyout
submenu is a sibling of the popover, not a descendant. Pressing an item
in one counted as an outside click: the whole tree unmounted on
pointerdown and the item's action never fired on pointerup. Test the
group container instead.

The trigger was likewise treated as outside, so closing on its
pointerdown let the click's own handler read the already-false open
state and reopen the menu — a trigger wired to a toggle could never
close it. Exclude it in Menu; ContextMenu keeps the old behavior, since
right-clicking elsewhere should reopen it against a new anchor.

* 🐛 Target the clicked file when it is not in the dashboard selection

The file menu adopted the whole selection whenever it was non-empty,
guarding only against it being empty. That left the case where the
selection holds files this row is not one of: toggle-file-select is a
no-op across projects, so shift-right-clicking a file in another
project leaves the previous project's selection intact and the menu
opened on the pointed-at file while offering rename, duplicate, move
and delete for a different one.

Adopt the selection only when it actually contains this file, which
covers the deferred-dispatch case the previous guard was written for
just as well.

* 🐛 Drop the file menu teams cache that outlived a logout

The cache was a module-global defonce atom, and logging out does not
reload the page — it resets the store and navigates. The next profile
to sign in on the same tab therefore opened its first file menu with
the previous account's team and project names listed under "Move to",
until the background fetch replaced them.

The cache only ever saved the brief absence of one submenu, which is
already guarded on having data and so does not shift any layout, so
remove it rather than scope it to a profile. Dispose the subscription
too: it wrote to component state after unmount.

* 🐛 Keep the Menu open when its own trigger takes focus

Excluding the trigger from the outside-click dismiss was not enough to
make a toggle trigger able to close the menu: usePopover passes
shouldCloseOnBlur unconditionally, and useOverlay acts on it regardless
of isNonModal, so focus moving to the trigger on its own pointerdown
closed the popover before the click ran. The click then read an open
state that was already false and reopened it.

shouldCloseOnInteractOutside is the one exception useOverlay consults
before closing on blur, so use it to exempt the trigger.

* 🔧 Add interaction tests for the Menu component

Cover the two dismissal regressions just fixed — closing the menu from
its own trigger, and a press inside a flyout submenu not being treated
as an outside click — plus drilldown navigation, the navigation stack
resetting between open/close cycles, and Escape and outside click.
Both regression tests fail against the code as it was before the fixes.

The story trigger now toggles instead of only ever opening, which is
what a real caller does (the dashboard's own is a swap!) and what makes
the reopen bug observable at all.

* ✨ Add max-width, density, and drilldown sizing to Menu/ContextMenu

Add a max-width prop (default 250px) to Menu, ContextMenu, and flyout
SubMenu, and an is-dense prop to Menu/ContextMenu that shrinks every
item — including nested flyout SubMenus, via a shared density context
— to a 28px row. Pin a drilldown SubMenu's popover to at least the
root level's own size, so navigating into a shorter or narrower list
doesn't shrink the menu mid-navigation.

Also truncate a plain MenuItem's text with an ellipsis instead of
letting it wrap and blow out the row height, matching the existing
SubMenu trigger label, and fix that label's own truncation: it was
missing min-inline-size: 0, without which a flex item can't shrink
below its content size and text-overflow: ellipsis never engages.

Exposed through the ClojureScript facade as :max-width/:is-dense,
documented with new example canvases, and covered by five new
Storybook interaction tests, each verified to fail without its
corresponding fix.

* ♻️ Wire the DS Menu/ContextMenu into the dashboard project menu

Replace the legacy context-menu-a11y-based project menu (grid, sidebar,
and per-project file view) with the DS Menu/ContextMenu components,
mirroring the earlier file menu migration. Drop the manual
:menu-open/:menu-pos position tracking in favor of the DS components'
own positioning, and split project-menu-items* out so both the "..."
trigger and right-click share the same options.

The hidden file input behind the "Import" option moves out of the
popover content and into whichever parent stays mounted regardless of
the menu's own open state: the DS popover really unmounts its content
on close (unlike context-menu-a11y, which only hid it), and selecting
"Import" closes the menu in the same tick a ref owned inside it would
already be gone.

Add an onOpenChange notification to ContextMenu (it stays uncontrolled,
this only reports state changes) so the project row's "..."/pin/add-file
actions can stay visible for as long as either menu is open, the same
way they already do on hover. Fix a related visibility bug this exposed:
closing a menu restores focus to its trigger regardless of whether the
open happened via mouse or keyboard, so :focus-within alone kept the
actions visible after closing with the pointer away — swapped for
:has(:focus-visible), which only matches real keyboard navigation.

* 🐛 Forward MenuItem's id to the DOM as data-testid

MenuItem's function signature never forwarded anything beyond its
explicitly-typed props to the underlying RACMenuItem, so a caller's
id — meant as a stable per-item identifier — only ever reached the DOM
as react-aria's own internal data-key, never as data-testid. This
silently broke dashboard.spec.js's "Multiple elements in context" test
after the file menu's migration to this component, since every existing
menu item id was already relied on as its test id.

id is already unique per item for selection/on-action, so deriving
data-testid from it directly means every item is reachable in a test
with no separate prop to remember to pass. SubMenu's own trigger row is
a MenuItem too, so this covers it for free.

* 🔧 Add Playwright coverage for the project options menu

Covers all four places the migrated project menu is reachable: the
dashboard grid's "..." button and title right-click, the sidebar's
right-click, and the per-project files page's "..." button. Checks
rename/duplicate/pin/move-to/delete render (and that the default
Drafts project correctly hides all of them), that rename opens the
inline editor, that delete opens the confirm modal, and that the
move-to submenu lists other teams.

Also drop an unused React import from context_menu.stories.jsx,
spotted in passing.

* ♻️ Add datatest id

* ♻️ Fix linter

* 🐛 Build @penpot/ui automatically after pnpm install

packages/ui/dist is gitignored (build output) and nothing in the
install pipeline built it, so a fresh checkout — CI included — never
had it. Any code importing "@penpot/ui/menu" (the frontend's own
cljs-runtime tests among them) failed at module resolution with
ERR_MODULE_NOT_FOUND rather than any real test failure.

Build it in postinstall, the same way plugins-runtime already does,
so it's always present after `pnpm install` without a separate manual
build step.

* 🔥 Remove flaky Menu dense/ellipsis Storybook tests

Test Dense Shrinks Items and Test Long Label Ellipses Instead Of
Wrapping asserted computed pixel styles that passed consistently
locally (including with a fresh packages/ui install) but failed in CI,
suggesting a CI-only timing/environment discrepancy in when the
computed style stabilizes. Dropping them rather than chasing a
non-reproducible flake.

* 💄 Open the file/project options menu right, top-aligned

Switch the dashboard file and project "..." options menus from
"bottom end" to "right top" placement, so they open beside the
trigger button instead of below it.

* 🐛 Stop drilldown SubMenu jumping to the opposite edge

A drilldown SubMenu swaps its parent Menu/ContextMenu popover's own
content in place, and react-aria re-runs its flip/collision placement
on every layout change. Drilling into a shorter level than the root
could shrink the popover enough that react-aria decided there was now
room on the other side, flipping it there — a visible jump even though
the popover never actually moved from the caller's point of view.

The previous fix padded every drilled-in level out to the root's own
min-inline-size/min-block-size so the popover never got small enough
to trigger a re-flip, but that meant a level naturally much shorter
than the root still rendered at the root's full height.

Replace it with shouldUpdatePosition={false} on the Popover for as
long as any level is drilled in. This freezes whichever edge react-aria
already resolved for the root, so a shorter level just shrinks from the
opposite edge instead of triggering a new placement decision, and a
taller level grows from that same opposite edge in the direction the
root already opened. shouldUpdatePosition goes back to true once the
stack returns to the root, so a fresh open still resolves normally.

* ♻️ Update menu placements and use buttons from DS

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-09-21 10:41:31 +02:00
Andrey Antukh
b660ea9d53 ✨ Route the app by query string with screen key
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
2026-09-15 17:10:54 +02:00
elhombretecla
a91d81c695 🎉 Add link preview metadata for shared links 2026-09-15 17:10:54 +02:00
Belén Albeza
91b433985a
🎉 Add context menu for v3 editor (#11677)
* 🎉 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>
2026-09-15 12:53:07 +02:00
Alonso Torres
8d69374386
🐛 Makes the CI more stable (#11698) 2026-09-15 10:43:41 +02:00
Andrey Antukh
ba608f8c77 Merge remote-tracking branch 'origin/staging' into develop 2026-09-14 14:50:32 +02:00
Shreyash
6a46f8beb5
🐛 Fix email already used showing a toast instead of an input error (#11647)
* 🐛 Fix email already used showing a toast instead of an input error

Registering with an email that already exists reported the problem with a
toast while the email input stayed in its valid state, which reads as if the
form was accepted. The message now lands on the email input itself, the same
way the recovery and change password forms report server side errors.

The error is written to :extra-errors because the form mutator recomputes
:errors from the schema on every change, so a value written there is dropped
on the next render.

Fixes #10890

* 📎 Update CHANGES.md to remove email registration fix

Removed a note about a fix for email registration error display.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

---------
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-14 10:13:54 +02:00
Eva Marco
c0221a9bf8
🐛 Hide "Create typography style" button for shapes with missing fonts (#11527)
The button let users convert a text shape's inline styles into a
typography asset even when the shape's font-id couldn't be resolved
(e.g. a custom/team font that was removed or isn't loaded), silently
baking a missing font into the new typography asset.

Guard the button on the font actually resolving via
app.main.fonts/fontsdb, in addition to the existing checks (no
typography or token already applied, single selection).

Added e2e coverage for all four conditions that must independently
hide the button: missing font, applied typography asset, multiple
selection with differing values, and applied typography token.

AI-assisted-by: claude-sonnet-5
2026-09-11 13:41:11 +02:00
Elena Torró
32ed9b5a08
🐛 Fix board clip on drag (#11620) 2026-09-11 08:27:11 +02:00
Elena Torró
0913545b41
🐛 Fix letter spacing and position when flattening text to path (#11555)
* 🐛 Fix letter spacing and position when flattening text to path

* ♻️ Simplify text to path conversion and add flatten tests
2026-09-11 08:20:22 +02:00
Elena Torró
9b24907992
🐛 Fix render text-decoration on spans and update regression tests (#11583) 2026-09-10 09:42:09 +02:00
Andrey Antukh
f695553469 Merge remote-tracking branch 'origin/staging' into develop 2026-09-08 21:15:58 +02:00
Elena Torró
af5b767933
🐛 Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) (#11545)
Co-authored-by: Belén Albeza <belen.albeza@kaleidos.net>
2026-09-08 10:29:15 +02:00
Elena Torró
7b135b80b2
🐛 Fix background blur clipping (#11532) 2026-09-07 15:51:27 +02:00
Eva Marco
cd98a88c4d
🐛 Fix errors on shortcuts (#11081)
* 🐛 Fix shift + character recording

* 🐛 Fix importing conflicts

* 🐛 Fix paste as non customizable shortcut

* 🐛 Fix duplicate manage of custom shortcuts

* 🐛 Fix json file name

* 🎉 Add search by commands

* 🐛 Fix CI
2026-09-07 10:14:09 +02:00
Alejandro Alonso
86aebd3429
⚡ Reduce WASM text selrect churn after batch page load (#11471)
* 🐛 Align WASM auto-width text size with HTML measurement

Include the HTML paragraph-set 1px right margin in Skia auto-width
so WASM and DOM measurements match.

* ⚡ Scope and defer post-font WASM text relayout

Track pending font faces per shape, force-relayout only affected
texts, defer stale-selrect sync until the first tiles paint, and
batch request-render after attr updates. Tolerate missing font-face
keys so callers that omit them do not blow up.
2026-09-04 12:24:37 +02:00
Alejandro Alonso
7f46c8c80e Merge remote-tracking branch 'origin/staging' into develop 2026-09-04 11:00:56 +02:00
Eva Marco
214dbd0c8b
🐛 Stop opening Comments section from persisting display-comments (#11492)
AI-assisted-by: claude-sonnet-5
2026-09-04 10:15:38 +02:00
Andrey Antukh
292390f0eb Merge remote-tracking branch 'origin/staging' into develop 2026-08-31 11:52:26 +02:00
María Valderrama
c01306734a
🐛 Fix default team and UI labels to use Personal Projects (#11418)
* 🐛 Fix default team and UI labels to use Personal Projects

* 📎 Code review
2026-08-28 14:04:56 +02:00
Andrey Antukh
0a14a1f3db Merge remote-tracking branch 'origin/staging' into develop 2026-08-28 11:22:52 +02:00
Andrey Antukh
43bbf756b0
🐛 Fix font change regression and activate font preview by default (#11341)
* ✨ 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>
2026-08-27 18:30:33 +02:00
Alonso Torres
217284b1e1
✨ Improve path operations and edition (#10807)
* ✨ Improve path operations and edition

* 🐛 Fix floating-point equality issues in path editing

Replace exact equality checks with tolerance-based comparisons
in path editing functions to handle floating-point rounding
differences after transforms, rotations, or curve fitting.

Changes:
- distribute-content: Round coordinates to 0.1 precision before
  grouping to ensure coincident nodes move together
- separate-node: Use gpt/close? instead of exact equality to
  find nodes with floating-point imprecision
- collision-step: Use mth/close? for tolerance-based comparison
  to detect paste collisions correctly
- resolve-edit-fills: Add cycle detection to prevent infinite
  loops with corrupted parent chains

Made collision-step, available-offset-step, and resolve-edit-fills
public for better testability.

Added comprehensive tests for all fixes covering both exact and
floating-point coordinate scenarios.

AI-assisted-by: qwen3.7-plus

* 🐛 Fix path editor code review findings

Fix issues identified during code review of path editor enhancements:

- Fix unused binding lint warning in distribute-content that blocked CI
- Fix collision-step floor comparison to use round instead of floor,
  correctly detecting collisions when coordinates drift slightly below
  integer boundaries
- Fix resolve-edit-fills to recurse through empty parent groups when
  searching for inherited fills in nested group hierarchies
- Fix expand-coincident-node-indices to use fuzzy comparison (gpt/close?)
  instead of exact equality, handling floating-point divergence after
  transforms or rotations
- Remove unreachable dead code in path-point* on-pointer-down handler
- Add tests for collision-step boundary cases, nested group fill
  inheritance, and coincident node alignment/flipping

AI-assisted-by: mimo-v2.5-pro

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-08-27 10:51:10 +02:00
Elena Torró
1886697458
🐛 Add mock to fix WASM render regression tests (#11268) 2026-08-19 12:49:21 +02:00
Belén Albeza
be83656d55
🎉 Add caret style changes (text editor v3) (#11171)
* 🐛 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
2026-08-13 07:23:15 +02:00
Alejandro Alonso
be9df28b00 Merge remote-tracking branch 'origin/staging' into develop 2026-08-12 07:30:05 +02:00
Eva Marco
53985dc630
🐛 Fix setting dark theme on onboarding (#11212)
* 🐛 Fix setting dark theme on onboarding

* 🎉 Add test
2026-08-11 13:49:48 +02:00
Alejandro Alonso
0de47302a6 Merge remote-tracking branch 'origin/staging' into develop 2026-08-11 12:47:36 +02:00
Luis de Dios
86c563f11f
🐛 Fix font family typography asset persist across files in new created text layers (#11134) 2026-08-10 11:35:51 +02:00
Belén Albeza
2392015c63
🐛 Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) 2026-08-06 12:40:57 +02:00