24466 Commits

Author SHA1 Message Date
Andrey Antukh
b66dde5a36 📚 Update changelog 2.18.4-RC1 2026-10-13 14:44:53 +02:00
Andrey Antukh
fb2a0ac7fc 📚 Record the rules for production data
Repair scripts run against production with a plain SQL client, not the
devenv psql: no `\set`, `\if` or `\copy` there. The SQL datasource the
agent can query is a read-only replica that rejects writes (a temporary
table included) and cuts long queries, so its queries are plain SELECTs
bounded by a list of ids: an unbounded scan over tens of millions of rows
does not come back.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
5c476b4d9c ✨ Make the CLIMIT queue visible with an accumulated gauge
The queue gauge was written by whichever limiter of a name ran last, so a
per-profile pile-up never showed: a healthy profile writing 0 overwrote the
15 of the profile that was queueing (4 of 1441 samples in production), and
a limiter whose requests never come back read 0 while 72 threads stayed
parked.

A queue is an entry and an exit, so the new gauge is accumulated: one
counter per limiter name and instance, up when a call starts waiting for a
permit, down when it stops, so a call that never leaves stays visible. It
is always written with `:val`, and a daemon sampler republishes every count
every 15s so a name whose traffic died does not go stale in the scrape;
halting the component stops that thread.

Rejections had no metric at all: penpot_rpc_climit_rejected_total counts
them by name and reason (queue-full or timeout), and the log lines now
carry queue-max, permits, code and reason, so an operator can tell whether
the limiter did its job or was saturated.

The gauges that could not tell the truth are gone: queue and permits (same
last-write-wins problem, and waiting plus the wait histogram replace them)
and http_worker_busy_threads (reads 0 on an instance with 72 blocked
threads; pool_size is the signal). No other dashboard or alert rule reads
them.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
dd6cc835ef 🎉 Add graceful instance drain via /readyz
Add a per-process availability flag (app.health) that /readyz
reports as 503 OFFLINE while offline, so the load balancer
drains the instance and in-flight requests finish untouched.

Operator surface is app.srepl.main/set-offline and
app.srepl.main/set-online; a restart comes back online.
Covered by backend-tests.health-test; HA docs updated.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-13 14:33:32 +02:00
Andrey Antukh
c3c65318b4 🔥 Drop the legacy file_object_thumbnail table
`file_object_thumbnail` was replaced by `file_tagged_object_thumbnail`
and nothing refers to it anymore: no query in the backend touches it,
no table has a foreign key pointing at it, no view depends on it, and
it is empty in production. It also lacked `deleted_at`, which is why
restoring files had to leave it out. Only legacy naming survives in two
helpers (`get-file-object-thumbnails` and
`delete-file-object-thumbnails!`, which operate on
`file_tagged_object_thumbnail`).

The storage bucket named "file-object-thumbnail", the one that
`file_tagged_object_thumbnail` uses, is a different thing and stays.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
575b68d2c9 🐛 Unmark the storage objects that a restore brings back
Restoring a file only un-marked the database rows (`file`,
`file_media_object`, `file_change`, `file_data`, `file_thumbnail`,
`file_tagged_object_thumbnail`), not the storage objects those rows
reference. An object that was already marked for deletion stayed
marked while a live row pointed at it, and those foreign keys are `NO
ACTION`: the storage GC cannot delete it and skips it forever (and
before the GC learned to skip, it cost the whole nightly run).
Restoring a team had the same gap with its font variants.

Both restores now unmark the storage objects of the rows they bring
back: the media and the thumbnail of the file media objects, the media
of the file and object thumbnails, the `storage-ref-id` of the file
data, and the four files of a font variant. Objects referenced by other
rows are left alone.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
0947e666c0 🐛 Keep objects-gc running past a blocked chunk
objects-gc deleted every kind of object inside one transaction per proc,
looping until empty, so a single row that a foreign key still references
aborted the whole run: the task died on its first file every night, after
the earlier steps had already spent around twenty minutes. The per-file
savepoint only saved the files proc and left the same shape everywhere
else.

