14407 Commits

Author SHA1 Message Date
elhombretecla
3c1210f603 💄 Fix compare/conflict modal preview 2026-11-03 11:28:24 +01:00
elhombretecla
d7ede341c7 ✨ Say what goes with an added or deleted layer
When a layer goes, Penpot also removes the prototype interactions that
lead to it and the flows that start on it, and resizes the boards with
a layout around it. The diff lists those changes as entries of their
own, with nothing that ties them to their cause.

The detail pane of an added or deleted layer now ends with what goes
(or comes) with it:

- Interactions that led into it and the flows that started on it, as
  warnings, naming the layers and flows they belong to.
- Each board with a flex or grid layout around it, and how it resizes,
  or how many of its other layers move when its size is fixed.
- The names of the layers folded under it.

Added and deleted flow entries carry the flow as `:flow`, as shape
entries carry `:shape`, so the pane can name the board a deleted flow
started on.
2026-10-28 09:50:32 +01:00
elhombretecla
d103805086 🐛 Round the numbers of the compare dialog once a plugin has loaded
Sizes, positions and other numbers in the compare dialog showed every
decimal (162.99999952316284 × 42.99999808520081) as soon as a plugin
had loaded. The plugin runtime's SES lockdown turns
`Number.prototype.toLocaleString` into `toString`, which ignores the
two-decimal limit and the locale.

The numbers now go through `Intl.NumberFormat`, which the lockdown
leaves alone, and fall back to the default locale when none is set.
2026-10-28 09:50:32 +01:00
elhombretecla
876ab29ba9 ✨ Fold the layers deleted with their container into one row
Deleting a group listed the group and each of its layers as rows of
their own, so one deletion read as four changes. Each row could be
left out alone too, which allowed a merge to keep a group while
deleting what it holds.

A layer added or deleted with its container now folds under the
outermost container added or deleted with it. The row says how many
layers it holds and its checkbox takes them along, so they travel or
stay together. The detail pane lists them as its contents.

The filters, the footer, the merge button and the merge confirmation
count the rows the list shows, so they agree with one another.
2026-10-28 08:52:19 +01:00
elhombretecla
9ae262b0ff ✨ Show added and deleted layers in the board that holds them
The compare dialog drew nothing for a deleted layer when the branch was
open, and nothing for an added one when main was: the diff named the
layer by id and label only, and the open file lacks it. The detail pane
was left with the layer's name and nothing else.

Added and deleted shape entries now carry the whole shape, as the
document that holds it has it, under `:shape`. The key names no side,
so it reads the same in both directions.

The previews use it. A layer added or deleted inside a board is shown
in that board on both sides, outlined in green or red on the side that
holds it, so the change reads as what the board gains or loses. The
facts of the detail pane (where it was, type, size) read it as well.

A board that moved or resized now draws at its own size on the side
the open file lacks: the diff leaves out `:selrect` and `:points`, so
they are rebuilt from position and size instead of kept from the open
side.
2026-10-28 08:45:58 +01:00
elhombretecla
e4e237346e ✨ Sum up what a merge applies and leaves out
The compare dialog no longer opens on an overview, so the merge
confirmation now says what the merge does: how many changes go into
main and how many were left out.

When changes are left out and the branch is not kept archived, a
warning replaces the deletion note: deleting the branch loses those
changes. The counts show when the compare dialog of that branch asked
for the merge, as it holds the comparison.
2026-10-27 15:59:40 +01:00
elhombretecla
c68daed816 ✨ Redesign the branch compare and conflict dialogs
The compare and conflict dialogs listed raw entries with their raw
property values. They now read as a review: each change says what it
did in words, where it lives and what it affects, and only what truly
needs a choice asks for one.

Both dialogs move out of the branches sidebar namespace into
branch_review/ (layout, compare, conflicts, previews, facts and the
pure helpers in common) and share one frame: the route between the
branch and main, a bar of category filters, the list beside a detail
pane, and a footer. They size to the window within bounds, so the
panes and previews grow on large screens and stay usable on small ones.

