mirror of
https://github.com/penpot/penpot.git
synced 2026-11-03 00:20:41 +00:00
167 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
65a76a21cd | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
a3fe43c1ec
|
🐛 Report raw fetch failures as connectivity errors (#12624)
* 🐛 Report raw fetch failures as connectivity errors A fetch that fails outside app.util.http (zip.js, plugins, schema loads) rejects with a bare TypeError such as "Failed to fetch". It reached the global handlers and was reported as an unhandled crash, with no stack and no URL. The global handlers now wrap the browser's network TypeErrors as :network (or :offline when the browser says so), so they get the connection toast and a compact handled report. Response body reads in app.util.http also wrap a TypeError as :network with the request uri, as fetch itself already did. Relates to #12581 AI-assisted-by: claude-opus-5-5 * 🐛 Treat only network TypeErrors on body read as connectivity errors process-response-type turned every TypeError from a body read into a :network error, so a bug such as "body stream already read" showed a connection toast. It now uses network-failure?, which moves to app.util.http so that both the http client and the global error handlers share it. Add Firefox's "Error in body stream" message. Tests now check :offline and :network separately by setting navigator.onLine, and read-error builds on async/->promise. AI-assisted-by: claude-opus-5-5 |
||
|
|
e20a0d748e
|
✨ Add OAuth support for MCP server (#12480)
* 🎉 Add OAuth support for remote MCP connections Add MCP-specific discovery, client registration, and approval endpoints. Exchange single-use authorization codes with PKCE for the fixed MCP user token, enabling MCP and creating the token on approval when needed. Use self-contained client IDs without new database tables or stored client records. Keep temporary authorization state in Redis/Valkey. Accept bearer credentials while preserving query-parameter connections. Enable OAuth through PENPOT_MCP_AUTH_URI in multi-user mode. Document the environment variable in the README and implementation details in Serena. Explain protocol and concurrency decisions in the Clojure code. Cover token provisioning, OAuth state, client registration, HTTP flows, and the approval page with tests. AI-assisted-by: gpt-6 * 🐛 Wait for devenv initialization before starting builds Docker reports a container as Running as soon as its main process starts, while the entrypoint may still be preparing the development environment. manage.sh used that status to launch tmux jobs, allowing frontend builds to race with ownership changes to /opt/rustup and Cargo setup. On a fresh container, rustup could fail with Permission denied before the frontend watcher started. Nginx would later serve the existing JavaScript, leaving source changes absent from the running app despite restarting devenv. Wait for nginx's local health endpoint before launching the jobs. Nginx starts after entrypoint setup, so its response confirms that setup has finished. Allow up to 120 seconds and report a timeout without starting the jobs if initialization does not complete. Validation: shell syntax and whitespace checks pass. AI-assisted-by: gpt-6 * ♻️ Share the login modal with MCP authorization Extract the viewer login modal and its styles into a shared auth component, retaining the viewer wrapper and its close behavior. Use the modal on the MCP approval page and preserve the pending request through login. Closing it clears the saved return URL and shows a cancellation message without further actions. Cover return URL handling and cancellation with frontend tests. AI-assisted-by: gpt-6 * ✨ Show client icons in MCP authorization Preserve HTTPS logo URLs from client registration and published metadata and show the icon beside the application name on the approval page. Ignore unusable URLs and hide failed images without blocking approval. Update the access description to refer to MCP connections established from Penpot. Cover logo metadata and approval responses in backend tests. AI-assisted-by: gpt-6 * 🐛 Follow the system theme when no preference is configured Default to the system theme in the shared resolver used by the app and plugins. Preserve explicit light and dark choices, including the legacy default value that means dark. Reuse the reactive theme hook in progress widgets and show the system preference in settings for accounts without a configured theme. Verify preference resolution with regression tests and browser checks. All 1,474 frontend tests pass. AI-assisted-by: gpt-6 * 🐛 Replace expired MCP tokens during OAuth approval Settings can give an MCP token an expiry date. OAuth approval reused the newest MCP token without checking it, so an expired token reached the client and never matched a plugin connection. Approval now treats an expired token as missing and creates a new one. Token exchange rejects a code whose token expired after approval with invalid_grant. AI-assisted-by: claude-opus-5-5 * 🐛 Log unexpected errors on MCP OAuth endpoints The OAuth routes sit outside the shared HTTP middleware, so the shared error handler never saw them, and their own handler turned unexpected failures into a 503 without logging anything. Failures in the session middleware of the approval endpoints had no handler at all. Unexpected failures now also go through the shared error handler for its logging, while clients still get an OAuth error body. The OAuth error handler also wraps the session middleware of the approval endpoints. AI-assisted-by: claude-opus-5-5 * 🔧 Add devenv script to run the MCP server with OAuth start-mcp-devenv-oauth starts the MCP server in multi-user mode with PENPOT_MCP_AUTH_URI derived from PENPOT_PUBLIC_URI, to test OAuth in the devenv. Agentic tooling still uses the single-user start-mcp-devenv. AI-assisted-by: claude-opus-5-5 * 🐛 Fix failing test case --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> |
||
|
|
4dc684dd9c | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
0532f15cee
|
🐛 Fix tab on variant name doesn't validate formula (#12655)
* 🐛 Fix tab on variant name doesn't validate formula * ✨ Move helpers to common * ✨ Mr proposed changes |
||
|
|
a8e8a54a2b | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
a70712fce4
|
🐛 Fix issue with compononent not sync on move (#11981) | ||
|
|
deb5771248
|
✨ Use WASM for SVG markup in code inspect and copy (#12567) (#12608)
When render-wasm is active, generate SVG for the Code inspector and Copy as SVG from the WASM export path instead of classic SVG codegen. Add multi-root render_shapes_svg so multi-select stays one document. Closes #12567 |
||
|
|
86e01d56a2
|
🐛 Update canvas and keep local sets when switching tokens source (#12442)
Setting a library as tokens source did not update shapes that use tokens, because set-tokens-source never ran the token propagation that every other token operation runs. Run it after the commit. Going back to the local tokens, or unlinking the library that was the source, left no local set active: the file has a single tokens status, and switching to a library replaced it with the library's one. Keep the status of the file's own tokens lib aside in a new optional :own-tokens-status attribute while an external library is the source, and restore it when the file is its own source again. Exporting without libraries and absorbing a deleted source library restore it as well. Closes #12430 AI-assisted-by: claude-opus-5-5 |
||
|
|
e27c65c77a
|
✨ Run WASM asset exports locally in the browser (#12515)
When render-wasm is active, shape exports (single file, ZIP, and Export boards multi-page PDF) run on the shared worker WASM module so the UI stays responsive. A progressive PDF session (begin/add-page/finish) updates progress per page; cancel settles on the client. The server exporter path remains for the SVG renderer. |
||
|
|
9aa78a536b
|
✨ Resolve tokens with the tokenscript-interpreter package (#12348)
Replace the @penpot/tokenscript npm package (the TypeScript interpreter) with penpot/tokenscript-interpreter, a Clojure and ClojureScript interpreter that now lives in its own repository and compiles into the frontend build. The frontend adapter (app.main.data.tokenscript) resolves tokens with it behind the existing :tokenscript flag and maps its results to the values and errors the workspace uses: - Token values are validated by the interpreter's token types, passed by their DTCG names: `1foo` is not a dimension, a number has no unit, an opacity is between 0 and 1. Penpot only adds its own limits (font weight names, text decoration keywords). - Division by zero, incompatible units, unknown functions and function arguments out of range get messages of their own, and token pills show the message of their error. - Font family names are quoted before resolving, as in the adapter this replaces (#11750): `Red Hat` stays one name. The token form's value input passed the token name to the tokenscript resolver in place of the typed value, and no name at all for a new token. It now resolves the typed value under the form's token name, as the StyleDictionary path does. StyleDictionary stays the default until the interpreter is stable. frontend/deps.edn pins the interpreter by tag and commit, v0.1.0. Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com> |
||
|
|
fcf9c27635 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
ecfa350470
|
✨ Auto-scroll sortable lists while dragging near their edges (#11595)
* ✨ Auto-scroll sortable lists while dragging near their edges Native drag and drop swallows wheel events, so a long list could only be scrolled mid-drag by hitting the browser's narrow auto-scroll strip at the very edge of the container. use-sortable now scrolls the item's nearest scrollable ancestor on every animation frame while the pointer sits within 56px of its top or bottom edge, ramping the speed up to 18px per frame as the pointer gets closer. Every list built on the hook (layers, sitemap, option rows, gradients, token sets) gets it without any change on their side. * ✨ Cap auto-scroll zones and test the scroll loop Each edge zone now takes at most a quarter of the container, so the middle half of a short list, like the pages panel, never scrolls. The scroll container is found once when the drag starts. The speed is scaled by the time between frames, so 120 Hz screens no longer scroll twice as fast. Starting a drag also stops any loop left over from a drag whose end event was lost. auto-scroll-speed is now private. New tests cover the running loop: only one loop starts, it stops on drag end, when the speed goes back to zero and after 500ms without dragover, and the frame time scaling. AI-assisted-by: claude-opus-5-5 --------- Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com> Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com> Co-authored-by: Andrey Antukh <niwi@niwi.nz> |
||
|
|
4635eaa467
|
🐛 Show request access modal on viewer and dashboard urls (#12456)
The exception page compared the screen query param against literals
that never occur ("view", "dashboard"), so viewer and dashboard urls
of inaccessible files and teams fell through to the plain not-found
page instead of the request access modal. Classify the param with a
tested helper that recognizes "viewer" and every "dashboard-*" route.
Closes #11224
AI-assisted-by: muse-spark-1.3-contributor
|
||
|
|
fffc3ee222 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
fdd66978fd | Merge branch 'main' into staging | ||
|
|
38a6961ff1
|
✨ Make keyboard shortcuts layout-independent (#12326)
* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)" This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2. * 🐛 Migrate mousetrap to key with sane modifiers Finish the half-done mousetrap migration from code/keyCode to key: canonical lowercase tokens on both bind and event sides so modifiers match again, US-only shift remap and keypress legacy removed, and AltGr phantom ctrl filtered on Windows. Record punctuation positionally through getLayoutMap (American fallback) so Shift+Period stores shift+. on every layout, while letters and digits stay glyph-based for AZERTY. Drop dead 219 aliases and revert the stray CamelCase escape command. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Rewrite mousetrap as ESM class without behavior changes Turn the 2012 constructor-plus-prototype shape into a class with underscore-private methods, const tables and arrow listeners. Drop dead _belongsTo, fold one-line prevent/stop helpers into _fireCallback, and let handleKey absorb the old _handleKey indirection. Public API and matching logic stay identical; verified by existing tests plus a runtime smoke. AI-assisted-by: muse-spark-1.3-contributor * 🎉 Add node:test suite for mousetrap shortcuts Cover canonical key normalization, modifiers with AltGr, bind arrays plus aliases keyup reset overwrite, sequences with fake timers, and the full stopCallback matrix, all through isolated instances and synthetic events. Hook the zero-dependency suite into frontend/scripts/test for CI. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Record shortcuts by glyph instead of position Drop the getLayoutMap positional layer and the American fallback: punctuation now records event.key lowercased, the same token Mousetrap dispatches, so Shift combos match with and without a layout map. Keeps the Numpad numN branch both sides share. Updates the pinned positional tests and adds the Spanish Shift-plus regression. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Resolve shifted digits to the digit itself Shift+DigitN now resolves to the digit on every layout, so shift+0 fires on QWERTY ()) as well as AZERTY (0), like the old keyCode behavior. Applies in the recorder and Mousetrap; unshifted digits and numpad are untouched. Also stages the layout-map suite left untracked. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Cover punctuation defaults with layout twins Some defaults only fired where their glyph is unshifted (+ dead on US layouts, shift++ dead outside ES/DE, \ dead where it needs AltGr). Bind the twin token per layout family and freeze the display with :show-command. No tooltip or translation changes. AI-assisted-by: muse-spark-1.3-contributor * ✨ Warn when the keyboard layout API is missing Shows a warning on the shortcuts settings page in browsers without navigator.keyboard.getLayoutMap, where Shift with punctuation may not fire. Uses a synchronous probe, English and Spanish strings, and documents the layout limitations in the user guide. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix hide-ui twins and qualify layout docs The ctrl+alt+\ twin could never fire (the AltGr filter drops Ctrl), so it goes away; alt+º covers Chrome on Spanish layouts where AltGr+º resolves to º. The user guide now states which claims need the layout map and which hold everywhere. AI-assisted-by: muse-spark-1.3-contributor --------- Signed-off-by: Eva Marco <eva.marco@kaleidos.net> Co-authored-by: Eva Marco <eva.marco@kaleidos.net> |
||
|
|
91a5b3a9fb
|
🐛 Fix token tree path handling for repeated and nested segments (#11591)
* 🐛 Fix token tree collapsing repeated path segments Tokens whose path contains the same segment name twice in a row (e.g. test.pointer.pointer) collapsed to a single level in the token tree. build-tree-node marked a node as a leaf when the current segment name matched the first segment of every remaining path. Because group-by-first-segment rewrites nested paths into the segment name, a still-nested remaining path was indistinguishable from a token ending at the current node, so test.pointer.pointer merged into one leaf. Mark rewritten segments with an internal ::remaining? marker and strip it when the rest-path is fully consumed; is-leaf? only accepts unmarked remaining segments as leaves. Closes #11588 AI-assisted-by: glm-5.3-flash Signed-off-by: Junsoo Choi <junsoo1172@gmail.com> * 🐛 Preserve ancestor tokens in path trees Keep a node's token and descendants independently when imported paths contain both an ancestor and a child. Render both in the token tree and include the ancestor token in existing path-based group operations. Cover accepted same-set imports, leaf IDs, repeated segments, and internal marker removal without changing import or collision validation. AI-assisted-by: GPT-6 Signed-off-by: makesomethingshit <junsoo1172@gmail.com> * ♻️ Rework token tree path building Carry the segments left to descend as a ::remaining-path vector on each segment and advance it by subvec, instead of rewriting the segment :name and re-splitting it later. With that, build-tree-node finds the leaf token with a predicate over ::remaining-path and takes children from the segments that still have one, so the pre-grouping children-first sort no longer changes the result. Drop sort-children-first and its segment-has-children? helper, and document inside-path?. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix and relocate the token tree tests The ancestor-and-descendant test called a function that does not exist in app.common.types.tokens-lib, so the namespace failed to load and the whole common suite errored. Use make-tokens-status-from-lib and the two-argument get-tokens-in-active-sets instead. Move the build-tree-root tests to path-names-tree-test so path-names-test stays a pure split/join unit test. AI-assisted-by: deepseek-v4.1-flash * ♻️ Refactor the token tree rendering and share path helpers Extract the token pill into a leaf-node* component and treat a node as a leaf only when it holds a token and has no children, so a node can carry both a token and its children. Make token-separator public and reuse it from the token tree and the management panel instead of local copies. Both management path filters inlined the same ancestor-or-descendant rule; extract it as token-in-path? and cover it with a unit test. AI-assisted-by: deepseek-v4.1-flash --------- Signed-off-by: Junsoo Choi <junsoo1172@gmail.com> Signed-off-by: makesomethingshit <junsoo1172@gmail.com> Co-authored-by: luisddm <luis.dedios@kaieldos.net> |
||
|
|
a3fc7c8465 |
✨ Save before downloading a .penpot file and warn if behind
The .penpot download is built from the server copy. Users who downloaded a file to keep their work while saves were failing got an old or empty file, with no warning. Opening the download dialog now sends the pending changes of the files and waits up to 10s for them. If some are still not saved, the dialog says the download will not include them. |
||
|
|
dfc55a7192 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
57fa280209
|
🐛 Validate and decode a job result with its declared schema (#12350)
* 🐛 Decode job results with the schema of the job-def A job result is stored as plain JSON, so a uuid comes back as the text it is on disk and a uuid used as a map key comes back as a keyword. The import resolution is a map keyed by file id, and the wizard lost that identity: choosing a library in the link step created no link at all, with no error and with the modal closing as if it had worked. A job-def can now declare a result schema and its precompiled decoder, the pair the params already have, and the substrate applies them when the row is read. A def that declares neither answers with the JSON the row holds, as before; a row whose job-def is gone is read untouched instead of raising. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Type the import result so its resolution keeps uuid keys The resolution of a version 3 import is a map keyed by the new file id, and the result column is plain JSON: without a schema of its own every key came back as a keyword while the :id of the entry came back as text, so the dashboard wizard could not match the file it was linking and created no library link at all. Declare the shape of the resolution (files, done and pending entries and their candidates) and give the job-def its result schema and decoder. The check before the result is stored keeps validating the same schema. Closes #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Take the imported file id from the entry, not the map key The wizard looked a file up in the selection with the key of the resolution map while it had indexed the selection by the :id of the entry. The key is keywordized on its way through the job result, so the lookup missed and the confirm button created no library link at all, with no error and with the modal closing as if it had worked. Take the id from the entry, the source the rest of the wizard already uses, and move the decision to a pure function covered by tests of both shapes of the resolution. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Test that an import result keeps its ids as uuids The regression this branch fixes is only visible through the read path of the substrate: the resolution of an import is a map keyed by the new file id, and a test that works with the value in memory never sees the keys coming back as keywords from the plain JSON column. Import a package whose library needs a replacement chosen by the user, read the result with `jobs/get-job` as the client does, and check the keys, the file ids and the ids of the candidates. Without the result decoder of the import job-def the test answers with text ids. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Document the result schema of a job-def The memories and the docstring of the substrate said a job result has no schema of its own and comes back as the JSON the row holds. A job-def can now declare its result schema and decoder, and the substrate applies them when the row is read. Keep the rule next to it: a result map keyed by uuid needs its schema declared, or every key comes back as a keyword, and the link-later step of the import wizard takes the file id from the entry and never from the key of the resolution map. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Fix the documented contract of a job result The docstring of get-job said a result has no schema of its own and comes back as the JSON the row holds, which is what this branch changes: the result is decoded with the schema its job-def declares, and a result that does not conform never reaches the column because the write validates it. Say the same in the management command that stores the result of an external worker, the other writer of the column. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Check the result of a job against its schema before storing it The result of a job crosses a plain JSON column, so a uuid comes back as text and a uuid used as a map key as a keyword. The job-def now declares the schema of its result and the substrate compiles the decoder from it, which is what the read path uses to get the types back. The same schema is the contract of the write path: complete refuses a result that does not conform, with the malli explanation, so nothing invalid reaches the column, and the answer is the same for the runner and for the external worker that completes a job through the management RPC. A job that is no longer completable is skipped without checking, and the read path decodes only: a row written by another version stays readable. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Check the result of an import inside its transaction The check ran after the transaction of the import had committed, so a result the schema does not accept failed the job with the files already created in the destination project: a half imported project and an error. The core decides whether the whole import is kept or rolled back, so the check belongs inside its transaction, which is what turns a contract violation into a rollback. The substrate checks the result again when the job is completed, which is the only net for a job that does not validate its own result. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 🐛 Cover the edges of an import result read by the client The shape of a result with no libraries to resolve, and the one of a version 1 import, is the most common in production and neither was covered by the tests that read a result the way the client does. Cover both through jobs/get-job, and the case of a registry that no longer knows the kind of the row: it answers with the JSON of the column, keywordized keys and uuid as text, instead of raising. Say in the two tests that depend on the decoder what implementation without it makes them fail. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📚 Fix the prose and the fixtures of the result contract The result schema of a job-def is what the read decodes with and what the write checks: say that in the memories, with the rule that any uuid of a result needs it, whether a value or a map key. The motive of the wizard helper was a fact this branch makes false: what a key of the resolution map holds depends on the result schema of the job-def that wrote it, so the rule is to read the id from the entry. Same correction in its memory and in the test that pins the shape. The candidates of a pending entry match by slugified name, not by name, and every test row now states the kind of its job, because the name is what resolves its job-def when the result is read or checked. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Keep the wizard link helper private and cover its shape The helper that decides which links the wizard creates belongs to its namespace, as its neighbours do: reach it from the test with @#', the pattern the frontend tests already use for a private helper. Cover the shapes it had no test for: a file with two libraries pending resolution, a file whose libraries were auto-linked, and a selection where only one of two entries was resolved. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Share one job-row helper across the job tests Three test namespaces had their own copy of the same insert, differing only in the kind of the job and in whether the row holds the resource it consumes. Move it to the test helpers as create-job*, with the kind of the job as its first argument: it is what resolves the job-def of the row when its result is read or checked, so every test states it. The bang of the name goes away with it: the design rules keep it for the mutating API of app.db, and this is a fixture of the tests. The three namespaces lose the requires that only their removed copy used. Relates to #12339 AI-assisted-by: deepseek-v4.1-flash Signed-off-by: Andrey Antukh <niwi@niwi.nz> --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz> |
||
|
|
334f727e87 |
🐛 Stop oversized audit events from wedging the queue
Port staging c74d415 (#12252) to main, adapted to this branch. The :export-shapes modal event carried the full :exports vector, each export embedding its shape, which could reach megabytes and wedge the audit archive queue past the proxy body limit. Build a compact body instead: totals, distinct shapes and per-type counts, plus the page name. Other modals keep their props. Move the modal :origin prop to ::ev/origin so make-proto-event records it as :event-origin; the UI still reads the plain prop. Bound exception messages at the source: get-hint clamps to max-message-length (1000 chars) and the CLJS format-throwable clamps the trace message line and honors :trace-length. The Context Hint header in generate-report prints get-hint instead of the raw hint. Also fix first-line on the JVM, where cuerdas/index-of returns nil and (pos? nil) throws. Adaptations to main: skip audit-log.md (absent here), skip the generate-report huge-message test (staging async helpers are absent), and fit the errors.cljs hunk to main's generate-report. AI-assisted-by: muse-spark-1.3-contributor |
||
|
|
d57cdb4588 |
⏪ Backport persistence related fixes from staging (#12306)
* ✨ Add async test helpers and the with-mocks* macro Backport of the test scaffolding that the persistence retry work needs, taken from staging. Purely additive: no existing helper changes behavior, so the suite result is unchanged (740 tests, 0 failures). - frontend-tests.helpers.async: ->promise, await-response, settle, observe and wait-for. wait-for checks immediately and then polls one macrotask at a time, bounded, so it fails instead of hanging. - mock/with-mocks*: installs mocks with set! and evaluates its body in a generated ^:async fn, so the test reads sequentially and there is no done to thread. Evaluates to a promise. - mock/asap and mock/run-mocked: run-mocked awaits the body and restores the originals always, in that order, reporting a rejection or a non-promise return as an :error. The matching change that made with-mocks defer its body via asap is NOT backported: it would force all 58 existing uses across 14 files into done-chained contexts, which is a separate refactor. AI-assisted-by: space-bunny-free * ✨ Add an error report governor with compact payloads One browser session could emit an unbounded number of identical error reports, and a dropped connection was reported as an application defect with the full payload. Both are fixed here, in one place, because the persistence retry work that follows depends on the new submit-report contract. - A report governor in app.main.errors. Each report carries a fingerprint (report-name|type|code|hint|first stack frame); the first occurrence is always emitted, repeats inside a 2 minute window are counted, and the next emitted report carries them as :occurrences. The cache is bounded by evicting the oldest entry, so memory stays fixed. - flash reserves the report before generating it, so a suppressed occurrence never pays the report-building cost. Its whole body now runs inside one ts/schedule callback, a reporting or notification failure is logged instead of propagating, and it returns a total promise so tests can await it. - generate-report accepts :format :compact|:full. The compact payload keeps the context header plus type, code and uri, and skips the stack, the ex-data dump (it can carry request headers) and the last-events list. - environment-error-types is derived from rp/retryable-types, so the transport classification has a single source of truth. Environment failures are audit-only handled-exceptions and never reach the exception page or the alerts. :offline gets its own handler instead of falling through to :default. :occurrences travels inside the audit event :props, which the backend schema accepts as a free-form map, so no backend change is needed. The dedup governor test file is new: 7 tests over the fingerprint, the pure reservation decision, and the flash pipeline. Relates to #11726, #11743 AI-assisted-by: space-bunny-free * ♻️ Answer commit refusals with an empty stream commit-changes used to return nil when it refused to build a commit, which forced every caller that wanted to observe the outcome to special-case nil. It now returns an empty stream, so a refusal and an empty result look the same and callers can subscribe uniformly. Only the refusal branch changes: the accepted branch already answered with (rx/of commit). Potok treats a nil watch result as "nothing", so both spellings were already correct at the store level. The 57 call sites that wrap this event in rx/of are unaffected. They hold the ::commit-changes event itself, not the value its watch returns, so they keep processing exactly as before. AI-assisted-by: space-bunny-free * ✨ Retry transient saves with backoff and a reconnect notice Any failed update-file landed on the same path: the queue halted in :error, the retained changes were flushed away, and the user was told to reload. A dropped connection and a revoked permission are not the same thing, so they now take different paths. transient-error? splits them, checking :type and :cause-type because persistence-failed wraps the original cause. Transient failures enter a :retrying episode: the head commit stays queued and resends with backoff at 2s, 8s and 20s, then falls through to today's terminal path. Everything else is terminal and keeps the exact :error path. - Resends rotate the ::request-id stamp only once the old request has left active-requests, carry the same :commit-id, and never double send while one request is in flight. - Retry timers carry an episode token; a timer whose episode was superseded stays silent instead of sending or failing. - One tagged reconnect notice per episode, hidden by tag on save and on terminal failure. Recovery is silent. - The browser online event re-enters the runner, but only for a live :retrying episode, guarded by exists? js/window. - wait-persisted-or-error waits through :retrying instead of cutting the wait short. The episode keeps its :run-id, so a new local edit only joins the queue. Re-entering the runner on every edit would bypass the backoff and spend one attempt per batched commit. The retry test namespace is new: 13 tests over the classification, the backoff schedule, stamp rotation, stale tokens and the notice lifecycle. Relates to #11724 AI-assisted-by: space-bunny-free * 🐛 Fix stalled saves that never answer and edits lost on leave Two holes the retry work did not close. An update-file request that never gets an answer kept its attempt stamp forever, so the queue could not move and the user saw a saving state that never resolved. The request now has a 7 minute timeout that fails it as a transient network error, which hands it to the retry path instead of blocking. The timeout is longer than the 5 minute stall threshold on purpose: the stalled report must fire before the retry, not instead of it. The leave-page warning also had gaps. It now tracks unsaved edits directly, through sync-unload-guard, which holds :persistence in st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept in the queue after a failed save keep the guard on too, so closing the tab warns instead of dropping them. store.cljs guards its ongoing-tasks watcher with exists? js/window, because the new tests exercise it without a window. Four new tests: three for the guard across buffering, an in-flight save and a retained edit, and one for a hung request that must resend and save. Closes #12223 AI-assisted-by: space-bunny-free * 🐛 Skip the save when a missing font cannot be remapped fix-deleted-fonts emitted text and typography changes even when no replacement font existed, so opening a page that uses a deleted custom font rewrote identical content and cleared position-data on every visit. Every visit became a save with nothing to save. Both loops now compare the remapped value with the original and emit nothing when they match. The remapped path is unchanged. Two new tests cover the no-replacement case and the remapped case. Closes #12151 AI-assisted-by: space-bunny-free * ✨ Add a debug helper that simulates an intermediary response A save that never reaches the backend is hard to reproduce: you need a proxy, a captive portal or a CDN in the way. This wraps fetch and answers the named API method with a status and a body that carries no Penpot error code, leaving every other request alone. Without a duration the simulation is spent after answering one call. With one it answers every matching call for that long, which is how to watch the save retry, warn, keep retrying and finally recover. debug.simulateHttpError('update-file') debug.simulateHttpError('get-comment-threads', 403) debug.simulateHttpError('update-file', 524, 120) debug.clearHttpErrorSimulation() Dev-only and self-contained: no production path reads it. AI-assisted-by: space-bunny-free * 🌐 Add the save retry and connection error strings Three strings the new persistence and error paths need: - errors.save-retrying, shown once per retry episode while the queue resends a queued save after a connection loss. - workspace.header.retrying, the save indicator state for an episode in progress. - errors.connection-error, shown by the network and offline handlers, which no longer fall through to the generic message. Added to en.po only. The translations script also normalizes every other locale and drops 439 strings that were already stale before this work; that belongs in its own maintenance pass, not in a backport. AI-assisted-by: space-bunny-free * 🐛 Report save failures as unhandled when they are our defect flash-persistence reported every failure it did not delegate as handled-exception, including the ones that are plainly our fault. A validation failure means the client sent something the server refused, yet it landed in the audit log with a name that skips the internal reports and the alerts. Nobody was paged. The report type now follows the cause. :validation, :server-error and :internal report as unhandled-exception, so they alert. The rest are unchanged. Two exclusions are deliberate: - Environment failures stay handled. They reach this point after the retry budget is spent, and the user's connection is not our bug. - :assertion stays handled, because its own handler already reports it that way and the two paths should not disagree. The cause type is read from :cause-type first, since persistence-failed wraps the original under :type :persistence. Reading :type alone would match the wrapper every time and change nothing. The user still gets the same sticky toast and report link; only the audit event name moves. The test that asserted a validation failure is "still notified" now asserts which event name it gets, across the bare and wrapped shapes, the environment types that must stay handled, and an unclassified one. AI-assisted-by: space-bunny-free * 🐛 Unblock persistence when opening a file after a terminal failure A terminal save failure set the state to :error and left the queue alone. recover-persistence only resumed for :save-permission-denied, so for every other terminal code both of its branches were false and it emitted nothing. Leaving and reopening the same file therefore changed nothing: the editor stayed halted until a reload, which rebuilds the whole store and loses the queue anyway. The path had two jobs under one name. initialize-persistence fires when the workspace view mounts and tears down the previous subscription; recover-persistence fires when a file is opened or the role changes. They are now separate: initialization picks up a queue left behind by a previous session, and deliberately does not lift a halt, so undeliverable edits are released when the user acts rather than when the view mounts. Recovery lifts the halt by releasing what can never be saved. release-failed-file drops every queued commit of the file that failed, then resume-persistence clears the error and settles an empty queue to :saved. Retaining them until that point is deliberate: it is what keeps wait-persisted-or-error rejecting, which is what stops workspace/versions.cljs from building a snapshot out of unsaved edits. Releasing at failure time would make waiters resolve instead. Permission loss is treated as permanent too, which the previous exception got wrong. The backend raises :not-found for a missing edit permission, so a resend does not succeed — it routes the user to the exception page. The old branch made that reachable: it resumed the save whenever the cached can-edit flag said yes, and the server disagreed. The only case where the flag can return to true is a team switch, where the queue is gone anyway, so nothing real is lost by releasing. Because the queue is shared, dropping the failed file is also what lets the other files' pending edits drain. One file that cannot be saved no longer blocks everything else. Tests: the failure retains the queue and notifies once, recovery releases it without replaying, a failed file does not block a second one, permission loss is released rather than resumed, and initialization leaves a halt alone. AI-assisted-by: space-bunny-free * ✨ Offer a reload on the save failure toast A terminal save failure stops autosave for the whole session, but the toast asked the user to reload without offering the reload. It listed five implied actions, none of them reachable from the banner, and it never said that the changes could not be saved. flash now takes a :links vector of actions, appended after the download link that :report-link? produces, so a caller can add recovery actions without reaching into the report handling. flash-persistence passes a reload built from labels.reload-page and location.reload, the pattern already used elsewhere in the app for a full restart. The message now says what happened, what is lost, and what to do about it: autosave has stopped for this session, the changes since the last successful save cannot be saved, and either reloading or leaving the file and opening it again restarts it. Naming both matters, because the toast links are easy to miss. Saying the edits are lost is not optional: recovery releases them, so without it the user finds out when they come back to the file. Translated in en.po and es.po only. The translations script would also normalize all 40 locales and drop entries that were already stale, which is a maintenance pass of its own. The reload callback cannot be invoked under the test runner, since js/location does not exist there, so it is covered by its label and by being callable. Whether the reload works is a manual check. AI-assisted-by: space-bunny-free * 🐛 Classify the responses the backend never wrote A reply written by a proxy instead of the backend carries no Penpot error code, so handle-response raised it as :internal. A 504 on a save skipped the retry cycle and stopped autosave at once, and because :internal reports as unhandled, it raised an alert. In a standard deployment this is how a slow save ends: nginx answers 504 after 300s, before the client's 7 minute timeout fires. The status now decides the type: - 504 and the Cloudflare 520-524 family raise :gateway-error, which joins retryable-types. Saves retry with backoff like 502 and 503, GETs retry, and a save that spends its retries reports as handled, because the report rules derive from the same set. - 429 raises :rate-limit, and any other error status :unexpected-response. Neither retries; both show a toast instead of the error page. - :internal is kept for a response below 400 that does not decode. Bodies we cannot read are cut to 500 characters, since proxy error pages run to tens of kilobytes. Tests: classification and retries per status, the transport handlers keep the page, a 504 and a 524 built by the real handle-response go through the save path and land on a retry, and the new types stay handled once the retries are spent. AI-assisted-by: claude-opus-5-5 * ✨ Keep retrying failed saves on a slow cycle Once the three backoff retries were spent, a save that failed on the connection stopped autosave like any terminal failure, and opening a file again dropped its edits. A connection that comes back after a minute lost the work all the same. A transport failure that outlasts the backoff now arms a slow cycle: one silent resend every 30s, until a save lands or 24h have passed since the failure started. The cycle stops when persistence restarts, the queue resumes another way, or the workspace closes. A save that keeps failing slowly is not resent for a day. A gateway that answers 504 after its own timeout may leave the backend applying the save, so each resend can cost the whole save again, and the failing file holds back every other file's queued edits. An attempt that fails after 90s or more counts as a slow failure; at three, the slow cycle stops sending the commit and the save fails as before. Fast failures, such as being offline, never count. - The first failure shows a toast that says the changes are kept and keep being sent, with no reload link, since a reload loses them. A save that lands hides it. - Opening a file sends a queue still inside its retry window once more instead of releasing it. Failures outside the window, and every non-transport failure, are still released. - A freshly loaded file gets back the queued edits of its version, so reopening it shows the changes that are still waiting to be saved. An edit that no longer applies to the loaded data leaves the file as the server sent it, so the file still opens; the edits stay queued. - A commit whose earlier request is no longer in flight is simply sent again under the same commit id: changes are idempotent, so there is no unknown-outcome guard any more. A request still in flight still blocks a parallel send. flash takes a :tag so the save failure toast can be hidden by tag. Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal failure does not, the cycle stops when the window closes or at the slow failure limit, recovery resends instead of releasing, reopening restores only the loaded file's edits and survives one that no longer applies, and the retrying toast offers no reload. * 🐛 Turn off the leave-page warning when failed edits are released The leave-page warning only followed the queue on appends and discards. Opening a file again releases the edits of a file that failed for good, but that release changed the queue without either event, so the warning stayed on with nothing left to save. Leaving the page asked about unsaved changes until the next save went through. The warning now also follows the release. Tests: releasing the last failed edit turns the warning off. * 🐛 Stop retrying failed saves outside the workspace Leaving the workspace was meant to stop the slow retry cycle, but only a cycle already armed stopped. A backoff timer kept firing after the user left, and a backoff spent outside the workspace armed a slow cycle that kept sending for up to a day from the dashboard. Those sends check the edit permission of whatever team is open, so a switch to a team where the user only views failed the queued edits for good, and the next file they opened dropped them. Leaving the workspace now suspends retries. A pending backoff timer goes stale, and while suspended a transport failure schedules no backoff, a spent backoff arms no slow cycle and the online event sends nothing. The edits stay queued and a request in flight still lands. Opening a file lifts the suspension and resends a retrying save whose backoff was cut short; a role change does not lift it. Tests: leaving stops a pending backoff, a failure after leaving schedules no retry and ignores the online event, a backoff spent outside the workspace arms no slow cycle, and opening a file saves the held edit in each case. --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> |
||
|
|
c74d415ddd
|
🐛 Stop oversized audit events from wedging the archive queue (#12252)
* 🐛 Stop the export-shapes modal event from dumping all exports The `:export-shapes` modal event carried the full `:exports` vector, each export embedding its shape (path data, geometry, text content). One event reached 1.7 MB, which made the audit archive cron POST a chunk past the proxy body limit and wedge the queue. Build a compact body for that modal instead: total exports, distinct shapes and one count per export type, plus the page name. Other modals keep their props unchanged. Move the modal `:origin` prop to `::ev/origin` so `make-proto-event` records it on the event context as `:event-origin`; the UI still reads the plain prop from the raw state. Add tests for the summary and for the modal origin. AI-assisted-by: deepseek-v4.1-flash * 🐛 Bound exception messages in reports and audit events A frontend crash whose message embeds a whole payload (a serialized file) produced a 9.4 MB audit event: the message was repeated in the `:hint` prop, in the report context and in the stack trace. A single event that big wedged the audit archive queue. Bound exception messages at the source: `get-hint` clamps to `max-message-length` (1000 chars), and the CLJS `format-throwable` clamps the message line of the stack trace and honors `:trace-length` (parity with the CLJ version). `report-context` now prints `get-hint` instead of the raw `:hint` from `ex-data`. Fix `first-line` on the JVM: `cuerdas/index-of` returns nil when the substring is missing and `(pos? nil)` throws there (it is false in CLJS), so single-line messages crashed the backend path. AI-assisted-by: deepseek-v4.1-flash |
||
|
|
0c0336da42 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
473a59b134 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
be17fabc6a |
🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
across dashboard and workspace navigation within one browser tab.
Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
cleanup of listeners, timers and UI resources. Global scope does not
imply automatic startup or persistence across a browser reload.
Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
workspace, while user, theme and UI facilities remain available on
the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
defaulting to the current team and resolving only once the requested
file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
reacquire file, page and shape references after changing files.
MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
and each tab connects explicitly through dashboard or workspace
controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
during navigation; disconnect on request and close on disable/logout.
MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.
Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
document or iframe reload, preserving facade identity and storage.
AI-assisted-by: gpt-6
|
||
|
|
6defe26a78 |
🐛 Keep per-side stroke widths out of inspect geometry (#12055)
Remove per-side stroke widths from both Inspect geometry panels so Stroke is the only section that lists them. Keep dimensions, position, corner radii, and transforms in Size and position. Add rendered-component regressions for boards, rectangles, multiple selection, uniform strokes, and CSS declarations. Closes #12026 AI-assisted-by: GPT-6 Signed-off-by: Danial Shirali <danialshirali16@gmail.com> Signed-off-by: Eva Marco <eva.marco@kaleidos.net> Co-authored-by: Eva Marco <eva.marco@kaleidos.net> |
||
|
|
0d8bcf9637
|
🐛 Fix crash when drawing (#12101) | ||
|
|
9abcfebf60
|
✨ Run binfile import and export as durable jobs (#12056)
* ✨ Run binfile import and export as durable jobs Import and export of `.penpot` files become durable jobs: two commands create them and answer at once with a job id the caller follows, while a worker of the `binfile` queue does the work. Each job keeps its own ledger (owner, params frozen at creation, result, progress history and an expiry), and the legacy commands and their SSE keep working. Why: both operations ran inside the request. A large import held it open for minutes, progress only existed while the caller kept the stream open, and a client that went away left work nobody recorded, retried or could cancel. The unified `job` substrate already dispatched, leased, retried and logged events, but it had no user-facing ledger. How: - The substrate gains the pieces a user-facing job needs: optional job-def metadata (family, resource-role), an expiry on submit, the owner in the runner context, and job resources in their own bucket, written with the profile of the job and never deduplicated. - One generic command per family: the caller names the job and sends its business params, the registry checks the family, the job-def decodes and validates, and permission and quota are checked first. - Progress taps of the core become milestones of the job: a shared stage vocabulary with self-contained counters, throttled heartbeats, and an interrupt that stops the run inside its own transaction on cancel. - Cancel reaches pending and running jobs from both modals, and closing a modal mid-run cancels the job it was following. - The export re-checks the read permission at run time and answers with the package descriptor; the import re-checks the edition permission, runs the core in a transaction so a cancellation rolls it back, and releases the consumed package and its temporary copy. AI-assisted-by: deepseek-v4.1-flash * ♻️ Handle externally-cancelled binfile jobs end to end The `cancelled` outcome of a job nobody followed no longer hangs its screens: the data layers emit an explicit terminal message for it. - The export emits `{:cancelled true}` per file and the modal paints a neutral cancelled row instead of success; a message without an artifact never reaches the download branch. - The import closes every entry with a dedicated cancelled message through the existing error rendering, so the wizard completes. - Both flows are pinned by data-layer tests, including the red state they replace. Also in this change: the progress-contract comment of the management API, early business-params validation in create-import-job (before assembling the upload), a `release-resource` helper without fabricated job ids, a note on the heartbeat write cost, and a memory cleanup. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Pre-translate the cancelled import message The cancelled entry of the import wizard carried its message as a key for the UI to translate, which needed a rehash marker comment. Like its neighboring errors, it now translates eagerly in the data layer: the literal call keeps rehash clean with no marker, and the tests assert through the same call instead of the raw key. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Move cancelled strings under generic labels keys The dashboard-specific cancelled key never shipped: before use it moves under `labels.*` next to the generic strings both flows already use, with Spanish translations following the established wording. AI-assisted-by: muse-spark-1.3-contributor * ✨ Add minor improvements * ♻️ Rename export job command to create-export-binfile-job Export now has one command per job type, the shape the :export-assets command needs: create-export-binfile-job freezes exactly the :export-binfile job, so :name no longer travels in the body and only :params does. Import keeps the generic envelope. submit-job reads the queue from the job-def metadata ::jobs/queue-name (falling back to :binfile), read by the command and never by the substrate, so a job-def can route its work to another queue without a change in the command. The rename reaches the frontend and the tests: the binfile export is asked for under its own name, and the exporter service keeps create-export-job. AI-assisted-by: deepseek-v4.1-flash * ✨ Polish the import and export status rows The status row of a file already carries what the file is doing, so the generic "Uploading file…"/"Downloading file…" lines are gone: the import wizard drops its own (the export one went with the jobs refactor), along with the now-unused style. In the export, every file is queued up front, even the ones whose job is created only after the previous one is over, and a file stays queued until its first milestone: a worker picking the job up no longer ends the queue. The queued text is a step smaller than the milestone. AI-assisted-by: deepseek-v4.1-flash * 🐛 Show the cancelled export and fix the import error The cancelled export was unreachable: mark-file-cancelled sets :loading false and the label lived inside the loading branch, so a cancelled file only painted a neutral notification. The terminal state now carries the label, and the export flags lose the `?` suffix (predicates keep it; a data prop is not a question). The import wizard rendered (:error entry) through tr, but every producer writes user-facing text (a hint or a translated label), never a key: it renders the text as-is now, and the dynamic-key ignores and the stale comment are gone. The total-text-limit comment no longer references the 200MiB it replaced; the value is 500MiB in develop already. AI-assisted-by: deepseek-v4.1-flash * ♻️ Reflect the real job params in the creation commands The creation commands declared their params as an open empty map, so the RPC contract and the generated docs said nothing about them. Each command now imports the schema from the job namespace (`export-binfile/schema:params`, `import-binfile/schema:create-params`), and the RPC validates the business params before the command runs. The job-def still owns the params and re-validates the assembled ones at run time. The commands no longer decode the params again: the RPC did it. Import joins export as one command per job type: `create-import-job` becomes `create-import-binfile-job`, its body no longer carries `:name` and the job-def is resolved by name like export's. Its creation schema keeps the version optional, because the command reads it from the package header when the caller does not say it, and fills the manifest metadata kept for audit. A version out of range is now refused by the schema (`:params-validation`) instead of the command's own `:unsupported-version` check, which is gone. AI-assisted-by: deepseek-v4.1-flash * 🐛 Stop the test system from running job runners The test system is main/system-config plus main/worker-config with a few components dissoc'ed. The runners of default, webhook and cron were listed by hand; the binfile runner added with the durable binfile jobs was not, so a real penpot/job-runner/binfile/0 thread ran during the whole suite. That runner races the tests that drive the loop by hand (run-batch plus run-worker-loop): it takes the job the test just dispatched and completes it, and the test then reads a state it did not produce. That is the reported flake of binfile-jobs-integration-test, whose middle assertion waits for a runner of another queue to not take the job. It only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant baked into main/worker-config differ from the one the test builds, so the two used different queue keys locally. Drop every [<profile> :app.worker/runner] by key shape instead of listing the known queues, so a new queue cannot bring the race back. AI-assisted-by: deepseek-v4.1-flash * ✨ Upload the package one chunk at a time and as a percentage The upload milestone counts chunks, not units of work, so the row wrote "Uploading the package 3/9" over a file the user had uploaded once: it read as if nine packages were on their way. Write the stage as a percentage of the file instead ("Uploading file… (33%)"), and make the English text of the stage say file, not package. The chunks also go one at a time now (concat-all instead of merge-all with two in flight), so the percentage moves in a straight line instead of jumping. upload-blob-chunked is the generic uploader, so media and font uploads become sequential too. AI-assisted-by: deepseek-v4.1-flash * 🌐 Complete the Spanish catalogue es.po was 25 keys short of en.po: the 15 that the durable binfile jobs introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps that predate them (errors.connection-error, errors.save-retrying, the find shortcuts, workspace.header.retrying, workspace.tokens.stroke-width and the mcp session labels). sync -l es then sorts the entries, syncs the #: comments from en and drops the 7 dashboard.import.progress.* keys en no longer has. The msgid sets of en and es are now identical (0 missing, 0 extra). AI-assisted-by: deepseek-v4.1-flash * ♻️ Move the job status keys into the jobs namespace The three status words the durable import and export rows render were filed under labels.*, next to the generic labels bucket, while their progress stages live under jobs.progress.stage.*: the strings of one feature sat in two places. Rename them so the whole job vocabulary is one namespace: labels.queued -> jobs.queued labels.export-cancelled -> jobs.export-cancelled labels.import-cancelled -> jobs.import-cancelled No other locale has these keys yet, so the rename costs no translation. The convention (a key names the feature that renders it; labels.* holds the generic reusable words) is written down in the frontend/translations memory. AI-assisted-by: deepseek-v4.1-flash * 🔥 Remove the translation keys nothing references Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...) call site left in frontend/src or common/src, and no literal reference either: labels.uploading-file a duplicate of jobs.progress.stage.upload since the upload stage says "Uploading file…" labels.downloading-file dead since the durable jobs replaced the old import and export progress rows inspect.attributes.stroke.alignment.center inspect.attributes.stroke.alignment.inner inspect.attributes.stroke.alignment.outer workspace.toolbar.mcp-connect-here The first two are leftovers of the durable binfile jobs, the other four predate them. Both catalogues keep an identical msgid set afterwards. AI-assisted-by: deepseek-v4.1-flash |
||
|
|
051f01d559
|
🐛 Keep per-side stroke widths out of inspect geometry (#12055)
Remove per-side stroke widths from both Inspect geometry panels so Stroke is the only section that lists them. Keep dimensions, position, corner radii, and transforms in Size and position. Add rendered-component regressions for boards, rectangles, multiple selection, uniform strokes, and CSS declarations. Closes #12026 AI-assisted-by: GPT-6 Signed-off-by: Danial Shirali <danialshirali16@gmail.com> Signed-off-by: Eva Marco <eva.marco@kaleidos.net> Co-authored-by: Eva Marco <eva.marco@kaleidos.net> |
||
|
|
a5df69f60f | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
2f47be6c15
|
🐛 Show the size badge on paths and straight lines (#12066)
* 🐛 Show the size badge on paths and straight lines The badge was hidden when the shape was smaller than the badge in either side. Flat paths have a zero width or height, so they never showed it, and straight two-point paths hid it on purpose. Paths now only compare their longest side with the badge width. A straight line compares its length, and the badge runs along the line, rotated with it and kept upright. Near the bottom of the viewport the badge moves to the other side of the line. The badge now reads the size from the shape :selrect, like the measures inputs, so a rotated path shows the same values in both places. The three badge branches share a single render block. AI-assisted-by: claude-opus-5-5 * ♻️ Extract the size badge layout and add tests for it Move the badge geometry out of selection-size-badge* into the pure size-badge-layout function, so it can be tested without rendering. The component now only picks the color and draws the result. Add unit tests for rects, flat paths and straight lines: when the badge hides, where it sits, how it rotates and how it moves when it reaches the bottom of the viewport. Keep the line rotation between -90 and 90 degrees instead of returning 360 for lines drawn leftward. The e2e test still expected straight lines to hide the badge. It now checks that the badge shows and follows the line angle. AI-assisted-by: claude-opus-5-5 * ✨ Show the length of straight lines in the size badge A straight two-point path is measured by its length, not by the box around it, so its size badge now shows the length with two decimals at most instead of width x height. Other shapes and paths keep width x height. The shorter text makes the badge narrower, so it also shows on shorter lines; the rule is the same, the badge needs the line to be at least as long as the badge. Relates to #12064 AI-assisted-by: claude-opus-5-5 * 🐛 Hide the path size badge when the path is lower than it Paths other than a straight line only hid the size badge when their longest side was narrower than it, so a wide but low path, or a narrow but tall one, still showed it. Flat paths no longer need that special case, since straight lines get their own length badge. Use the same rule as any other shape: hide the badge when either the width or the height of the path is smaller than the badge. AI-assisted-by: claude-opus-5-5 |
||
|
|
746184caf9 | Merge remote-tracking branch 'origin/staging' into develop | ||
|
|
582f943228
|
🐛 Show stroke width tokens in the inspect stroke panel (#12096)
Since stroke widths can be set per side, stroke width tokens are stored in the four per-side attributes, not in :stroke-width. The inspect stroke panel still read :stroke-width for Border width and had no token lookup for the per-side widths, so it showed the raw value instead of the token. Read the token of each side for the per-side widths, and the token shared by every side for Border width. Files that are not migrated yet keep the token in :stroke-width, so Border width still falls back to it. Closes #12029 AI-assisted-by: claude-opus-5-5 |
||
|
|
b06a43404b
|
🎉 Add detach to typography and shadow tokens (#11969)
* 🎉 Add detach to typography tokens * 🎉 Add detach to shadow tokens * 🎉 Add tests |
||
|
|
7c039231f7
|
🎉 Implement paste of basic HTML formatting (v3) (#12050)
* ✨ Add rich HTML paste to the v3 text editor Pasting HTML into the v3 text editor keeps bold, italic, decoration and text-transform, and drops fonts, colors and links. Gated behind text-editor-wasm/v1-html-paste. Refs #10465 AI-assisted-by: claude-opus-5-5 * ✨ Paste HTML on the canvas as a styled text shape With v3 HTML paste on, pasting HTML on the canvas creates a text shape with the same emphasis. Clipboard options are now read at paste time. Refs #10465 AI-assisted-by: claude-opus-5-5 |
||
|
|
822dbd6517
|
⚡ Speed up sidebar menus for large selections (#12028)
Reuse extracted colors for unchanged shapes and cache the text attrs read from each text content. |
||
|
|
1554847d40
|
⚡ Compute the drop ignore tree in a single pass (#12033)
Walk only from the top-most transformed shapes, carry the component root down the walk and memoize each shape transform so the update step reuses it. AI-assisted-by: claude-opus-5-5 |
||
|
|
09ffcb1fbc
|
⚡ Reduce render-wasm GPU memory and move cost on large pages (#11980) | ||
|
|
60679bbbcd
|
🐛 Revert RTL auto-width text growing from its right edge (#12051)
Revert #11775 (commit 142f3d9de8). We are putting this fix on hold until we settle the UX for RTL auto-width text. Relates to #11523 |
||
|
|
b5fbc4fd8c
|
✨ Add size limits to profile props and plugin registry (#11596)
* ✨ Add size limits to profile props and plugin registry Bound the total serialized size of profile settings to 2 MiB (:profile-props-max-size), checked on the merged result before persisting, with a controlled :props-too-large error. Profiles that already exceed the limit can still shrink but cannot grow. Cap plugin registry entries in the shared schema (code 1 MiB, 50 plugins max, bounded name/host/description/icon) and restore rate limiting on the plugin RPCs (profile-mutations bucket, one write at a time per profile). The plugin manager now asks for confirmation before removal and ignores repeated clicks while a persist request is in flight. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Enforce plugin count cap, byte sizes and removal guard Enforce the declared 50-plugin cap in add-profile-plugin with a specific :too-many-plugins error (updates of existing entries still pass); the cap lives in a shared max-plugins constant. Measure profile props size in UTF-8 bytes instead of chars so multibyte content cannot slip past the limit. Cover install/remove persist logic with mocked-RPC frontend tests (release semantics, in-flight dedupe, validation vs rollback split) and add the missing boundary tests in common. Expose the in-flight persist set from the plugin registry and disable the remove button of entries being saved. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix rollback loops and restore paths in plugin registry Restore the previous plugin version instead of dropping the entry when a validation error rejects an update of an installed plugin. Make compensating writes one-shot with terminal callbacks so a persistent failure cannot ping-pong between install and remove. Restores keep the original list position; the unused public plugin-persisting? predicate is removed. Pin count-before-size precedence with a dedicated test and fix translation source refs to their canonical lines. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Guard notifications write and fix restore ordering Route update-profile-notifications through check-props-size! so oversized profiles cannot grow through that path; document the exempt system writers. Remove the duplicated stale entries in en.po, keeping the canonical translation refs. Restore rejected plugin updates at their original list position instead of leaving the optimistic move in place. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Skip no-op plugin removal and clarify size comments Return early from remove-profile-plugin when the id is absent: no wasted write, no size check, and no manufactured :plugins key that could spuriously fail on oversized profiles. Clarify that per-field string caps count chars while the byte budget is enforced by profile-props-max-size. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 📎 Fix formatting in rlimit.edn for profile operations Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Fix formatting of import-binfile/global entry Signed-off-by: Andrey Antukh <niwi@niwi.nz> * ♻️ Simplify props size check and tighten plugin entry caps Measure props with transit bytes directly instead of the PGobject string roundtrip. Rename check-props-size! to check-props-size: single hard limit on the merged props, no growth comparison, and return props so writers thread the check into the update. Move the 2 MiB default into default-props-max-size on the profile namespace, still overridable with the optional :profile-props-max-size config entry. Tighten registry-entry :code and :icon to 500 chars: they hold manifest paths, not content. Closes #11592 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix compatibility problems --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz> Co-authored-by: alonso.torres <alonso.torres@kaleidos.net> |