Now every proc streams its pending keys with db/plan inside a read-only
transaction of its own and deletes them chunk by chunk, each chunk in a
transaction of its own with FOR UPDATE SKIP LOCKED. A chunk that fails is
reported with l/err and skipped, so the run reaches the end and the next
run retries the skipped rows.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-13 14:33:32 +02:00
Andrey Antukh
115b9b9006 🐛 Keep the storage GC running past a blocked chunk
`storage-gc-deleted` ran its whole batch inside one transaction: the
handler opened it and `delete-in-bulk` reused that connection, so the
"separate transaction" its comment promised did not exist. A single
row that a foreign key still references aborted that transaction, the
cursor then died on its next fetch ("current transaction is aborted")
and the task ended up throwing `unhandled exception on running task`
after rolling back the whole night of work. That is what has kept
production from deleting a single object since late April.

Now `clean-deleted` walks the pending objects with the cursor in its
own read-only transaction and every chunk runs in its own one, so a
blocked chunk is logged with its ids and skipped while the rest is
committed. `task finished` is reachable again.

The private functions also drop the `!` suffix: the namespace is all
mutation, so the mark adds nothing and reads as noise.

The test builds a blocked object (a `file_media_object` pointing at it
as its thumbnail) plus a full chunk of pending objects and one more in
the next chunk, and checks that the run finishes and that the object of
the next chunk is deleted.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
7ffa841366 📚 Update serena memories 2026-10-13 14:33:32 +02:00
Andrey Antukh
3bbc1c3079 ♻️ Make cascade helpers stream rows and enqueue tasks
The four `process-deleted-*-cascade` helpers of `app.srepl.main`
loaded every soft-deleted row of a table into memory (`db/exec!`)
and ran `:delete-object` inline (`wrk/invoke!`), so they could not
be used against production: `file` alone has 2.1M deleted rows.

They now share a single `submit-deleted-cascade!` that reads the
rows with a server-side cursor (`db/plan`, in a read-only
transaction) and submits one task per row, so the work goes
through the workers like any other task. The row id goes as
`label` with `dedupe` on, and the helpers accept `:limit`, `:ids`
and `:dry-run?` to run bounded batches. Public names and their
zero-arity form are unchanged.

Tests cover the task params, the row's own date (not `now`), the
`limit`/`ids`/`dry-run?` options and that each helper reads its own
table.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
f242ff9574 🐛 Keep concurrency limits from deadlocking on nested calls
An instance left the audit events pipeline dead: every push-audit-events
answered with 429 and 69 worker threads parked for good inside a limiter.

Two code paths of one profile acquired the same two limiters in opposite
order: a command declaring its own limits (upload-file-media-object) took
process-image/by-profile and then root/by-profile, while a command with no
limits of its own took the roots first and then re-entered climit from
inside its handler with the same ids (create-file-media-object-from-url).
Each path held what the other one waited for, and no limiter had a timeout
to break the wait.

Now wrap puts the root limiters after the command's own, so they end up
outermost and every path meets them first: the nested chains carry only the
command's own limits and run inside them, which puts both paths in the same
order. impl-invoke also keeps, per thread, the set of limiters that thread
is inside of and runs the handler inline when a call asks for one it
already holds, so a handler can no longer queue behind itself.

The shipped climit.edn sets a timeout on every limiter (60s for operations
that can be slow, 30s otherwise), so no wait can park a thread for ever.
The production file lives outside the repository and has to be mirrored.

Tests cover the acquisition order, the deadlock, the reentrancy and the
timeouts of the shipped config.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +02:00
Andrey Antukh
18705f2d00 🐛 Make the audit events concurrency limit per profile
The command declared its limit as a plain keyword next to
`::climit/key-fn`, metadata that `climit/wrap` does not read: it
takes the key only from the positional `key-fn` of the vector form.
The limit was a single shared one, so when one call got stuck it
held the only permit and every audit batch of that instance was
rejected with 429 from then on.

The test builds the limiter from the command metadata and checks
that two profiles end up with different limiters.

