14669 Commits

Author SHA1 Message Date
Álvaro Tejero Cantero
c725d78d9a
💄 Rename the holder in two remaining comments
The datascript dependency's comment in frontend/deps.edn still
named the graph-overlay and the retired derived-index worker, and
the Ladybug-mirror set's docstring still named the graph-worker
branch. Both now use the holder names.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
1ccfd315a6
💄 Clear the overlay's lint warning and formatting drift
The cascade's verify gate lints backend, common, and frontend/src/bench,
and clj-kondo exits non-zero on a warning, so the redundant let in
apply-set-token-set held every rebuilt candidate of this branch back.
The two lets become one, with no change in behaviour. An unused pid
binding in graph-overlay-test gets its underscore.

cljfmt fix over the three files ./scripts/ci reported as formatted
incorrectly, so the backend and frontend fmt checks pass.

clj-kondo over the verify scope: 0 errors, 0 warnings. The overlay
suites: 56 tests, 788 assertions, 0 failures.

AI-assisted-by: claude-opus-5-5
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
bf7295b847
🔧 Run the A/B bench at :advanced, now that the corruption is answered
The :simple pin existed because datascript's query layer was broken at
:advanced under this toolchain, which mirror:ds-advanced-corruption has
since answered: shadow-cljs never reads a library's own deps.cljs
externs declaration, so the build must name the file itself.

:ab-bench therefore runs :advanced with :externs
["datascript/externs.js"], and with :infer-externs :all to match the
datahike column, whose build needs it. The default :auto infers externs
for a project's own sources and not for a jar.

bench.core/-main now exits explicitly. Datahike keeps the node event
loop alive after the last reading, and the shared body must stay
identical between the two worktrees.

Entry: pp🪞graph-overlay-experiment
AI-assisted-by: opus-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
b3d4e9e7b7
🐛 Name datascript's externs in the builds that rename properties
datascript.query labels a datom's slots with the string names "e", "a",
"v" and "tx", and reads one back with a bracket access on the datom
object. Closure renames the field and leaves the string, so the read
succeeds and returns whichever field inherited the old name. The index
reads the same slots as real fields, so storage keeps answering
correctly and the corruption stays silent.

Datascript ships datascript/externs.js to pin those five names and
declares it in its deps.cljs. shadow-cljs collects that declaration
into a :deps-externs entry it never populates, and reads it only behind
an unset :cljsjs-externs flag, so the declaration is inert and the
:worker release names the file itself. It costs 170 B on the probe
build's 436 197 B.

bench.debug becomes the gate rather than a reproducer: six readings,
one per datom slot plus a join and an index control, each paired with
the answer it must give, exiting non-zero on a wrong one. The new
ds-probe build compiles it with the worker's release options. Without
the externs entry that build reads nil for every transaction, and a
vanilla shadow project loses the value slot as well, which is why the
gate binds each slot on its own.

The ab-bench pin stays at :simple, and its comment now names the
reason that still holds: one compiler configuration across both
engines, matching the 2026-08-21 V8 table.

Answers mirror:ds-advanced-corruption.

AI-assisted-by: opus-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
3300c809b6
🔧 Add the engine bench probe and the :advanced corruption reproducer
frontend/src/bench carries the datascript half of the same-session V8
A/B (core.cljs body shared verbatim with the datahike worktree) and
debug.cljs, the minimal reproducer for the :advanced query-layer
corruption: storage reads correct, full-scan values replaced by
hash-like integers, bound-value queries empty. The ab-bench shadow
build pins :optimizations :simple because of exactly that corruption.
Owned by mirror:ds-advanced-corruption.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
720712138c
✨ Add datascript dependency for graph-overlay
First step of the graph-overlay port: index identity, type and topology
in datascript over the document, instead of projecting every shape
attribute into a Ladybug database. Backend and frontend both declare it,
because the index is meant to end up in the derived-index worker.