Compare dialog:

- Changes are grouped by page and by top-level board, with decisions
  first and pages and library entries in groups of their own. A board
  folds its changes only when it holds two or more of the listed ones.
- Category filters (Decisions, Copy, Color, Layout, Components,
  Variants, Tokens, Other) are neutral chips with an icon; colour is
  kept for what happened to an entry: added, modified, deleted or in
  conflict.
- Each row carries a one-line summary of the kinds of change ("Fill
  changed", "Moved") and the detail shows the values, before and after.
- Previews show main and the branch side by side or overlaid, and an
  expanded view hands them the whole dialog. The side the open file
  lacks is rebuilt from the changed attributes; a side with nothing to
  draw says why (not in main, deleted, no preview).
- New and deleted pages and layers say what they hold and where they
  land; deleted components, colours, typographies and tokens count the
  layers of the open file that depend on them.
- Changes can be marked as reviewed while moving through the list.

Conflict dialog:

- A conflict compares each property with its base value. Properties
  that one side changed alone come filled in from that side, so only
  the real clashes wait for a choice, property by property.
- Text conflicts show the paragraphs each side keeps and drops, and
  entities that cannot be split offer the two versions to pick from.
- Main, branch and result previews and a note of what the decision
  drops sit beside the property table.

The compare buttons take the new git-compare-arrows icon.
2026-10-27 15:59:33 +01:00
elhombretecla
f56da68b95 ✨ Add change review components and icons to the DS
Add four product components that the branch review dialogs build on:

- tag*: a short status word, as a small code-font label or a rounded
  pill, in neutral or one of six tones, with an optional icon.
- filter-chip*: a rounded toggle that filters a list by category. A
  selected chip draws its outline, text and icon in its tone, or in the
  accent colour when neutral.
- change-row*: one entry of a change list, a short status bar beside a
  card with an icon, a title over a summary, and a slot at each end.
- preview-card*: a framed preview of one side of a comparison (base,
  main, branch or result) over a transparency checkerboard. A container
  can size its stage through two custom properties.

Each one ships with its Storybook story and docs, and is exported from
the DS entry point.

Add three icons: side-by-side and collapse for the comparison views,
and git-compare-arrows for the compare action.
2026-10-27 15:59:14 +01:00
Álvaro Tejero-Cantero
dcedd40a42
✨ Tell merged and archived branches apart (#12572)
* 🐛 Refuse saves to a branch kept after its merge

A branch kept after its merge stays an ordinary file with status
"merged", so every save passed the edition check and appended ops to
an op log that no command integrates any more. Refuse such saves with
a :branch-merged validation error instead of stranding them. An
archived branch still takes saves: restoring it brings them back.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Tell merged and archived branches apart

The workspace treated "merged" and "archived" as one state: an
archived branch read "Merged branch" and lost every action, and a
merged row offered Restore, which the backend always refused.

Each status now has its own presentation. A kept merged branch keeps
"Merged branch" and View in main, and opens read-only: the workspace
permissions drop :can-edit while it is the open file, the way a
viewer gets a read-only workspace. An archived branch says so in the
banner and offers Compare and Restore. The header notes each status,
the switcher lists merged and archived branches in two collapsible
groups, the all-branches dialog gets a Merged tab, and the branch info
dialog offers Restore and Compare for an archived branch.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Refuse saves to an archived branch

An archived branch is now read-only, like a merged one: the user
restores it, or turns it into an ordinary file, to keep editing.
persist-branch-file! refuses its saves with a :branch-archived
validation error, distinct from :branch-merged so the client can say
which way out applies. A restored branch takes saves again, and so
does an archived branch turned into an ordinary file.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Open archived branches read-only with their ways out

An archived branch now opens read-only, like a merged one. Its banner,
its branch info dialog and its row in the all-branches dialog offer
the two ways back to editing, Restore and Turn into an ordinary file,
next to Compare. The header and switcher note reads "Archived, read
only".

A save the server refuses because the branch is merged or archived
now shows a warning that names the way out, instead of the generic
autosave error with a report link.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:54:15 +02:00
Álvaro Tejero-Cantero
d669eec0c0
✨ Load the branch list only when the switcher opens (#12571)
* ✨ Add a branch count query for the workspace header

The header shows its branch row only when the file has branches, and
it learnt that from get-file-branches, which compares every diverged
branch against main. get-file-branch-count answers the same question
with one indexed count: archived and merged branches included, counted
from main when the file is itself a branch, with no comparison and no
climit, like the branches_count subquery of the dashboard listings.

The listing keeps its climit, and :get-file-branches/global now sets
the 60 s timeout that the header of climit.edn promises, so a waiter
behind six cold listings gets an error instead of waiting without
bound. Its rationale comment names the callers it serves now.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ List branches only when the switcher opens

Opening a file ran the counted branch listing for every user, because
the header decided whether to show its branch row from that listing.
A cold listing runs a three-way comparison per diverged branch, and
one save to main makes every branch of the file a cache miss again.

The header now loads the branch count and the branch context with the
file. The listing loads when the branch switcher or the all-branches
dialog opens, and both show "Loading branches…" until it arrives
instead of a list from an earlier opening.

A failed listing used to read as an empty list, which on main hid the
branch row and sent the shortcut to the create dialog. It now ends in
an error state: the switcher and the dialog say "Couldn't load
branches" with a Retry action, and the row stays, since its visibility
comes from the count. A successful listing refreshes the count, so
deleting the last branch from the list still hides the row.

Creating a pull request from the branch banner looked the branch up
in the listing to decide whether to save pending edits first. Without
the listing loaded at open it would skip the save, so it now reads the
branch context of the open file.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ✨ Show the branch row when the branch count fails

A failed get-file-branch-count left no count, so on main the header
hid its branch row and the shortcut opened the create dialog on a file
that may have branches. The failure is now kept as an error state
under :workspace-branch-count, and has-branches? reads it as true.

The row then shows the Main chip and, where the status note goes,
"Couldn't load branches" in the warning colour. Opening the switcher
loads the listing through the existing loading and Retry path, and a
successful listing settles the count, which replaces the note.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 📚 Name the callers of the branch listing limit again

The merge of iop-vc-preview took its climit.edn, whose comment on
:get-file-branches/global still described a panel open. Restore the
comment this branch wrote: the listing runs when the switcher, the
all-branches dialog or the dashboard popover opens, and a file open
only counts branches.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 🐛 Mock the branch count in the library copy tests

The header now shows its branch row from get-file-branch-count, not
from the listing, so the library copy tests mock the count as well.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:50:58 +02:00
Álvaro Tejero-Cantero
d0414481ef
🌐 Translate the compare dialog labels and counts (#12566)
The compare dialog named changed properties, booleans and collection
counts from English literals in the UI namespace, so no locale could
replace them; it also passed bare numbers to tr, so a plural catalogue
entry could never reach its singular form.

Property labels now come from workspace.branches.compare.attr.*, the
true and false values from .value-true and .value-false, and every count
travels through i18n/c, which makes the singular msgstr[0] reachable.
Numbers are rendered with the active locale, so Spanish reads 439,5 and
groups thousands. An attr the catalogue does not name still falls back to
its own key, humanised: a technical identifier rather than prose.

The en.po entries for the counts gain their plural forms. es.po does not
carry the compare area yet, so it stays untouched.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:40:34 +02:00
Álvaro Tejero-Cantero
1c9baa0b06
🔥 Remove the dashboard path of branch creation (#12565)
Branch creation happens only in the workspace since #12359, where the
user sees the file being branched, and the designer settled that in the
review. Nothing opens the create dialog from the dashboard any more, so
the second path of `create-branch` and the dialog's `:file-id` prop
were dead code.

`create-branch` keeps one path: it branches the open file, after
force-persisting it so the merge base holds the latest edits, and lands
in the new branch. The dialog passes only the name and the description,
as it already did.

The `dashboard.create-branch` string was marked unused and nothing
referenced it, so it goes too.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:36:18 +02:00
Álvaro Tejero-Cantero
d4f999e6dd
✨ Tell a library branch that its consumers read main (#12570)
Files that use a shared library read its main, never a branch, so a
branch of a library is not isolated in the sense the create dialog
promised. On a shared library the create dialog now says that files
using the library can adopt changes from main only, the chip of a
library branch says the same, and the note beside main reads
"Default branch (read by library consumers)". Other files keep their
copy.

get-file-branch-info returns source-is-shared so a branch knows its
main is a library.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:04:23 +02:00
Álvaro Tejero-Cantero
d613972474
🐛 Merge file-level plugin data and base font size on branches (#12569)
A branch whose only change was file-level plugin data or the base font
size merged with a success response, and the change was lost. The merge
engine compared colours, typographies, components, media, pages, and
tokens only, so such a branch produced an empty summary and the merge
took the empty-changes shortcut.

The engine now classifies two more kinds three-way: file plugin data,
flattened to namespace and key as page plugin data already is, and the
file options, where an unset base font size reads as the default. Both
kinds count towards ahead, behind, and conflicts, appear in the compare
dialog, and translate into set-plugin-data and set-base-font-size
changes for merge and update from main. Both sides changing the same
key to different values is a modify-modify conflict.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 20:04:02 +02:00
Juan de la Cruz
720b2f193d
✨ Move branching into the workspace header (#12359)
* ✨ Move the branch switcher to the workspace header

* ✨ Add branch DS components and the all-branches dialog

* 💄 Polish the branch switcher and its menu entry

* ✨ Keep the dashboard to switching branches

* 💄 Polish the branch and pull request review UI

* ♻️ Move the branching UI onto the design system

* 🐛 Address the review of the header branch switcher

The bottom banner turned the nil counts that get-file-branch-info sends
for a branch it cannot compare into zeros, so it said "Up to date with
main". It now says "Couldn't compare this branch with main" in the
warning colour, as the header row already did.

During a review the review banner replaces the branch banner, which was
the only component loading the branch context. The header row now loads
it too, so a review link opened fresh shows the branch under review
instead of "Main".

On a branch, "New branch" in the all-branches dialog is disabled with a
tooltip saying branches are created from main: the server refuses to
branch a branch.

With "Try interactions" gone, the create-review note says reviewers
open the live branch and View mode plays it, and the unused
open-pull-request-viewer event goes.

The pull request specs follow the new banners, and three tests cover
the fixes above.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-22 16:50:19 +02:00
Álvaro Tejero Cantero
6ab7b3ebd6
Merge branch 'develop' into iop-vc-preview
Both conflicts are tail appends onto regions develop rewrote. In
migrations.clj keep both appended blocks, develop's two entries
first so the branch's four stay the tail of the vector. In
climit.edn take develop's rewritten entries and re-append the
branch's provisional block, moving the map's closing brace to the
last line.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-10-14 16:34:48 +02:00
Andrey Antukh
4023642acb
🌐 Complete Russian translations to 100% (#11956)
Complete ru.po from 1460 to 2275 keys (100%),
add words.ru.txt catalog, fix two dropped %s
placeholders and normalize board/layout/shortcut
terms. Documents Russian conventions in the
translations memory.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-14 09:58:09 +02:00
Andrey Antukh
0c0336da42 Merge remote-tracking branch 'origin/staging' into develop 2026-10-13 21:04:58 +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
21ecfb67b7
🐛 Fix shortcuts with modifiers on letters and digits (#12292)
Mousetrap matched keydown shortcuts against event.key, the glyph the
key types, but bindings name the key. Modifiers change the glyph: on
any OS with a US layout Shift+0 types ")" and Shift+5 types "%", and
macOS Option changes it further (Option+Shift+5 types "fi", Option+A
types "å"). So shortcuts like Alt+Shift+5 for strikethrough or
Shift+0/1/2 for zoom never fired, on every platform. Read letters and
digits from keyCode on keydown, and record them the same way when a
modifier is held.

Closes #12217

AI-assisted-by: claude-opus-5-5
2026-10-13 15:59:04 +02:00
andrés gonzález
89c6bad8cd
✨ Open files and projects in a new tab with Ctrl/Cmd+click (#12230)
Ctrl+click (Cmd+click on macOS) on a dashboard file card, a project
title, the "Show all files" button, or a project or Drafts item in the
sidebar now opens it in a new browser tab. It reuses the same
`::rt/new-window` navigation as the "Open in new tab" file menu option.

On file cards the modifier click does not change the selection, and a
modifier double click does not navigate the current tab. Files in the
trash are not opened.

Closes #12228

AI-assisted-by: claude-opus-5-5
2026-10-13 12:56:48 +02:00
Luis de Dios
a2c6e87fc7
🐛 Fix missing text style in verification email screen (#12242) 2026-10-13 11:29:41 +02:00
Andrey Antukh
8102fb2d24
🐛 Make the error pages navigable and drop the Closure history (#11948)
* 🐛 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
2026-10-13 09:16:22 +02:00
Alonso Torres
ecc5f11d96
🐛 Fix problem with stalled saves (#12223) 2026-10-11 19:54:40 +02:00
Andrey Antukh
6f33d8d9d8 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 19:16:03 +02:00
Elena Torró
4faac8f746
🐛 Fix text decoration selection on v3 editor (#12255) 2026-10-09 15:48:03 +02:00
Elena Torró
c4068a2256
🐛 Fix additional suffix on new exporter files (#12256) 2026-10-09 15:47:08 +02:00
andrés gonzález
9931d82379
🐛 Show the themes permission message only to non-editors (#12203)
The theme selector showed "You need to be an editor to use themes"
whenever tokens could not be edited. That is also the case for editors
when the tokens source is a library, and they can still switch themes.
Base the message on file edit permission instead.

Closes #12177

AI-assisted-by: claude-opus-5-5
2026-10-09 14:14:51 +02:00
andrés gonzález
f9515a69ca
🐛 Translate the This file tab texts in the libraries modal (#12204)
* 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)

* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

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

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

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

* 🐛 Translate the This file tab texts in the libraries modal

The tab name, the section title and the empty state button of the
This file tab were hardcoded in English. Add translation keys with
English and Spanish texts.

Closes #12178

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

---------

Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-09 14:12:17 +02:00
Alejandro Alonso
92bcc0e635
🐛 Show live text size in Design tab while editing (#12250)
Auto-grow text geometry is finalize-only to avoid typing lag, so the
Design tab kept the committed selrect (0.01 for new texts) until Esc.
Patch display W/H from live WASM layout while editing, matching the
viewport outline, without restoring per-keystroke modifiers.

Closes #12244
2026-10-09 13:56:39 +02:00
Eva Marco
57bbe2112c 🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

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

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

AI-assisted-by: claude-opus-5-5
2026-10-09 13:03:21 +02:00
Miguel de Benito Delgado
5afdeffa5f
♻️ Move frame type constants to webgl.cljs (#11949)
* ♻️ Move some constants out of app.render-wasm.api

- Moved to aapp.render-wasm.wasm where they can be imported
  without pulling half the editor with them
- Renamed one for consistency

* 🔥 Delete unused function, update docstring
2026-10-09 11:22:19 +02:00
María Valderrama
7745c1f052
✨ Make the whole current plan card clickable (#12238) 2026-10-09 10:46:33 +02:00
Andrey Antukh
473a59b134 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 09:01:12 +02:00
Luis de Dios
fad083314d
🎉 Expose per-side stroke width in the plugin API (#11994)
* ✨ Add stroke side-width helpers to common types

Add set-width-to-all-sides, set-width-to-single-side and side-width to
app.common.types.stroke, mirroring the border-radius helpers in
app.common.types.shape.radius. They back the per-side stroke width API
and are covered by unit tests.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Round-trip per-side stroke widths through the plugin API

Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so
reading shape.strokes exposes the per-side widths and writing a stroke
back preserves them instead of collapsing to the uniform width.

parse-stroke prefers a live stroke proxy's hidden raw state when present,
so a commit only rewrites the attributes the plugin touched.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add per-side stroke widths to the plugin API types

Add the four per-side members to the Stroke interface and to the
borderWidth and dimension token property lists, and refresh the generated
API surface used by the plugin test suite.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Expose per-side stroke width on the stroke proxy

Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to
the uniform strokeWidth; strokeWidth now writes all four sides, like
borderRadius writes all four corners; each side setter writes only its
own key and the top side also updates strokeWidth.

Reject per-side writes on shapes other than boards and rectangles with a
validation error naming the property, and expose the raw stroke map
through a hidden proxy member so a commit preserves untouched attributes.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Support stroke width tokens per side in the plugin API

Token attribute expansion now accepts the uniform strokeWidth property and
expands it to the four per-side attributes, mirroring the design tab.
shape.tokens exposes the per-side keys and reports strokeWidth when all
four sides share one token, and applyToken routes through the shared
expander.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Document per-side stroke width in the plugins changelog

Note the new Stroke members, the restored strokeWidth token property and
the shape-type validation in plugins/CHANGELOG.md.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix some minor issues

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-08 18:09:06 +02:00
Eva Marco
9218448b29 🐛 Keep selection handles visible when editing measures
Changing position, size or rotation from the measures menu triggered
the bounding box cloaking, which sets the transform to :move for one
second. While it lasts the selection outline and handles are hidden,
and since the size badge is now shown during :move, only the badge
stayed on screen. Stop cloaking from these inputs so the selection
stays visible while its geometry is edited.

Closes #12162

AI-assisted-by: claude-opus-5-5
2026-10-08 17:58:36 +02:00
Eva Marco
dc6fc46a36 🐛 Refresh X/Y inputs when a flex child toggles absolute position
The measures menu is memoized and only re-renders when one of the
compared values changes. :layout-item-absolute was not among them, so
switching a flex child between static and absolute kept the X/Y inputs
in their previous disabled/enabled state until another compared value
changed or the shape was selected again.

AI-assisted-by: claude-opus-5-5
2026-10-08 17:58:36 +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
Belén Albeza
c2a1d70476
🐛 Fix strikethrough tooltip shortcut (#12221)
The Strikethrough button tooltip showed Alt+Shift+Ctrl+5, but the
shortcut is Alt+Shift+5. The tooltip used meta-shift, which adds
Ctrl (Cmd on Mac). Build it with shift so it matches the command.

Closes #12217

AI-assisted-by: claude-opus-5-5
2026-10-08 15:26:11 +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
c9146a30ab
🐛 Keep layer search and tree scroll across sidebar tab switches (#12117)
Switching sidebar tabs unmounts the layers panel. The search text and
filters lived only in component state, so on return the still open
search bar was treated as a new search and cleared. Keep that state per
file in a session atom, as the assets panel already does.

Closing or clearing the search mounts the tree container again, but the
restore hook only ran on mount or page change, so the tree went back to
the top. Let use-restore-scroll take an active? flag so each list
restores its own position whenever it is shown again, and drop the
saved search position on close so a new search starts at the top.

Closes #11972

AI-assisted-by: claude-opus-5-5
2026-10-08 11:45:35 +02:00
Eva Marco
41efc3558a
🐛 Fix nested libraries vanishing from the libraries modal (#12031)
* 🐛 Fix nested libraries vanishing from the libraries modal

The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:

- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
  the top level, and both were lost.

The libraries showed while the shared files were loading, and vanished
when they arrived.

Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.

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

* ♻️ Move the linked libraries tree to sort-linked-libraries

Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.

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

* ♻️ Use connected-to to find library parents when sorting

The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.

Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.

AI-assisted-by: claude-sonnet-5-5

---------

Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
2026-10-08 09:54:46 +02:00
Belén Albeza
cb1118f904
🎉 Preserve Penpot's internal formatting when pasting (v3) (#12137)
* 🎉 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
2026-10-07 18:20:03 +02:00
Miguel de Benito Delgado
f252439d84
♻️ Factor GL context initialization out of canvas init (#11931)
* ♻️ Factor GL context initialization out of canvas init

- fixes setting of gl-context-handle to be after _init so an exception
  does not leave invalid state behind.
- api.init-canvas-context uses a new webgl.init-context!
- this decouples config from init, so we can reuse it in benchmarks
- some helpers move to the webgl ns

AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3

* 🐛 Release acquired GL context when renderer init fails

- Properly release resources if init fails
- Rethrown exceptions with the original cause
- Fix vacuous check in clear-canvas

AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3

* ✨ Test webgl context init and release

AI-assisted-by: muse-spark, glm-5.3

* 💄 Address review comments

* 💄 Drop `!` from some function names
2026-10-07 18:19:34 +02:00
Alejandro Alonso
30127e57a0
🐛 Skip spurious save when missing font cannot be remapped (#12164)
fix-deleted-fonts committed text and typography changes even when no
replacement font existed, so opening a page with a missing custom font
rewrote identical content and cleared position-data on every visit.

Only emit changes when the remapped content or typography actually
differs. Add focused unit coverage for the no-replacement and remapped
paths.

Closes #12151
2026-10-07 18:03:51 +02:00
Eva Marco
dcf0b197eb
🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

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

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

AI-assisted-by: claude-opus-5-5
2026-10-07 15:45:10 +02:00
alonso.torres
dfff188409 ✨ Ask users to allow global plugins on install
A plugin with "scope": "global" keeps running across the dashboard
and files and can find and open the user's projects and files. Any
manifest could ask for this without the user being told.

Add the allow:global permission. The plugin registry adds it to
every manifest with global scope, so the install dialog lists it,
and a plugin that becomes global in a later version shows the
permissions update dialog again. The permission is valid in the
shared schema, which the backend checks when saving, and in the
runtime manifest schema. The built-in MCP plugin declares it too.

Document the scope property and the new permission for plugin
developers.
2026-10-07 14:58:32 +02:00
alonso.torres
a381761aa4 ✨ Add project and file creation to penpotMgmt
Add penpotMgmt.createProject({name, teamId?}) and
penpotMgmt.createFile({projectId, name}). Both require content:write,
trim the name and require 1 to 250 characters, and return the same
metadata as listProjects and listFiles without changing the current
view. When the new project or file belongs to the current team, the
dashboard state is updated so it shows up right away.

listProjects, listFiles and the creation methods share the summary
builders, so fileCount is now 0 instead of missing for new projects.

Update the plugin types, the MCP API docs and the MCP instructions.
2026-10-07 14:58:32 +02:00
alonso.torres
c4d2ca9cd1 ✨ Move the dashboard MCP menu to the page headers
Show the MCP menu next to the layout toggle in the header of the
projects, files and deleted pages, instead of in the sidebar. It
uses the secondary button style there and a visible dot colour
while disconnected.

Clicking the MCP button now toggles the menu instead of only
opening it. Dashboard events report "dashboard:header" as their
origin.
2026-10-07 14:58:32 +02:00
Dominik Jain
2bb6cd557e ✨ Add project and file discovery to penpotMgmt
Discovery:
- Add listProjects and project-scoped listFiles with content:read checks.
- Return metadata from existing RPCs in dashboard and workspace contexts.
- Reject invalid IDs and preserve backend access errors.

Documentation:
- Keep usage in API type comments and include regenerated MCP API docs.
- Remove README implementation details and consolidate the changelog.
- Add usage information to the MCP server's initial instructions

Validation:
- Pass 8 frontend tests, 81 runtime tests and the runtime build.
- Verify both APIs through live MCP calls in dashboard and workspace.
- Pass type, lint, formatting and diff checks.

AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
2026-10-07 14:58:32 +02:00