AI-assisted-by: deepseek-v4.1-flash
2026-10-13 14:33:32 +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
Miguel de Benito Delgado
6f34794f54
🐛 Fix render-wasm build arguments and artifact selection (#12254)
Forward Cargo arguments as an array and honor explicit profiles.
Build and copy each renderer target once under the repository lock,
with watch rebuilds using the same wrapper.

Select this checkout's compiler artifact and matching generated enum
table from Cargo JSON. Record actual features, profile and output paths,
and allow callers to override publication destinations.

Run the existing Cargo parser tests before the renderer's native tests
and stop on parser failures.

AI-assisted-by: gpt-6.1-sol
2026-10-13 12:54:48 +02:00
Luis de Dios
a2c6e87fc7
🐛 Fix missing text style in verification email screen (#12242) 2026-10-13 11:29:41 +02:00
Eva Marco
317a731dda
🐛 Detect and repair children missing from their parent's list (#12216)
* 🐛 Detect and repair children missing from their parent's list

A shape could point to an existing parent that did not list it in its
:shapes. The validator missed it, since it only walks the tree from
the root plus the shapes whose parent does not exist. Operations that
walk :shapes missed it too: deleting a variant also deleted its
container, which looked empty, and left the other variants without a
parent, so the backend rejected the change and the file stopped
saving.

- get-orphan-shapes also returns shapes not listed by their parent,
  so :child-not-in-parent is reported.
- The :child-not-in-parent repair adds every unlisted child of the
  parent. All repairs share the same file data and each one sets the
  whole :shapes, so with several children only the last one was kept.
- New migration 0032-fix-children-not-in-parent repairs the affected
  files when they are opened.

Closes #11758

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

* 🐛 List shapes moved to root by the parent-not-found repair

The :parent-not-found repair set the shape's parent to the root but
did not add it to the root's :shapes, so it left the shape in the
child-not-in-parent state that the validator now reports. It now
adds every shape without parent to the root at once, since all
repairs are built from the same file data and each one sets the
whole :shapes.

Also cover the missing parent case in the repair tests.

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

* ♻️ Simplify unlisted children validation and repair

Address review comments on the validation and repair of shapes that
their parent does not list.

- get-orphan-shapes no longer rebuilds *children-sets*: both callers
  bind it. The lookup moves to a listed-in-parent? helper shared with
  check-parent-children, which scans :shapes when the var is unbound.
- The :child-not-in-parent and :parent-not-found repairs no longer
  walk every shape of the page. Each one moves only its own shape with
  pcb/change-parent. The :mov-objects change adds the shape to the
  parent's :shapes when applied, so several repairs on one parent no
  longer overwrite each other.
- The :child-not-in-parent repair test uses plain shapes instead of
  variants.

AI-assisted-by: claude-opus-5-5
2026-10-13 11:17:01 +02:00
Pablo Alba
2965a4c043
✨ Remove duplicated nitrate log type (#12275) 2026-10-13 09:33:06 +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
5eb33add7e
✨ Add skip-if-active option to jobs submit (#12266)
* ✨ Add skip-if-active option to jobs submit

Producers that need at most one live job per key can now pass
::jobs/skip-if-active: the submit counts live jobs for the same
tenant, name, queue and label in the same transaction and skips
the insert (returning nil) when one lives. The option requires an
explicit label and documents the same best-effort race caveat as
the dedupe it complements.

Closes #12265

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

* ✨ Record skipped job submissions as a metric

Skip-if-active submissions withheld nothing observable: the skip
now records penpot_jobs_skipped_total (name, queue) instead of the
submitted counter, so dashboards can tell withheld work apart from
new work. The insert path is untouched.

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

* ♻️ Drop jobs counters duplicated by histogram counts

The completed and requests counters counted exactly the same events
as the _count of their timing histograms, so they go away: terminal
totals now come from penpot_jobs_total_ms_count by outcome and
request totals from penpot_jobs_request_ms_count by outcome. One
event, one family from here on: counters only cover events without
latency, and the dispatched vs queue-wait pair stays because pushes
and claims are different events.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-11 18:48:02 +02:00
Filip Sajdak
10955f17e2
✨ Add get-file-tokens RPC to read a file's design tokens (#12172)
Clients that only need a file's design tokens, such as a CI job that
syncs them to a git repository after an update-file webhook, had to
download the whole file with get-file.

get-file-tokens returns the file's tokens as a DTCG document, the same
format the token export produces, or nil when the file has none. Active
themes and sets come from the file's tokens status, so they reflect
what is currently active.

The document is returned as a plain map. A bare TokensLib is not a
collection, so the response middleware would hand it to ring unencoded.

Relates to #7091

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-10-09 20:08:14 +02:00
breken
fa5e5b6176
🐛 Log errors readably in MCP execute_code console output (#11930)
* 🐛 Log errors readably in MCP execute_code console output

The console captured during execute_code rendered every object argument
with JSON.stringify. An Error has no enumerable fields, so logging a
caught error printed "{}" and the agent lost the message. A circular
object or a BigInt made JSON.stringify throw, so a console.log call
aborted the executed code with an unrelated serialization error.

Format console arguments in a small pure module: errors by their string
form (name and message), other objects as indented JSON, falling back to
the string form when JSON cannot serialize the value.

AI-assisted-by: claude-opus-5.5
Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>

* ♻️ Keep console formatting within ExecuteCodeTaskConsole

---------

Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
2026-10-09 19:39:40 +02:00
Andrey Antukh
6f33d8d9d8 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 19:16:03 +02:00
Andrey Antukh
da2652f1dd 📚 Note GitHub assignee limits in the issues workflow
`gh issue edit --add-assignee` fails with a 422 for users without
write permission on the repo, for non-members, and for people who
never commented, and the command still exits 0. The memory now
records that failure and the `gh api` check that reveals whether the
assignment actually took effect.
AI-assisted-by: step-5-preview-free
2026-10-09 19:14:29 +02:00
Andrey Antukh
25e8f349e1 ⬆️ Update opencode to 2.0.26 in the devenv image
Bumps the OpenCode CLI version pinned by the devenv image from 2.0.25
to 2.0.26 and refreshes the per-architecture SHA-256 checksums used
to verify the downloaded tarballs.
AI-assisted-by: step-5-preview-free
2026-10-09 19:14:29 +02:00
Andrey Antukh
0c450a8f60 🐛 Skip pino transports inside node:test processes
The pino transports run in a worker thread reached through a message
port that stays referenced once a log line is in flight, so the
node:test child process kept running after every test and hook had
finished. The runner then canceled the file's root test with
"Promise resolution is still pending but the event loop has already
resolved" and the suite hung until something killed it, which
happened in roughly two runs out of five.

`logTransportTargets` returns no targets inside a node:test child
process, which `node --test` marks with `NODE_TEST_CONTEXT`, so
tests log straight to stdout and the process exit is deterministic.
Production still uses the console, file, and Loki transports.
AI-assisted-by: step-5-preview-free
2026-10-09 19:14:29 +02:00
Andrey Antukh
c2125e1589 ⬇️ Downgrade MCP TypeScript from 7 to 6
The 7.x line ships only a version stub at the `typescript` entry
point and no longer exposes the synchronous compiler API, so the
`ts.transpileModule` call that `ConnectionMetadata.test.ts` uses to
compile `main.ts` and `plugin.ts` into a sandboxed browser context
failed with "Cannot read properties of undefined (reading
'CommonJS')", taking all 11 plugin connection tests down.

6.0.3 restores that API and matches the version the frontend and
plugins packages already pin.
AI-assisted-by: step-5-preview-free
2026-10-09 19:14:29 +02:00
Andrey Antukh
7be9254a55 📚 Document review comment structure and finding IDs
The review-code flow skill now documents how to post a review as a
GitHub comment: in English, with a minimal structure (intro line,
main issues, minor suggestions, optional closing). Posting is
explicit-only: the review stays in the session unless the user
asks for the comment.

The code-review-criteria skill replaces the flat finding ID
sequence (F1, F2, ...) with severity classes: R1 for required
findings, M1 for minor ones, and S1 for suggestions. IDs stay
stable so follow-up discussion can reference findings
unambiguously.

AI-assisted-by: glm-5.3-flash
2026-10-09 19:14:29 +02:00
ump45nose
1125f72d81
🐛 Remove third-party Work Sans import from plugin styles (#12087)
@penpot/plugin-styles loaded Work Sans from fonts.bunny.net via a remote
@import in core/fonts.css, which every plugin built with the package
fetched at runtime. That broke air-gapped deployments, leaked user IPs
to a third party, and forced deployments to widen the CSP style-src
and font-src directives introduced in #11374.

Drop the remote import and use the system-ui stack instead of
'Work Sans' in the same package (both in core/fonts.css for code
blocks and in core/generic.css for the base typography, which relied
on the same webfont), so plugins no longer reach any external domain
and 'self' is enough for both CSP directives.

Verified by bundling the package entry point with esbuild using the
same options as tools/scripts/build-css.mjs: the produced styles.css
is balanced, contains system-ui, and contains no external import.

Closes #12023

Signed-off-by: ump45nose <1729065730@qq.com>
2026-10-09 18:38:05 +02:00
Filip Sajdak
d7ccc0f3a6
✨ Add get-file-plugin-data RPC to read file plugin data (#10171)
The Plugin API can write arbitrary key/value plugin data onto the file
root, but it could only be read back by downloading and decoding the
whole file. Add a `get-file-plugin-data` read command that returns the
file-level plugin data map, optionally narrowed to a single namespace,
so external tooling can fetch metadata such as issue or commit
references cheaply.

Read-only: reuses `bfc/get-file` and `check-read-permissions!`, and
adds no data-model or change-type changes. Object/page level reads are
intentionally left out of this first command.

Relates to #10169

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-10-09 18:15:07 +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
Francis Santiago
e463e7c95f
Merge pull request #12236 from penpot/fc-subpath-documentation
📚 Document subpath configuration and migration
2026-10-09 12:47:17 +02:00
Francis Santiago
6839b16735 📚 Document subpath configuration and migration 2026-10-09 12:26:57 +02:00
Alejandro Alonso
cda19e0d51
⬆️ Use Wasm EH and SjLj for render-wasm with Rust 1.99 (#12235)
Rust 1.91 links emscripten with JS exception handling, which cannot
mix with SUPPORT_LONGJMP=wasm. Bump the devenv to Rust 1.99 (native
Wasm EH) and align EMCC_CFLAGS / SKIA_BINARIES_URL with a Skia
-ljmp-wasm prebuild so +simd128 no longer hits invoke_*V TypeErrors.
2026-10-09 11:53:28 +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
Pablo Alba
76e9c132ef
✨ Send more enriched events to nitrate log (#12210)
* ✨ Send more enriched events to nitrate log

* ✨ Fix mr
2026-10-09 10:58:05 +02:00
María Valderrama
7745c1f052
✨ Make the whole current plan card clickable (#12238) 2026-10-09 10:46:33 +02:00
Alejandro Alonso
4eec950b96 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 10:00:13 +02:00
Alejandro Alonso
dd76faf17b Merge remote-tracking branch 'origin/main' into staging 2026-10-09 10:00:00 +02:00
Andrey Antukh
9b8dde2954
✨ Add rust toolchain setup on rust scripts (#12237) 2026-10-09 09:54:55 +02:00
Andrey Antukh
bcb7a8329c Merge remote-tracking branch 'origin/develop' into develop 2026-10-09 09:01:39 +02:00
Andrey Antukh
473a59b134 Merge remote-tracking branch 'origin/staging' into develop 2026-10-09 09:01:12 +02:00
María Valderrama
d0351a3ee2
✨ Add event name for create attributes (#12165)
* ✨ Add event name for create attributes

* 📎 Code review
2026-10-09 08:50:35 +02:00
Andrey Antukh
a36193e54b 🐛 Pass team-id in binfile v3 import tests
`resolve-and-link-libraries` asserts `::bfc/team-id`, and
`enable-backend-asserts` is now enabled in `scripts/ci` and
`tests-backend.yml`. Without a team-id, the v3 import tests
error instead of passing. Production always provides the
team (RPC import, management and debug routes); mirror that
in the tests.

Update `mem:backend/testing` and `mem:scripts/ci` with the
`enable-backend-asserts` behavior.

Verified with `./scripts/ci backend`: 1235 tests, 6384
assertions, 0 failures.

AI-assisted-by: deepseek-v4.1-flash
2026-10-08 17:33:40 +00:00
Andrey Antukh
ff4c4a4a34 📎 Backport .github and scripts/ci from develop 2026-10-08 19:24:58 +02:00