Measured motivation (devenv, JVM datascript 1.8.1): a 20k-shape file
indexes in 1.1s and 5.7MB, against 33-43s and 144MB for the equivalent
Ladybug session.
2026-11-11 17:38:10 +01:00
andrés gonzález
a301c2b43f
✨ Keep board names visibility after reloading the workspace (#12872)
Hiding board names from the View menu reset on every reload, while
rulers and comments kept their state. Add the flag to the layout flags
persisted in local storage so it behaves the same way.

Closes #12871

AI-assisted-by: claude-opus-5-5
2026-11-11 14:52:35 +01:00
Andrey Antukh
671ff4a05f Merge remote-tracking branch 'origin/staging' into develop 2026-11-10 20:06:46 +01:00
Andrey Antukh
e4fcc663c0 Merge remote-tracking branch 'origin/staging-2.19.x' into staging 2026-11-10 18:09:07 +01:00
Andrey Antukh
1db1487c7f
🐛 Require thread access when marking threads as read (#12894)
* 🐛 Require thread access when marking threads as read

mark-all-threads-as-read took any list of thread ids and wrote the read
status of the caller on each of them, without checking that the caller
can access the file that owns the thread. It was the only comment
mutation that did not check permissions, and it took the ids as an
unbounded array, so a single request could keep a database connection
and a transaction open for a long time.

The rpc now resolves the threads in one query and checks the comment
permissions of every distinct file, answering not-found when a thread
does not exist or is not accessible. Both cases answer the same error
on purpose, so the response cannot be used to tell existing threads
apart.

The status rows are now written with a single insert, keeping the
modified-at date one second in the future that makes the thread count
as read. Repeated ids are deduped, the command returns nil again like
the sibling commands with no result, and the schema limits the number
of threads per call to 500 while the dashboard sends the ids in batches
of that size.

Tests cover access rejection, unknown threads, mixed batches that write
nothing, repeated ids, empty lists, marking twice, and the frontend
batch split.

Relates to GHSA-j4vc-cmf6-jhr7

AI-assisted-by: minimax-m3.1-flash

* 🐛 Refresh the unread list when a batch fails

The dashboard sends the thread ids in batches of 500 and each batch
commits on the server. A failure in a later batch left the unread list
stale: the re-read only ran when every batch succeeded, so the threads
already marked as read stayed visible as unread.

The re-read now runs in both paths. On failure the event is emitted
before the error is propagated, so the error toast still shows and the
success toast stays limited to a full success.

The backend also answers an empty list without opening a transaction,
since there is nothing to check and nothing to write.

Relates to GHSA-j4vc-cmf6-jhr7

AI-assisted-by: deepseek-v4.1-flash
2026-11-10 15:54:11 +01:00
Andrey Antukh
8510daef52
🐛 Restrict the team invitation token and list to admins (#12892)
* 🐛 Restrict the team invitation token and list to admins

The token of a team invitation is a bearer credential: whoever holds it
accepts the invitation with the role of the invitation row. The RPC that
mints it only required edition permissions, while the rest of the
invitation commands require admin, so a team editor could get the token of
any pending invitation, including the ones that grant the owner role.

The token now requires admin, and the token of an owner invitation is
restricted to the team owner: an admin cannot create nor promote an owner
invitation, so it cannot keep its token either. It reuses the error code
already used for that rule.

Minting the token now leaves an audit event named as an action, carrying
the granted role and the id of the invitation, so the extraction does not
go unnoticed.

The list of pending invitations is admin only as well: it carries the
email of the invited person and the role offered, and the dashboard was
painting those emails to any member. The frontend catches the permission
error and shows the permission notice instead, and the copy action is not
offered for an owner invitation, which the backend rejects for an admin.

The five invitation commands now answer with the same permission error,
so they speak one language.

Relates to GHSA-4mmc-j8rh-74pf

AI-assisted-by: minimax-m3.1-flash

* 🐛 Clear the cached invitations and gate the list on team admin

The pending invitations are a team admin only list. The dashboard still
offered the entry to every member and keyed the section on the invite
policy of the organization, so a team admin blocked by that policy saw
the no-permission notice although the backend lets them list.

- `fetch-invitations` answers a denied refetch with an
  `invitations-fetched` event carrying an empty list, so a demoted admin
  does not keep the cached emails and the header no longer paints the
  invite button from a stale list.
- The header tabs and the team options dropdown hide the invitations
  entry without `:is-admin`, which already includes the team owner.
- `invitations-section-content` keys the section on `:is-admin`, and the
  invite policy only gates the invite actions: resending keeps the
  `can-invite?` check, deleting and row selection only need team admin.

AI-assisted-by: deepseek-v4.1-flash
2026-11-10 15:53:43 +01:00
Miguel de Benito Delgado
cbd4c9634c
🔧 Use correct :cache-root in shadow-cljs.edn (#12883) 2026-11-10 13:47:50 +01:00
Filip Sajdak
ee3e2a0e61
🔧 Keep the CI expect timeout in the default and ds e2e projects (#12922)
The Playwright config gives the retrying expects 40 seconds on CI and 5
seconds elsewhere, but the default and ds projects set their own expect
block for the screenshot tolerance, and a project block replaces the
top-level one instead of merging with it. On CI those projects have run
with Playwright's 5 second default.

That is too short for the first dashboard load on a busy runner, where the
dashboard module is fetched lazily: specs that wait for the dashboard
heading time out and pass on the retry (flaky), or fail after the
retries. In the shard of one recent run that was 7 flaky and 4 failed
specs out of 112, and develop itself failed the same families of specs on
several runs the same day.

Name the timeout once and repeat it in both projects. A probe config that
imports this one and polls a condition that never holds reports 40000 ms
for both projects with this change, and 5000 ms without it. The
render-wasm project already repeats its own value.

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

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
2026-11-10 11:33:42 +01:00
Filip Sajdak
90d98b1989
🐛 Call the team hook on every render of the team container (#12920)
team-container* called mf/deref only after returning early for a team id
that is not a uuid. When the same component rendered once without a team
id and then with one, the number of hooks changed between the two renders
and React threw "Rendered more hooks than during the previous render"
(error #310), which sent the user to the internal error page. The reverse
order throws "Rendered fewer hooks than expected".

Call the hook on every render and keep the uuid check in the condition
that builds the providers.

Two tests mount the component in jsdom and render it with a team id that
arrives and with one that leaves; both fail without this change.

Closes #12728

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

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
2026-11-10 11:32:58 +01:00
Alonso Torres
4fb0ea9eee
🐛 Give unique values to same-name components combined as variants (#12881)
When components whose name matches the variant container are moved into
it, removing the container name leaves an empty value. Combining several
components named the same produced "Value 1" for the first variant and
empty values for the rest, so variants shared the same combination.

Assign the first unused "Value N" to the last property when all computed
values are blank, tracking values given within the same batch.

Combine moves the remaining mains into the container one at a time, in
the combine order, so the generated values follow that order instead of
the layer order.

Closes #12878
2026-11-10 09:16:08 +01:00
Eva Marco
72575e5c05
🐛 Make Add to an organization a keyboard button (#12722)
"Add to an organization" in the team settings opens a modal but was an
<a> without href: the keyboard could not reach it. It is now a
<button type="button"> that keeps its text-link look (accent color,
medium weight, underline on hover, the same box), so Tab reaches it and
Enter or Space opens the modal.

New visual test "Shows the Add to an organization link", with fixtures
for a team in no organization and an organization the user belongs to:
0 px of difference at rest and on hover, and 0 px on the whole settings
page.

AI-assisted-by: claude-opus-5-5
2026-11-10 08:33:04 +01:00
Luke Lau
cd1ece4beb
⚡ Keep the translation flag on undo and redo of a move (#12870)
* ⚡ Keep the translation flag on undo and redo of a move

Previously undos and redos always forced a component sync + thumbnail
rerender for every component touched even if the underlying commits were just
translations. This propagates the translation flag to undos and redos and
propagates it to commit-changes. An entry merged from several undos/redos is
a translation only if all of its undos/redos are translations.

This reduces a UI freeze when undoing a move of a board with 453 components
from 2.42s -> 0.22s.

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

* :translation? -> :translation

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-11-09 20:47:42 +01:00
Luke Lau
6846afd08c
⚡ Send objects-map pages to the worker without a second encode (#12811)
* ⚡ Send objects-map pages to the worker without a second encode

When switching between pages on a large file e.g Material Design Icons, there's
some UI freeze from :index/initialize encoding object map shapes to send to the
worker. An objects map already stores the encoded JSON for each shape, and
`encode-str` was wrapping those strings in a second layer of JSON on the main
thread.

We can just transmit the already encoded JSON in a js/Map alongisde the
payload, similar to :blob, so structured clone just copies the strings verbaitm
without re-encoding. To do this this PR adds two functions to ObjectsMap that
converts to and from a js/Map.

The "Main components" page of the Material Design Icons has ~30k objects, so
this reduces the freeze when switching pages from 306ms -> 189ms (1.63x) on a
release build.

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

* Split property tests out into new deftest

---------

Signed-off-by: Luke Lau <luke_lau@icloud.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-11-09 17:35:20 +01:00
Luke Lau
943e63a99e
⚡ Group color updates in selections (#12875) 2026-11-09 12:08:36 +01:00
Hosted Weblate
9034e707bf
🌐 Merge branch 'origin/develop' into Weblate. 2026-11-09 11:09:57 +01:00
Alexis Morin
968edc6a55
🌐 Add translations for: French (Canada)
Currently translated at 96.7% (2285 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-09 11:07:01 +01:00
AntonPalmqvist
a2dfcb8669
🌐 Add translations for: Swedish
Currently translated at 95.9% (2266 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2026-11-09 11:06:59 +01:00
Andrey Antukh
1df2941576 Merge remote-tracking branch 'origin/staging' into develop 2026-11-09 10:42:28 +01:00
Andrey Antukh
0ea6d166f0
✨ Add a session origin model to auth events (#12856)
* 🐛 Return the full profile from the LDAP invitation branch

The invitation branch responded with only {:invitation-token token},
so the session start received a map without id or auth-backend. With
the new auth-origin vocabulary that map fails validation and the login
breaks in development and tests; in production the event would be
emitted without a channel.

Return the profile plus the token, like the regular branch, and cover
both branches with RPC-level tests.

Closes #12849

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

* 🐛 Mint an auth token in the OIDC callback with the invitation inside

The callback used the invitation JWE as the session token, so the
frontend landed directly on token verification and never ran the
regular session start: no signin event, no teams, no host hook.

Always mint an :auth token and carry the invitation in its claims.
Token verification returns the claims verbatim, so login-from-token
attaches the invitation to the profile and logged-in does the rest,
the same pattern the verify-email token already uses.

Closes #12849

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

* ✨ Keep the session origin in the telemetry shadow rows

The anonymisation filter drops every string unless it is on a short
allowlist. Add source and activation next to lang and auth-backend so
the shadow copy keeps them; the email and the full name are still
dropped and the email domain is still derived from the email.

The filter keeps its two bindings: the transducer discards everything
that is not a UUID, a boolean or a number, so the allowlist is applied
on the filtered map, never on the raw props.

Closes #12849

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

* ✨ Describe the signup outcome with activation, not with action

The three success branches of register-profile labelled a new account
as "login", "email-verification" or "accept-invitation", so the event
contradicted its own name and the value only lived in context, where
the analytics consumers never read it.

Add activation to the event props and drop action from those branches:
the name already says it is a signup, and a constant action would only
add noise. The retry branches keep their action in context because
there it distinguishes four different outcomes.

Closes #12849

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

* ✨ Describe the session origin in the signin event

The event carried the profile but never said why the session started:
the only field that tried was frontend metadata that never leaves the
browser, and several flows filled it with the wrong value.

Add a small closed vocabulary (source, auth-backend, activation,
from-invitation) and compose it from the profile when signin is
emitted. The assertion fails in development and tests and is elided in
the release build, so a bad value cannot break a real login.

Closes #12849

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

* ✨ Emit the session origin from every login entry point

The four emitters stamped a free-form string as metadata, so the
session event could not rely on it: two flows that were signups wrote
the same value as a login.

Route them through origin-meta: password and LDAP logins declare
source login, register declares source register with activation
immediate, and login-from-token derives both from the token issuer:
:auth for a login and :verify-email for a signup that needed the
email confirmation.

Closes #12849

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

* 🐛 Send the invitation signups through the regular session start

Registering from a team invitation link navigated by hand to token
verification, so the session start never ran: no signin event, no
teams, no host hook. The same navigation was copied in four places.

Teach login-from-register to carry an optional invitation token,
extract the profile decoration into with-register-origin, and call it
from the four sites instead of the hand-written navigation.

Closes #12849

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

* 🐛 Resolve the invitation without waiting for the teams

Registering from an invitation link now goes through the session
start, and that start only navigates after the teams request
completes. fetch-teams has no error handling, so a network failure
there would leave the invitation unaccepted and the user on the signup
screen with a session already minted.

Move the invitation navigation out of the teams path: it emits as soon
as the profile carries a token, and the teams path emits nothing for
an invited profile so it cannot move the user away from the token
verification screen.

Closes #12849

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

* 📚 Document the session origin vocabulary and the invitation flow

Add a focused memory with the four fields, where each value comes from,
the table of flows, the LDAP exception and the invitation path through
logged-in, and link it from the frontend routing.

Update the audit-log memory: source and activation are now on the
telemetry allowlist, activation lives in props and action only exists
on the retry rows.

Closes #12849

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

* 📎 Cover the invitation session start and fix the auth memories

The invitation flows and the token decoration in login-from-token were
only covered by helper tests, so removing the guard that keeps the
teams path from navigating, or the token attach, broke the behaviour
silently.

Run the events through ptk/watch with a teams-fetched event as input:
an invited profile navigates to the token verification screen and
never to the dashboard, and the signin keeps from-invitation. Add the
login-from-token cases with and without the token, and fix the memory
statements about action, the flow table and from-invitation.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash
2026-11-09 10:11:54 +01:00
Alexis Morin
5b9bf3cbba
🌐 Add translations for: French (Canada)
Currently translated at 96.5% (2279 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-08 10:51:26 +01:00
Danial Shirali
44a0cc7147
🌐 Add translations for: Persian
Currently translated at 94.5% (2233 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2026-11-08 10:51:22 +01:00
penpot-renovate[bot]
75b3da5290
⬆️ Update dependency pnpm to v12.18.0 (#12876)
- pnpm 12.18.0 (docker/images/Dockerfile.media-processor)
- pnpm 12.18.0 (docker/images/Dockerfile.mcp)
- pnpm 12.18.0 (docker/images/Dockerfile.exporter)
- pnpm 12.18.0 (docker/devenv/Dockerfile)
- pnpm 12.18.0 (render-wasm/package.json)
- pnpm 12.18.0 (plugins/package.json)
- pnpm 12.18.0 (plugins/libs/plugins-styles/package.json)
- pnpm 12.18.0 (plugins/libs/plugins-runtime/package.json)
- pnpm 12.18.0 (plugins/libs/plugin-types/package.json)
- pnpm 12.18.0 (plugins/apps/table-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/rename-layers-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/poc-tokens-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/poc-state-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/plugin-api-test-suite/package.json)
- pnpm 12.18.0 (plugins/apps/lorem-ipsum-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/icons-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/example-styles/package.json)
- pnpm 12.18.0 (plugins/apps/e2e/package.json)
- pnpm 12.18.0 (plugins/apps/create-palette-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/contrast-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/composable-test-suite/package.json)
- pnpm 12.18.0 (plugins/apps/colors-to-tokens-plugin/package.json)
- pnpm 12.18.0 (package.json)
- pnpm 12.18.0 (media-processor/package.json)
- pnpm 12.18.0 (mcp/packages/server/package.json)
- pnpm 12.18.0 (mcp/packages/plugin/package.json)
- pnpm 12.18.0 (mcp/packages/common/package.json)
- pnpm 12.18.0 (mcp/packages/browser-extension/package.json)
- pnpm 12.18.0 (mcp/package.json)
- pnpm 12.18.0 (library/package.json)
- pnpm 12.18.0 (frontend/text-editor/package.json)
- pnpm 12.18.0 (frontend/packages/ui/package.json)
- pnpm 12.18.0 (frontend/packages/mousetrap/package.json)
- pnpm 12.18.0 (frontend/packages/draft-js/package.json)
- pnpm 12.18.0 (frontend/package.json)
- pnpm 12.18.0 (exporter/package.json)
- pnpm 12.18.0 (docs/package.json)
- pnpm 12.18.0 (common/package.json)
- pnpm 12.18.0 (backend/package.json)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-07 22:52:43 +01:00
Alexis Morin
dac470cc94
🌐 Add translations for: French (Canada)
Currently translated at 94.4% (2231 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-07 08:51:35 +01:00
Stephan Paternotte
da8cdb734b
🌐 Add translations for: Dutch
Currently translated at 100.0% (2361 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2026-11-07 08:51:32 +01:00
Elena Torró
7ecf48c52c
⚡ Speed up uploading page shapes to wasm (#12854)
* ⚡ Compute text fonts once per shape when uploading to wasm

* ⚡ Write the shapes batch in place and coerce fills once

* 🐛 Free the upload buffer when a batch record write fails
2026-11-06 15:45:34 +01:00
Álvaro Tejero-Cantero
87fc15b5aa
🐛 Open the first page in view mode when the URL has no page id (#12687)
A view URL without a page-id crashed builds with assertions enabled:
the viewer passes a nil page-id to `initialize`, whose schema accepted
a missing key but not a nil value. Release builds skip that check and
draw the first page, but every viewer event reads the page from the
route, so zoom to fit and fill, the next-board arrow, Navigate To
interactions, and comment pins did not work.

`initialize` now accepts a nil page-id, `fetch-bundle` makes it
optional, and `bundle-fetched` replaces a route without page-id by one
naming the file's first page. The viewer then initializes again on the
completed route, as it does when the user changes page. A file with no
pages keeps its route.

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

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-06 13:05:39 +01:00
Andrey Antukh
a77cb2608e
🐛 Normalize stored SVG attribute keys to kebab-case (#12006)
* 🐛 Normalize stored SVG attribute keys to kebab-case

The v3 reader already emits kebab-case keys, but birth stored the
camelCase React prop names, so import and export never converged:
fill rules painted wrongly and filter defs lost their blur.

Birth now normalizes every stored attribute key to kebab-case
through the `svg-props` whitelist (`attrs->kebab-props`), applied on
all element paths of the svg importer, on the file builder and on
defs. svg-raw `:content` is kebab-ized in spelling only and keeps
unknown keys, because the sidebar reads them. Non-spec attrs Penpot
must keep live in `penpot-extra-attrs`, seeded with `:stroke-style`.
Render boundaries translate back to camelCase for React and for the
wasm props, and the shape schema declares the closed kebab contract
derived from that same whitelist, so birth and validation cannot
drift.

Migration `0035-fix-shape-svg-attrs-and-defs` converts old files
once with the birth transforms, and carries back the svg-viewbox
rect normalization the withdrawn `0021` had. That old id leaves
`available-migrations`, so files recording it migrate again through
this one, and the binfile cleaner drops its own svg repair.

Review fixes included here: `setup-opacity` and the blend mode read
the stored keys, gradient stops accept `:style` as a CSS string or a
map, `inherit-attributes` reads `:style` so a child's inline style
beats the inherited group attr per CSS, and the svg-attrs menu shows
spec spelling (`viewBox`, `class`) through `stored-attr-display-name`.

Tests cover the migration, the closed svg-attr schemas and the
import/export round trip; the svg-content schema test pins its
sample seed, which dealt no svg-raw shape on unlucky runs.

Closes #11947

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

* 🐛 Keep legacy svg node tags and transforms valid in migration 0035

The closed svg node schemas require keyword tags and map attrs, but
stored files hold def and svg-raw content tags as strings, and some
node `:attrs` as nil. Migration 0035 left them as they were, so the
file data check that runs after migrating rejected those files and
they could not be opened. `normalize-node-tree` now turns string tags
into keywords and nil attrs into an empty map, at birth and in 0035.

With keyword tags, the svg defs renderer applies the shape transform
to user-space defs. Legacy `:svg-transform` values are plain maps,
which multiply into NaN matrices. `gmt/coerce` turns them into
matrices, in 0035 and in the binfile cleaner, because every v3 import
decodes the transform as a plain map again. Legacy masks and gradients
now render as designed in the svg renderer.

The binfile test helper calls `export-files!` with the current config
keys.

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

---------
2026-11-06 12:52:53 +01:00
Andrés Moya
4f02995a3c
🐛 Re-enable adding layout to a variant container (#12879) 2026-11-06 12:11:48 +01:00
María Valderrama
2df4d10bd5
✨ Enforce attribute-based organization permissions (#12868)
* ✨ Enforce attribute-based organization permissions

* 📎 Code review
2026-11-06 11:40:50 +01:00
Elena Torró
7fa225115e
⚡ Reuse uuid instances when decoding transit on the frontend (#12792) 2026-11-06 11:33:37 +01:00
alonso.torres
1a80e98e62 🐛 Keep Inspect from crashing on a shape whose parent is missing
In a damaged file a shape may point to a parent that does not exist.
The Inspect tab computes the position relative to the parent, so
inspecting such a shape replaced the workspace with the internal error
page. Inspect leaves the position out and logs a warning instead.
2026-11-05 23:59:32 +01:00
alonso.torres
7eacbec245 🐛 Keep comments, focus, thumbnails and text in step with a rollback
A rollback reverts the local data with a rebase, which is not a commit,
so the parts of the workspace that follow commits missed it:

- Comment pins moved in the reverted action stayed where they were, on
  screen and on the server, and pins on a board the rollback removed
  kept pointing to it. They go back to the reverted position, and pins
  on a removed board move to the page root.
- Focus mode stayed on with no shape left when the rollback removed the
  focused ones, and the next shape drawn turned it on again. It ends
  when its last shape goes, and the selection drops removed shapes.
- Component thumbnails kept the rejected look. The thumbnails of the
  reverted mains are rendered and uploaded again, and an upload whose
  shapes changed during the render is skipped.
- Under WASM, an open text editor reset to the reverted content but the
  canvas kept the old layout. It is laid out again.
- A library sync whose save failed showed no update notice again once
  the release undid it. The notice is checked again after that
  rollback.
2026-11-05 23:59:32 +01:00
alonso.torres
88416d2542 ✨ Label the copy kept before a repair in the History panel
Before it repairs a damaged file, the backend keeps a copy of it as a
system snapshot, and the repair notice points to the version history to
find it. The History panel listed that copy among the autosaves, so it
could not be told apart.

The History panel lists the copies kept before a repair as their own
entry per day, "Before automatic repair, <date>", above that day's
autosaves, and the preview of such a copy uses the same label.
2026-11-05 23:59:32 +01:00
alonso.torres
61bf66342b ✨ Tell repairs, damaged data and other files apart in save notices
The notice of a rejected save always said the same thing. It did not
name the file when the user had moved to another one, gave no hint when
the page held data that was already invalid, and replaced the notice of
a backend repair still on screen, since the store holds one toast.

The backend flags a rejection whose first commit touches pages or
components that failed validation before it (:preexisting-damage). The
rejection notice names a file other than the open one, says when the
data was damaged before, and folds into a repair notice still on
screen; a later repair notice folds a rejection notice in the same way.
The notice says that the latest changes were undone, no longer that the
rest of the work is saved.
2026-11-05 23:59:32 +01:00
alonso.torres
27c1c38360 🐛 Apply the changes stored before a save before acknowledging it
The answer to a save lists the changes other sessions stored before it,
such as the backend's repair of a damaged file. When the answer came
before the message with such a change, the saved commit was
acknowledged first, and the late change was then applied on top of the
local edits instead of under them, out of the server's order.

A saved commit of the open file now stays pending until this tab has
received every change the answer lists from other sessions, so a late
change is applied under the local edits. If one does not arrive within
10 seconds, the file reloads.
2026-11-05 23:59:32 +01:00
alonso.torres
302f83f072 🐛 Drop the unsaved edits made on a version a peer restored
When a peer restores a version, the file reloads to it. Edits made on
the replaced version stayed queued: their save failed for good and
locked the workspace, and a rollback or a rebase replayed them on the
restored data.

Loading another version forgets the queued, buffered and saved commits
made on the old one. A save of such a commit, in flight or answered, is
dropped instead of failing or being rolled back, and one save carries
the edits of one version only.
2026-11-05 23:59:32 +01:00
alonso.torres
1d862c0dd5 🐛 Refuse every edit while saves fail
While saves fail the workspace turns read-only, but only some tools
checked it. Edits from the asset, color and token panels, and a drag or
a drawing in progress when the lock came, still made commits that
queued behind the failed save.

commit-changes refuses commits while the save-failure lock holds,
except those that are not user edits: the undo of rolled-back commits,
text position data and component touches. Undo and redo do nothing
while locked. When the lock comes, moves, resizes, rotations, drawings
and the drags of flex, guide and grid handles in progress are cancelled
and commit nothing, and the design panel shows the Inspect view.
2026-11-05 23:59:32 +01:00
alonso.torres
e10ceefb5d 🐛 Keep a file reload from doubling the workspace streams
Reloading the open file (a peer's restore, the fallback of a rollback)
runs initialize-workspace again without leaving the workspace. The
streams the first run started kept running next to the new ones, so a
commit added one undo entry per reload and each notification was
handled twice. The old subscription also sent its unsubscribe after the
new subscribe, which ended the new one on the server.

A new initialize-workspace, text reflow, shape layout or notification
subscription stops the one before it, and a subscription replaced by
one to the same file sends no unsubscribe.
2026-11-05 23:59:32 +01:00
alonso.torres
cc4788aabf 🐛 Keep reporting rollbacks the MCP agent has not heard about
The MCP plugin sends the rollbacks it saw with its next task response
and then forgets them. When that response came after the server had
given up on the task, the server dropped it and the agent never heard
about the rollback. A reload of the tab or of the plugin lost them too.

The server now sends the rollbacks of such a late response back to the
plugin, which reports them again with its next response. The plugin
keeps the rollbacks not reported yet in its local storage, one entry
per file, so a reload does not lose them. Both MCP plugin manifests ask
for the allow:localstorage permission for that.
2026-11-05 23:59:32 +01:00
alonso.torres
a6255880da 🐛 Let plugins write in Inspect mode and stop them as viewers
Plugin writes failed whenever the workspace read-only flag was set. The
Inspect tab sets it too, although it only hides the editing tools, so an
MCP agent could not edit while the user was inspecting. A user who
became a viewer had the flag cleared, so plugin writes did not throw and
silently did nothing.

Writes now fail on what makes them unsafe, not on the flag: failing
saves, a user who cannot edit, a version preview or a lost render
context. The error says which one, and the MCP instructions tell the
agent which of them pass by waiting.
2026-11-05 23:59:32 +01:00
alonso.torres
7538d2c187 ✨ Tell plugins and the MCP agent when changes are rolled back
The backend rejects a save after the call that made the changes has
returned, and the workspace then undoes them. A plugin, or the MCP
agent, went on working with shapes that were no longer there and never
learned why.

The rollback event now names the files that lost commits and the pages
and page shapes their changes touched, and plugins get it as the new
`contentrollback` event (`ContentRollback` type) through a subscription
to the store stream, so no rollback is dropped by debouncing.

The MCP plugin keeps the rollbacks it is told about and sends them with
its next task response, failed or not. The server collects them per tool
call and puts a warning first in that tool's response, naming what was
affected; the instructions tell the agent to read the pages again and
redo what is missing.
2026-11-05 23:59:32 +01:00
alonso.torres
9368c83dca ✨ Make plugin writes fail while the workspace is read-only
A read-only workspace (saves failing, a user who cannot edit, a version
preview) only hid the editing tools: plugins, the MCP among them, still
changed shapes, and those edits were never saved. Every plugin write now
throws a read-only error, whatever the throwValidationErrors flag, the
same way a write to a removed shape does.

`check-permission` with a write permission runs the new `check-writable`,
which covers most writers. Writers without a permission check (most
context methods such as create*, group, align or flatten, the z-order
methods, applyTypography on a text range, removing a ruler guide) call it
themselves, and shape setters get it through `check-shape-writable`.
Reads, comments and undo blocks keep working, so a block opened before a
lock still closes.

The MCP instructions tell the agent what the read-only error means.
2026-11-05 23:59:32 +01:00
alonso.torres
c8ef46a0a4 💄 Put the team organization update handler on its own line
`ptk/UpdateEvent` of `handle-change-team-organization` sat on the same
line as the end of its watch body.
2026-11-05 23:59:32 +01:00
alonso.torres
8ef3ba628d 🐛 Keep the workspace open when closing the v2 text editor
Closing the v2 text editor with Escape could crash the workspace. The
text options panel renders once more with the editor it saw last, after
closing has disposed it, and reading the style of a disposed editor
throws.

The panel now takes the values from the shape when the editor is gone.
2026-11-05 23:59:32 +01:00
alonso.torres
6426b17c9c 🐛 Fix small save bugs found by the rollback test plan
- Ending a drag inside the frame the shapes already belong to sent an
  empty reparent save and bumped the revision. The move commits only
  when some shape changes parent.
- When a user restored a version, other sessions reloaded the file with
  no notice. They now see a dialog naming who restored which version
  (by its name, else its date), saying that later changes are no longer
  in the file and, when they had changes not saved yet, that those were
  lost.
- Exports drawn in the browser (PDF, SVG, and PNG when the WASM renderer
  draws it) and duplicating a file from the dashboard started at once,
  so an export could show changes a pending save then rejected, and a
  copy could miss queued edits. Both wait for the file's pending saves.
  A client export warns that it includes unsaved changes; a copy warns
  that it misses them.
2026-11-05 23:59:32 +01:00