Compare commits

...
313 Commits
Author SHA1 Message Date
Andrey Antukh 1006efc571 🐛 Fix translation strings 2026-07-01 14:38:08 +02:00
Andrey Antukh 0dee6e3cb0 📚 Add let binding algnment info to serena 2026-06-26 14:32:32 +02:00
Andrey Antukh 925dca35ab 📚 Update common testing doc on .serena 2026-06-26 14:28:49 +02:00
Andrey Antukh 736a22ab1d 📚 Add paren-repair script for automatic parentheses repair 2026-06-26 14:28:15 +02:00
Andrey Antukh 8645801eed 📎 Update changelog 2026-06-25 11:33:59 +02:00
Andrés Moya 4d1706f71c 🐛 Fix error when sending user feedback form (#10419)
If the smtp-default-from is something like "Penpot
<no-reply@penpot.app>", the schema validation fails because
it expects a simple email without < >
2026-06-25 10:37:50 +02:00
Alejandro Alonso b096832bf5 🐛 Fix v2 text editor detaching typography tokens (#10402) 2026-06-24 18:20:21 +02:00
Andrey Antukh 27274a56fd 📎 Update changelog 2026-06-24 11:01:38 +02:00
Andrey Antukh 9391535f48 📎 Add minor enhancement to update-changelog skill 2026-06-24 11:01:14 +02:00
Andrés Moya 5042a34e3c 🔧 Normalize text nodes comparison, to be used in tokens detach
🔧 Add more tests for all cases and fix text token application in tests
2026-06-23 12:24:30 +02:00
Andrey Antukh d8434cbffb 🐛 Add missing migrations (#10363) 2026-06-22 13:26:21 +02:00
Alejandro Alonso 3a9be0b1d8 Merge pull request #10358 from penpot/niwinz-bugfix-1
🐛 Fix incorrect events handling on webgl render toggle
2026-06-22 12:54:13 +02:00
Andrey Antukh 3b9a895f62 🐛 Fix incorrect events handling on webgl render toggle
From the workspace main menu
2026-06-22 11:29:57 +02:00
Andrey Antukh 960b90253f ⬆️ Update root deps 2026-06-22 09:04:13 +02:00
Andrey Antukh d8894abcbc 📎 Update changelog 2026-06-22 09:04:01 +02:00
Andrey Antukh d3bec95860 🐛 Allow pasting comma-separated emails in multi-input (#10186)
The multi-input component did not handle paste events for
comma-separated values. When users pasted emails like
'qa@example.com, test@example.com', the entire string was
inserted as-is, triggering validation errors.

The on-key-down handler already split text on commas/spaces
when typing, but paste events bypassed this logic.

Added an on-paste handler that:
- Detects if pasted text contains commas or whitespace
- Splits the text by commas and/or whitespace
- Validates each part individually
- Adds valid items to the items list
- Prevents default paste behavior
- Resets input state after processing

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-19 09:38:00 +02:00
196e47fa93 Backport MCP related changes from develop (#10315)
* ♻️ Add mcp integration state management refactor (#10226)

* ♻️ Add mcp integration state management refactor

* 🐛 Fix access tokens do not appear

* ♻️ Refactor some names

* ♻️ Refactor token deletion

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>

* 🐛 Fix stale MCP token data after create/regenerate (#10280)

Fix the root cause in profile.cljs: remove the optimistic conj from
access-token-created and instead chain a fetch-access-tokens after the
create-access-token API call succeeds. This ensures all callers get a
fresh, server-consistent token list automatically.

Suggested-by: niwinz

Signed-off-by: kapilvus <kapil69265@gmail.com>
Co-authored-by: kapilvus <kapilvus@gmail.com>

*  Remove non-recoverable mcp key warning from regenerated modal (#10298)

---------

Signed-off-by: kapilvus <kapil69265@gmail.com>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Co-authored-by: kapil971390 <kapil69265@gmail.com>
Co-authored-by: kapilvus <kapilvus@gmail.com>
2026-06-18 18:08:40 +02:00
Andrey Antukhanddeepseek-v4-flash 71f5c11a11 Qualify MCP Redis channel names with tenant prefix
Read PENPOT_TENANT env var (defaulting to "default") and embed it in
Redis Pub/Sub channel names as penpot.mcp.<tenant>.task.{req,res}.<id>.

This prevents cross-tenant interference when multiple environments share
a Redis instance, matching the backend convention
(e.g. penpot.rlimit.<tenant>.window.<name> in app.rpc.rlimit).

Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>
2026-06-18 09:22:29 +02:00
Eva Marco 895c9cb8da 🐛 Fix tokens fonts combobox to show resolved value 2026-06-17 21:18:08 +02:00
Andrey Antukh a2e69db265 ⬆️ Update deps 2026-06-17 21:18:08 +02:00
Andrey Antukh fe9598f96c Merge remote-tracking branch 'origin/main' into staging 2026-06-17 19:13:16 +02:00
Andrey Antukh 594bbf9dd6 📎 Update pr and commits worflow on serena 2026-06-17 19:12:26 +02:00
Andrey Antukh cc4cdff729 🔥 Remove duplicated github workflow 2026-06-17 00:05:07 +02:00
Andrey Antukh b645f51486 Backport .github directory from develop 2026-06-17 00:03:25 +02:00
Andrey Antukh 0338655cd0 📎 Add frontend pnpm-lock.yaml dedup 2026-06-17 00:02:07 +02:00
Andrey Antukh c9f9bd5029 📎 Use same playwright version on all frontend subpackages 2026-06-16 23:22:19 +02:00
andrés gonzález 565e173918 📚 Update troubleshooting WebGL doc (#10233) 2026-06-16 16:03:14 +02:00
Andrey Antukh 3dff9b8f28 Merge remote-tracking branch 'origin/main' into staging 2026-06-16 13:07:40 +02:00
Alejandro Alonso 92c9517322 Merge pull request #10184 from penpot/azazeln28-fix-frame-stroke
🐛 Board stroke disappears when a layer is placed inside
2026-06-16 09:32:14 +02:00
Aitor Moreno c54ab3bd34 🐛 Fix frame stroke 2026-06-15 16:09:23 +02:00
Andrey Antukh 61c52a665d ⬆️ Update dependencies (#10166)
* ⬆️ Update exporter dependencies

* ⬆️ Update frontend dependencies

* ⬆️ Update nodejs version devenv and docker images
2026-06-15 12:03:59 +02:00
Andrey Antukh 79227c4de8 Batch multiple thumbnail deletions into a single RPC call (#9943)
*  Batch multiple thumbnail deletions into a single RPC call

Replace the old per-object immediate thumbnail deletion with a
debounced batched approach. The frontend queues object-ids in state
and waits 200ms before sending a single RPC request with up to 200
object-ids. The backend deletes all matching thumbnails in one SQL
statement with a single RETURNING clause, then touches the affected
media objects.

This reduces RPC overhead when rapidly clearing thumbnails (e.g.
navigating pages) and makes deletions more efficient.

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

* 📎 Fix missing issues

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-15 12:01:32 +02:00
Juan de la CruzandLuis de Dios ddeaf3ce2a Add MCP status button to workspace toolbar with single-tab connection control (#9930)
*  Add MCP connection badge to the workspace toolbar

*  Add MCP status button with single-tab connection control

* ♻️ Extract component for MCP indicator in the toolbar

* ♻️ Some improvements

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-06-15 11:29:25 +02:00
David Barragán Merino 6a1c3348f3 🐳 Clean apt package cache in all Dockerfiles 2026-06-15 11:13:12 +02:00
David Barragán Merino fe86359dc8 🐳 Migrate storybook image to alpine 2026-06-15 11:13:12 +02:00
David Barragán Merino 572b094bab 🐳 Migrate frontend image to alpine 2026-06-15 11:13:12 +02:00
Luis de Dios 739a2d4958 💄 Update translations for company size options in in-app onboarding (#10164) 2026-06-15 10:57:08 +02:00
Andrey Antukh a8d0c18c1b 🐛 Fix race condition between MCP initialization and plugin runtime (#10137)
* 🐛 Fix race condition between MCP init and plugin runtime

Add promise-based synchronization to ensure MCP initialization waits
for plugin runtime to be ready before calling global.ɵloadPlugin.

- Add runtime-ready-promise in app.plugins that resolves when
  init-plugins-runtime completes
- Add wait-for-runtime function for other modules to await readiness
- MCP init now waits for runtime via rx/from before starting plugin
- Add defensive guards in start-plugin!, load-plugin!, close-plugin!
  to check if plugin APIs exist before calling
- Rename init-plugins-runtime! to init-plugins-runtime

Fixes: global.ɵloadPlugin is not a function error when MCP plugin
starts before async plugin runtime initialization completes.

* 📎 Add 'create-pr' opencode skill
2026-06-12 11:40:02 +02:00
David Barragán Merino 7cb7f7adb2 🐳 Add command to upgrade packages during the creation 2026-06-12 09:34:26 +02:00
Justin Lin 9c7af3aeb5 📚 Add WebSocket proxy configuration for MCP in Nginx example (#10152)
The current Nginx example doesn't include the required Websocket settings to correctly proxy /mcp/ws, and leads to WebSocket connection error when trying to connect in a design. Adding this lines fixed the issue.

Signed-off-by: Justin Lin <30039756+lancatlin@users.noreply.github.com>
2026-06-12 08:55:23 +02:00
Andrey Antukh 2e1839f898 🐛 Fix NotReadableError in rasterizer during thumbnail generation
The rasterizer's create-image function was clearing image.src in its
Rx teardown cleanup. This caused the decoded pixel data to be discarded
before downstream operators (drawImage / createImageBitmap) could read
it, resulting in a browser NotReadableError.

Changes:
- Remove image.src = "" from cleanup; the image element will be
  garbage collected naturally. Event handler nulling is kept to break
  circular references.
- Add dimension validation in svg-get-adjusted-size to return nil for
  zero/NaN dimensions instead of producing invalid sizes.
- Add fallback in svg-set-intrinsic-size! to use [max max] when SVG
  dimensions can't be determined.

Error occurred in production (2.16.0-RC10) during thumbnail generation
in the workspace.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-11 17:04:24 +02:00
Andrey Antukh 5f21ebd08d 🐛 Filter ignorable React removeChild errors at error boundary and fix HTML anti-pattern (#10145)
* 🐛 Filter ignorable exceptions in error-boundary onError callback

The global uncaught-error-handler already skips NotFoundError/removeChild
and other harmless errors, but react-error-boundary's onError callback fires
independently of the window.onerror pipeline. This means the error boundary
was still logging these errors and setting last-exception, causing them to
continue appearing in error reports despite being non-actionable.

Add the is-ignorable-exception? check to the error-boundary* onError so
harmless errors are silently ignored, matching the behavior of the global
handler.

* 🐛 Fix dangerouslySetInnerHTML anti-pattern in context-notification

The previous code used dangerouslySetInnerHTML on the same element that
could also contain React children. This is a React anti-pattern that can
cause reconciliation mismatches and lead to removeChild DOMExceptions.

Refactor to use two separate element branches: one for raw HTML injection
and one for normal React children with links.
2026-06-11 16:59:00 +02:00
David Barragán Merino b2c7854f5e 🐳 Pin docker images to 2.16 2026-06-11 16:48:24 +02:00
David Barragán Merino ab614d2ae9 🐳 Pin docker images to 2.16 2026-06-11 16:47:35 +02:00
David Barragán Merino 11096f1829 🔧 Remove the confirmation step for publishing docker images 2026-06-11 16:26:36 +02:00
David Barragán Merino b1ec03a5d2 🔧 Remove the confirmation step for publishing docker images 2026-06-11 16:26:18 +02:00
Andrey Antukh 715bd1c09e 📎 Update mcp server api_types.yml file 2026-06-11 14:51:02 +02:00
Andrey Antukh 6827c4554b 📎 Update version on mcp package.json 2026-06-11 14:49:48 +02:00
Andrey Antukh 8b16326334 📎 Update changelog 2026-06-11 14:49:09 +02:00
Andrey Antukh 86d508eaf1 Merge remote-tracking branch 'origin/main' into staging 2026-06-11 13:24:57 +02:00
Luis de Dios 331e66c1c6 Update company size options in in-app onboarding (#10110) 2026-06-11 13:23:41 +02:00
Alejandro Alonso 38d12b3363 Add hard reload path for renderer menu A/B test (#10133) 2026-06-11 13:16:09 +02:00
Andrey Antukh 935fd85e9c 🐛 Fix exporter deps install script 2026-06-11 12:42:43 +02:00
Andrey Antukh 3a386c0ee6 Merge remote-tracking branch 'origin/main' into staging 2026-06-11 11:56:05 +02:00
andrés gonzález a8c1a673a8 📚 Add MCP server block and setup videos to docs (#10121) 2026-06-11 11:46:40 +02:00
Andrey Antukh d65e8317e3 📎 Update changelog 2026-06-11 10:41:05 +02:00
Andrey Antukh 99bf493030 📎 Update the update-changelog skill 2026-06-11 10:40:03 +02:00
Andrey Antukh 3377473f05 Merge remote-tracking branch 'origin/main' into staging 2026-06-11 10:12:12 +02:00
Andrey Antukh ba9b03268a ⬆️ Update backend and common dependencies (#10108)
* 🐛 Fix incorrect valkey uri on backend tests

* ⬆️ Update backend dependencies

* ⬆️ Update pnpm dependencies

* 📎 Fix minor linter issues
2026-06-11 10:11:23 +02:00
Aitor Moreno 17b38e3e6b Merge pull request #10123 from penpot/superalex-fix-wasm-selected-text-hover-double-click
🐛 Keep hover on selected text layers in WASM renderer
2026-06-11 09:47:50 +02:00
Belén Albeza 8eedd951a8 🐛 Fix mistmatch in mapping custom font variants (wasm) (#10122) 2026-06-11 09:36:29 +02:00
Alejandro Alonso 435cdcb1d4 🐛 Keep hover on selected text layers in WASM renderer 2026-06-11 09:23:03 +02:00
Andrey Antukh d1fb1d34a1 🐛 Fix email validation to reject consecutive dots in domain (#10096)
* 🐛 Tighten email validation regex to reject consecutive dots in domain

* 📎 Add minor adjustments to gh-issue-from-pr skill
2026-06-10 19:21:51 +02:00
Andrey Antukh fa0531cd28 Merge remote-tracking branch 'origin/main' into staging 2026-06-10 11:26:14 +02:00
Andrey Antukh 3419f7e60a ⬆️ Update root repo deps 2026-06-10 11:25:59 +02:00
Andrey Antukh 0ac092d177 🐛 Make set-file-shared idempotent to fix race condition with optimistic updates (#10093) 2026-06-10 10:58:50 +02:00
Alejandro Alonso becba0a82b 🐛 Fix frame inner strokes ignoring focus mode in wasm renderer 2026-06-10 09:36:28 +02:00
Andrey Antukh f7c5ce7ac9 Merge remote-tracking branch 'origin/main' into staging 2026-06-09 14:38:48 +02:00
Andrey Antukh e0a44eede0 Backport serena memory and other minor config fixes from develop 2026-06-09 14:37:59 +02:00
David Barragán Merino 82acec1191 📎 Set pnpm version on docs/package.json 2026-06-09 13:50:00 +02:00
David Barragán Merino 11a8d08f95 📎 Set pnpm version on docs/package.json 2026-06-09 13:28:54 +02:00
Andrey Antukh 27ba1ffbe0 📎 Update version on mcp/package.json 2026-06-08 14:38:47 +02:00
Andrey Antukh c7fae1f353 Merge remote-tracking branch 'origin/main' into staging 2026-06-08 14:36:24 +02:00
Andrey Antukh 51a9eed02e Backport from develop AGENTS.md changes 2026-06-08 14:35:19 +02:00
Andrey Antukh 0e16db66b8 Backport from develop AGENTS.md changes 2026-06-08 14:34:31 +02:00
Andrey Antukh eff533374d 🐛 Ignore Safari browser extension errors in error handler
Add detection for Safari's webkit-masked-url:// extension URLs and filter
the "Attempting to change value of a readonly property" TypeError to prevent
Safari browser extension errors from being surfaced to users.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-08 14:32:01 +02:00
Andrey Antukh 82cfbedc26 Merge remote-tracking branch 'origin/main' into staging 2026-06-08 14:28:30 +02:00
Andrey Antukh c2f2e0e34b 📎 Add opencode issue-title skill 2026-06-08 14:27:07 +02:00
Andrey Antukh a326cc416e Backport github issue templates from develop 2026-06-08 14:26:45 +02:00
Alonso Torres 6808390827 🐛 Fix problem with color picker error (#10056) 2026-06-08 13:25:45 +02:00
Andrey Antukh 5426092d68 📚 Remove the requirement of changelog update 2026-06-08 12:02:28 +02:00
Andrey Antukh 4d0a3efc5c 🐛 Fix plugin API crash when setting text fills (#10051)
The `update-text-range` event's `watch` method was returning a bare
potok event object (`dwwt/resize-wasm-text-debounce id`) directly
inside `rx/concat`, instead of wrapping it in `rx/of`. This caused
RxJS to throw "You provided an invalid object where a stream was
expected" when a plugin set text fills via the Plugin API.

The fix wraps the event in `rx/of` so it becomes a valid Observable,
matching the pattern used elsewhere in the codebase (e.g.,
`clipboard.cljs` lines 1050/1082 and `texts.cljs` line 1232).

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-08 11:33:36 +02:00
Andrey Antukh 4f852e33bf Backport mcp package changes from develop 2026-06-08 09:59:33 +02:00
Andrey Antukh bae4d23c67 Merge remote-tracking branch 'origin/staging' 2026-06-08 09:40:28 +02:00
Andrey Antukh c5bd583b1f 📎 Update root deps 2026-06-08 09:39:59 +02:00
Andrey Antukh 4755ebbedf Merge remote-tracking branch 'origin/main' into staging 2026-06-05 11:49:18 +02:00
Andrey Antukh 2ad63d8887 📎 Backport .opencode directory fron develop 2026-06-05 11:49:06 +02:00
Andrey Antukh 7736104daa Merge remote-tracking branch 'origin/main' into staging 2026-06-05 11:44:36 +02:00
Andrey Antukh f457c68355 📎 Backport devenv improvements 2026-06-05 11:44:20 +02:00
Elena Torró 47ce68eed0 🐛 Fix masked group applied blur and bounds (#10028) 2026-06-05 11:01:47 +02:00
Andrey Antukh 6d77ca3fc1 Merge remote-tracking branch 'origin/main' into staging 2026-06-04 19:01:25 +02:00
David Barragán Merino 97c3a9facf 🐳 Add improvements related to Docker and Podman compatibility (#10012)
* 📎 Add tests for boolean parser coverage

* 🐳 Normalize boolean handling in nginx entrypoint

* 🐳 Quote boolean env vars in compose example (add Podman compatibility)

* 🔥 Remove deprecated and duplicated nginx.conf file for Storybook
2026-06-04 10:11:58 +02:00
Elena Torró dfa88a28fd 🐛 Fix text editor swap when WebGL render is enabled/disabled 2026-06-04 08:39:33 +02:00
Andrey Antukh 7e66929010 🐛 Fix crash when typography token value is an array (#9992)
Add guard in parse-composite-typography-value to check if the
converted value is a map before attempting map operations. When
a typography token has an array value like ["Roboto"], return
an invalid-token-value-typography error instead of crashing with
IMap.-dissoc protocol error.

Add regression test to verify the fix.
2026-06-03 16:54:40 +02:00
Andrey Antukh 2808268e52 📎 Update changelog 2026-06-03 14:36:05 +02:00
Elena Torró 0fe4337359 🐛 Fix webgl thumbnail label (#10009) 2026-06-03 14:06:05 +02:00
Andrey Antukh 3cedf11e1c 🔧 Update tests github workflow config 2026-06-02 17:24:35 +02:00
Andrey Antukh 6bf7c33c43 🐛 Fix del-page change constructed with nil id (#9990)
Guard against nil id and missing page in delete-page to prevent
broken changes from being sent to the server. This can happen due
to a race condition where the page is no longer present in the
pages-index. Also add assertion in changes-builder/del-page as
defense-in-depth.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-02 17:23:13 +02:00
Andrey Antukh a57833f3cd 🐛 Fix get-comment-threads called with empty params due to race condition (#9988)
Prevent navigate-to-comment-id from making an RPC call with nil
file-id when current-file-id has been cleared by finalize-workspace
during rapid workspace navigation.  The deferred stream observer
(rx/observe-on :async) could fire after the workspace state was
already cleaned up, causing {:file-id nil} to become {} after
query-string nil-filtering in map->query-string.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-06-02 17:22:39 +02:00
Belén Albeza e2545915b8 🔧 Fix log level of migration exceptions (#9986) 2026-06-02 16:17:22 +02:00
Belén Albeza d5fe5f82f3 🐛 Fix wasm info label positioning (#9981) 2026-06-02 15:18:37 +02:00
Andrey Antukh 3744186510 🔧 Update default nginx limit configuration 2026-06-02 14:05:21 +02:00
Belén Albeza 7fdd2ceb5c 🐛 Fix crash when dismissing the restore version modal (#9969) 2026-06-02 11:33:06 +02:00
Andrey Antukh 17fb1c49f8 Redunce the render throttling to 50ms of the layers-tree* component 2026-06-02 10:30:08 +02:00
Andrey Antukh fe9e47f947 Merge remote-tracking branch 'origin/main' into staging 2026-06-02 10:15:24 +02:00
Andrey Antukh cd18a2bcb2 📎 Update version on mcp/package.json 2026-06-02 10:13:01 +02:00
Andrey Antukh d49fa51fef Update changelog 2026-06-02 10:10:20 +02:00
Andrey Antukh 0d2e0f8367 📎 Update the update-changelog skill and gh.py tool 2026-06-02 10:09:53 +02:00
Andrey Antukh 0044e76cb4 🐛 Revert throttle timeout increase on layers pannel 2026-06-01 14:40:55 +02:00
Andrey Antukh c5de4c27b0 Merge remote-tracking branch 'origin/main' into staging 2026-06-01 12:57:39 +02:00
Alejandro Alonso 88f2366c6f 🎉 Enable render switch and wasm info by default and simplify feature helpers to use pre-computed features set (#9942)
The setup-wasm-features function is the single source of truth for
    resolving the renderer choice (URL param > profile preference > team
    flags), storing the result in state[:features]. Several helpers were
    re-deriving the same priority chain independently, duplicating logic:

    - wasm-enabled?, wasm-url-override, wasm-url-override-ref
    - enabled-by-flags?, enabled-without-migration?

    This change removes all duplicated helpers and simplifies the
    remaining functions to rely exclusively on the pre-computed
    :features set:

    - active-feature? — now just checks (contains? (:features state)
      feature) without special-casing render-wasm/v1
    - use-feature — uses the reactive features-ref for all features
    - initialize/recompute-features effects — use the local features
      binding directly

    Since :features is rebuilt by setup-wasm-features on every
    initialization and recompute, this preserves correctness while
    eliminating ~50 lines of duplicated code.
2026-06-01 12:52:34 +02:00
Aitor Moreno 2410bcb0df Merge pull request #9941 from penpot/alotor-layout-fixes
🐛 Fix layout render-wasm issues
2026-06-01 12:38:01 +02:00
alonso.torres dddb4cf0b6 🐛 Fix layout render-wasm issues 2026-06-01 10:27:59 +02:00
Luis de Dios d3148e1a10 🐛 Fix add modal confirmation when clicking restore from saved version preview (#9804) 2026-06-01 10:08:40 +02:00
Juan de la Cruz af81818b97 Add new release 2.16 slides (#9940)
*  Add new slides for the 2.16 release

* 🎉 Add new slide gif images
2026-06-01 09:59:10 +02:00
Andrey Antukh 3a4e3aaeac 🐛 Fix consistency issues on mcp readme 2026-06-01 09:36:44 +02:00
Andrey Antukh d66e8702e7 📎 Update root repo deps 2026-06-01 09:27:58 +02:00
Luis de Dios 0b56fd2f77 🐛 Fix locked flex and grid elements cannot be selected in viewer role (#9865)
* 🐛 Fix locked flex and grid elements cannot be selected in viewer role

*  Add playwright test
2026-06-01 08:30:27 +02:00
Belén Albeza a5c8bcaf9e 🐛 Fix text editor crash when switching from svg to wasm renderer (#9926)
* 🐛 Fix crash when switching renderers with text editor open

* ♻️ Use new initialized? helper in wasm api
2026-05-29 14:00:49 +02:00
Alejandro Alonso b5108ca1ad 🎉 Update wasm label (#9938) 2026-05-29 13:40:35 +02:00
Andrés Moya 7e6884e330 🐛 Fix error when copy & paste a swapped copy (#9934) 2026-05-29 13:36:17 +02:00
Eva Marco 6e8d2b3708 🎉 Add clear error messages (#9886) 2026-05-29 13:27:43 +02:00
Eva Marco 237fa568e8 🐛 Fix comments 2026-05-29 12:28:10 +02:00
Eva Marco ba39600192 🐛 Fix show error on name-input 2026-05-29 12:28:10 +02:00
Andrés Moya 05cceab768 🔧 Add small adjustments and spanish translation 2026-05-29 12:28:10 +02:00
rene0422 0efebc5e0e 🐛 Keep colliding tokens visible as broken pills 2026-05-29 12:28:10 +02:00
Andrés Moya 429103d076 🐛 Fix errors when token name conflicts with group name 2026-05-29 12:28:10 +02:00
Eva Marco 8dbbd49c0e 🐛 Fix foreground color on numeric input (#9920) 2026-05-29 11:30:19 +02:00
Yamila Moreno ddba2ffa75 📎 Update Kaleidos Copyright (#9929) 2026-05-29 11:24:58 +02:00
Pablo Alba aa996c5118 🐛 Fix nested components becomes flat after reset (#9841) 2026-05-28 15:57:35 +02:00
Andrey Antukh 3cce47094e ♻️ Refactor font upload to process variants sequentially (#9921)
* ♻️ Refactor font upload to process variants sequentially

Change the batch upload handler so fonts are uploaded one at a time
instead of all concurrently, preventing excessive simultaneous
upload requests.

Previously `on-upload-all` used `run!` which fired all font variant
uploads simultaneously. Now it uses `rx/from` combined with
`rx/mapcat` to process each font sequentially.

As part of this change, extract the upload logic into a standalone
`handle-font-upload` helper for reuse between single and batch
upload paths, and remove the separately memoized `on-upload*` hook.

Also fix error logging to use `js/console.error` instead of
`js/console.log` for consistency with project conventions.

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

* 📎 Add code comment

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-28 15:51:46 +02:00
Eva Marco 7843bb1208 ♻️ Fix regression and add test 2026-05-28 15:06:29 +02:00
Aitor Moreno 92a3030f05 Merge pull request #9904 from penpot/superalex-fix-tile-spiral-missing-column
🐛 Fix inclusive TileRect width/height for tile spiral scheduling
2026-05-28 13:34:18 +02:00
alonso.torres 111d7daa94 🐛 Fix problem with absolute elements in flex layout 2026-05-28 13:09:06 +02:00
Alejandro Alonso 6663105f4b 🐛 Avoid restoring empty saved-font-size to prevent invalid text shapes on save (#9917) 2026-05-28 12:39:43 +02:00
Alejandro Alonso 03e10fa871 🐛 Fix inclusive TileRect width/height for tile spiral scheduling 2026-05-28 10:22:22 +02:00
Alejandro Alonso 921c5a4294 🐛 Fix drag shapes out of frames (#9905) 2026-05-28 09:13:04 +02:00
Belén Albeza a278820230 Adjust viewport interest area (#9897) 2026-05-27 19:21:42 +02:00
Eva Marco 16d0ee99c1 ♻️ Update copy on context menu (#9888) 2026-05-27 13:43:31 +02:00
Andrey Antukh 95e7edc9d2 📚 Update changelog 2026-05-27 12:51:26 +02:00
Andrey Antukh 243798f458 Add improvements to 'update-changelog' skill 2026-05-27 12:51:26 +02:00
David Barragán Merino a0f869b917 🐳 Remove the configuration of the mcp from Nginx if it is not enabled 2026-05-27 12:36:26 +02:00
Andrey Antukhandalonso.torres 40ce360c99 Improve performance and fix orphan detection in validate-file (#9789)
*  Improve performance and fix orphan detection in validate-file

- Add `*ref-shape-cache*` dynamic var to memoize `find-ref-shape`
  lookups per page, avoiding repeated O(depth) ancestor walks.
- Add `*children-sets*` pre-computed maps for O(1) parent-child
  containment checks, replacing linear `some` scans.
- Short-circuit `inside-component-main?` when the shape context
  already implies a main component.
- Use single-pass reduce with early exit for duplicate detection
  (children, swap slots) instead of count/distinct or frequencies.
- Guard `check-missing-slot` to skip expensive `find-near-match`
  when the shape already has a swap slot.
- Refactor variant-set validation to use `run!` with direct `get`.
- Refactor `check-ref-cycles` to use a single `reduce-kv` pass.
- Fix `get-orphan-shapes`: the original `map` pipeline produced
  nils so orphan shapes were never validated; rewrite with
  `reduce-kv` for correct results.
- Add `validate-file-affected!` for change-scoped validation,
  replacing full file validation in `process-changes-and-validate`
  to only validate pages and components touched by the changes.

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

*  Improved validation

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-05-27 12:36:21 +02:00
Alejandro Alonso 30bba7cd38 🎉 Increase viewport interest area threshold (#9885) 2026-05-27 11:47:40 +02:00
Andrey Antukh 57397bc32a Merge remote-tracking branch 'origin/main' into staging 2026-05-27 11:35:47 +02:00
Andrey Antukh 676e7f8646 🔧 Update devenv postgresql config 2026-05-27 11:35:21 +02:00
Andrey Antukh f6c76711f4 Merge remote-tracking branch 'origin/main' into staging 2026-05-27 11:34:59 +02:00
Andrey Antukh e43703c368 📎 Update root dependencies 2026-05-27 10:57:06 +02:00
Alejandro Alonso 1025d2e756 🐛 Fix WASM context-restore background reapplication (#9880) 2026-05-27 09:16:02 +02:00
Pablo AlbaandAndrey Antukh c9d43006c8 🐛 Fix restore saved version keeps view-only (#9514)
* 🐛 Fix restore saved verrsion keeps view-only

* 📎 Remove outdated note from CHANGES.md

Remove note about restoring saved version from Preview mode.

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-27 09:09:32 +02:00
Andrey Antukh 110db4380e 🐛 Fix invitation token propagation in login flow
Pass invitation-token through login-from-token event so it reaches
the logged-in state. Fix component render syntax (:& -> :>) for the
verify-token route. Remove redundant navigation that re-visited
verify-token after login. Fix missing dependency in effect hook
to re-run when token changes.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-26 19:03:47 +02:00
Andrés Moya 7a8fa7a9cb 🐛 Token remap preserves child component sync after renaming a token group (#9566) (#9878) 2026-05-26 17:12:03 +02:00
Eva Marco 310f224ab6 🐛 Fix keep copy untranslated to preserve token name validation (#9877) 2026-05-26 16:26:13 +02:00
Eva Marco 0c568b0b2d 🐛 Fix highlight on token pill (#9829) 2026-05-26 16:25:18 +02:00
Alonso Torres b609a964be 🐛 Fix boolean issues on the wasm render
* 🐛 Fix sharp angles in text-to-path due to wrong quad/conic degree elevation

* 🐛 Preserve even-odd fill type through Skia path conversions

* 🐛 Fix wrong quadratic-to-cubic degree elevation in push_bezier

* 🐛 Skip zero-length degenerate close segments in path_to_beziers

* 🐛 Replace BTreeMap for Vec for bool calculation

* 🐛 Fix even_odd missing when creating Path
2026-05-26 15:11:52 +02:00
David Barragán Merino f1c78945c4 🐳 Start penpot-frontend always after penpot-mcp 2026-05-26 13:55:27 +02:00
Elena Torró 75c61f9211 🐛 Fix stroke rendering on drag (#9871) 2026-05-26 13:14:38 +02:00
Belén Albeza 34f30e38aa 🐛 Fix migrations throwing exception on corrupted file (#9868) 2026-05-26 12:07:22 +02:00
Aitor Moreno eb095169b8 Merge pull request #9814 from penpot/hiru-fix-detach-token-typ
🐛 Fix changed font size when editing a text with no changes
2026-05-26 11:42:30 +02:00
Alonso Torres 5a3a855b24 🐛 Fix problem with position-data not present
* 🐛 Fix problem with position-data not present

* 🐛 Async set-objects wait before calculate-position-data
2026-05-26 09:50:23 +02:00
alonso.torres eafd261ca6 🐛 Add a e2e test for undo after renaming 2026-05-26 09:46:18 +02:00
alonso.torres 63886d097b 🐛 Fix problem with undo rename tokens 2026-05-26 09:46:18 +02:00
Eva Marco 9439d63682 🐛 Fix rename on non empty page (#9850) 2026-05-26 08:58:09 +02:00
Eva Marco 017f1d9994 🐛 Fix filter tokens to be case-sensitive (#9800)
* 🐛 Fix filter tokens to be case-sensitive

* ♻️ Add test
2026-05-25 07:47:58 +02:00
Elena Torro 2fdd3aab98 🐛 Fix nested inherited transformations 2026-05-22 14:11:50 +02:00
andrés gonzález 649efd124e 📚 Update User Guide with 2.6 features (#9768) 2026-05-22 10:49:39 +02:00
Belén Albeza 3fd114550f 🐛 Fix library update tab UX (#9812)
* 🐛 Fix update library modal UI not working properly when updating a lib

*  Add playwright test for bug 14214
2026-05-21 19:50:12 +02:00
David Barragán Merino d574ec4ed2 🔧 Change the path to the cache directories in the custom runner 2026-05-21 19:04:41 +02:00
David Barragán Merino 0e399b7ad8 🔧 Change the path to the cache directories in the custom runner 2026-05-21 19:03:58 +02:00
Eva Marco 8119ed132e 🐛 Fix sorting of standalone and grouped tokens (#9736) 2026-05-21 17:15:07 +02:00
Luis de Dios bfa338bdee 🐛 Fix resize line between sitemaps and layers 2026-05-21 16:54:31 +02:00
Andrés Moya 65c6b7be7c 🐛 Fix changed font size when editing a text with no changes 2026-05-21 16:46:52 +02:00
Alonso Torres a7b17f54f1 🐛 Fix problem of path position on variant change (#9801) 2026-05-21 16:39:26 +02:00
Belén Albeza 4a73a97a32 🐛 Fix shapes not being rendered after render switch 2026-05-21 16:31:56 +02:00
andrés gonzálezandAndrey Antukh 6d6f624d09 Track WebGL rendering toggle events (#9683) (#9772)
*  Track WebGL rendering toggle events (#9683)

* 📎 Normalize workspace menu events origin

* 📎 Normalize event origin for profile settings page

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-21 13:17:14 +02:00
e2ed6a488d Polish workspace find and replace UX (#9687)
*  Polish workspace find and replace UX

Co-authored-by: Cursor <cursoragent@cursor.com>

*  Add toggle mode button

This button toggles between search and search and replace modes

* ♻️ Refactor and CSS cleanup

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-05-21 12:10:36 +02:00
Alejandro Alonso f3053fc844 🐛 Fix 2.15 release notes crash on invalid slide index (#9805) 2026-05-21 12:09:43 +02:00
Alonso Torres 3cfd1e1a48 🐛 Fix problem with request file access 2026-05-21 11:34:45 +02:00
Belén Albeza 3512a57df7 🐛 Fix referential integrity data in old files (#9771) 2026-05-21 11:18:13 +02:00
Alonso Torres c0e7bfae00 🐛 Fix problem with shift and alt in numeric inputs 2026-05-21 11:02:47 +02:00
Elena Torró df0a58af93 🐛 Fix team members request loop on dashboard 2026-05-21 09:26:53 +02:00
Belén Albeza d7a50735ba 🐛 Fix page and delete page icons in the sidebar
* 🐛 Fix page delete icon not disappearing on selected page

* 🐛 Fix page icon being clipped on its right side
2026-05-21 09:14:45 +02:00
Francis Santiago 0d8785a0bd Merge pull request #9738 from penpot/fc-8897-disable-ipv6-listen
🐳 Fix frontend startup on hosts without IPv6 support
2026-05-20 21:07:07 +02:00
Francis Santiago 2ad85db016 🐳 Fix frontend startup on hosts without IPv6 support
Signed-off-by: Francis Santiago <francis.santiago@kaleidos.net>
2026-05-20 20:53:30 +02:00
Eva Marco 857aa4175c 🐛 Fix Numeric inputs rejects values with leading whitespaces 2026-05-20 17:38:16 +02:00
Belén Albeza d96442483e 🐛 Fix blurry boards (svg renderer) 2026-05-20 17:37:07 +02:00
Eva Marco f1c0ea2a19 🐛 Fix typing full token name on numeric input (#9725) 2026-05-20 13:23:21 +02:00
Andrés Moya 3e2b00b97f 🐛 Reload libraries when the tokens change (#9715) 2026-05-20 13:12:52 +02:00
Francis Santiago 106b10e971 📚 Clarify self-hosted OIDC configuration for containerized deployments (#9758)
Signed-off-by: Francis Santiago <francis.santiago@kaleidos.net>
2026-05-20 13:05:40 +02:00
Alonso Torres 29d449b42f 🐛 Deactivate text update (#9757) 2026-05-20 11:08:41 +02:00
Elena Torro fb6c522cc0 🐛 Fix inner strokes clipped on boards created from rect 2026-05-20 00:15:53 +02:00
Eva Marco 27df2aebfc 🐛 Fix token pill not updating when token name is equal token group (#9742) 2026-05-19 18:58:18 +02:00
Eva Marco b72389b5e3 🐛 Fix unset color on delete invitations modal (#9747) 2026-05-19 18:56:39 +02:00
Andrey Antukh 5f30704b28 📚 Update changelog 2026-05-19 17:55:41 +02:00
Andrey Antukh 0b0bd72dce 📎 Backport opencode skills from staging 2026-05-19 17:47:00 +02:00
Andrey Antukh d0cc859bc2 Migrate svg-attrs, optimize set-shape-svg-attrs, filter invalid URLs (#9118)
*  Add svg-attrs casing fix migration

*  Optimize set-shape-svg-attrs by removing redundant operations

- Remove backward compatibility for kebab-case SVG attribute keys
  (fill-rule, stroke-linecap, stroke-linejoin) since svg-attrs are
  already normalized to camelCase by the attrs->props migration.
- Remove unnecessary select-keys filtering and intermediate map
  construction (dissoc :style + merge style).
- Directly extract values from style and attrs using or, avoiding
  any intermediate map allocation.

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

* 🐛 Filter non-http(s) URLs in upload-images to prevent invalid calls

Skip upload for image items that are not data URIs and do not have
an http:// or https:// URL, avoiding unnecessary RPC calls with
invalid URLs to create-file-media-object-from-url.

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 17:41:41 +02:00
Alejandro Alonso 20c6da2138 Merge pull request #9745 from penpot/superalex-fix-numeric-input-unmount
🐛 Fix commit pending numeric input on unmount without blur side effects
2026-05-19 16:59:55 +02:00
Alejandro Alonso 1d2c158ebe 🐛 Fix commit pending numeric input on unmount without blur side effects 2026-05-19 16:59:39 +02:00
Alejandro Alonso 783cfd3e55 Merge pull request #9724 from penpot/alotor-fix-grid-position
🐛 Fix problem with grid child positions
2026-05-19 16:57:39 +02:00
Alejandro Alonso 0de351fcf6 Merge pull request #9734 from penpot/elenatorro-14211-fix-translation-drag-out-of-board
🐛 Clean modifiers when needed
2026-05-19 16:54:53 +02:00
Elena Torro 29ad9aa057 🐛 Fix redirect after leaving team 2026-05-19 15:44:27 +02:00
Andrey Antukh 405a73e8ba Add climit impl and config for file snapshot methods (#9722)
*  Add dedicated concurrency limit for restore-file-snapshot

This adds a dedicated climit configuration for the restore-file-snapshot
RPC method with :permits 1 per profile (plus queue of 2 and 60s timeout)
and a global limit of 3. Previously the method only used the generic
root/by-profile and root/global limits, allowing up to 7 concurrent
restore operations per profile which caused database row lock contention
on FOR UPDATE and connection pool exhaustion.

*  Skip locking on restore! to avoid blocking other operations

Changes the row lock acquisition in restore! from a blocking FOR UPDATE
to FOR UPDATE SKIP LOCKED. If the file row is already locked by another
concurrent operation (e.g., another restore or an update-file), the query
returns no rows and the caller fails fast with a clear conflict error
instead of blocking indefinitely holding a database connection.

*  Add queue and timeout limits to root/by-profile concurrency limit

Previously root/by-profile had no queue limit (unbounded Integer/MAX_VALUE)
and no timeout, allowing requests to pile up indefinitely behind a profile
whose permits were exhausted by long-running operations. This could lead
to memory pressure and cascading failures. Now limited to 30 queued
requests with a 30-second timeout so excess requests fail fast.

*  Move backup snapshot creation outside restore transaction

The backup snapshot (fsnap/create!) is now created in its own short-lived
connection before the actual restore transaction begins. This ensures the
backup is persisted independently of the restore outcome and reduces the
restore transaction window.

The restore itself runs inside a db/tx-run! block with an optimistic
locking check: it reads the file with FOR UPDATE and compares its revn
against the value captured at backup time. If the file was edited
concurrently, the restore aborts with a conflict error to prevent data
loss.

Co-dependent with the SKIP LOCKED change in restore! — the FOR UPDATE
acquired here is in the same transaction as restore!, so the SKIP LOCKED
inside restore! correctly sees the row as unlocked (same transaction).

* ♻️ Remove unused private function get-minimal-file

The local get-minimal-file function in file_snapshots.clj is no longer
used since restore! switched to direct exec-one! with FOR UPDATE SKIP
LOCKED. The sql:get-minimal-file SQL constant is still used directly.

*  Add minor improvements on db connection management

* ♻️ Refactor create-file-snapshot to use explicit transaction management

Remove automatic transaction wrapping (`::db/transaction true`) and
pass `cfg` through the call chain instead of destructured `conn`.
Wrap `fsnap/create!` in an explicit `db/tx-run!` for clearer
transaction boundaries.

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

*  Add dedicated concurrency limit for create-file-snapshot

This adds a dedicated climit configuration for the create-file-snapshot
RPC method with :permits 1 per profile (plus queue of 2 and 60s timeout)
and a global limit of 3. Previously the method only used the generic
root/by-profile and root/global limits, allowing up to 10 concurrent
snapshot creation operations per profile which could cause database
contention and connection pool exhaustion.

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-19 14:30:44 +02:00
Alonso Torres ee6489b202 🐛 Fix problem with login shoing wrong credentials 2026-05-19 13:19:06 +02:00
Alonso Torres aa1fb718e0 🐛 Fix invalid token on anonymous session 2026-05-19 13:13:11 +02:00
Elena Torro c53856b5a9 🐛 Clean modifiers when needed 2026-05-19 12:45:14 +02:00
alonso.torres 8098250b23 🐛 Fix problem with grid child positions 2026-05-19 12:43:56 +02:00
Elena Torró d9ee28229c 🐛 Toggle token path on token rename 2026-05-19 11:35:30 +02:00
Eva Marco ed746bb694 🐛 Fix no gap on token list 2026-05-19 11:01:39 +02:00
Alonso Torres a9d0feb8fd 🐛 Fix problem with caret color value (#9717) 2026-05-19 09:56:16 +02:00
Elena Torró 8dd4b486e7 Improve drag performance avoiding unnecessary modifiers 2026-05-19 09:44:58 +02:00
Eva Marco 44f4c43f15 🐛 Fix apply tokens on token creation (#9713) 2026-05-19 09:40:10 +02:00
Andrey Antukh 46c35b01a8 📎 Update changelog 2026-05-19 09:02:34 +02:00
Andrey Antukh d9bcc1431c 📎 Update the 'update-changelog' opencode skill 2026-05-19 09:02:28 +02:00
Andrey Antukh 5b7c732449 Merge remote-tracking branch 'origin/main' into staging 2026-05-18 19:59:46 +02:00
Andrey Antukh 87b969bd05 📎 Update changelog 2026-05-18 19:59:12 +02:00
Andrey Antukh 1161a163a7 ⬆️ Update root repo opencode dependency 2026-05-18 19:59:12 +02:00
Andrey Antukh 4ad137aef3 📎 Update gh-issue-from-pr opencode skill 2026-05-18 19:59:12 +02:00
Andrey Antukh 1b6b367951 Add diagnostic keys to SSRF validation exceptions
Add :uri and :scheme/:host keys to exceptions raised by
`validate-uri` for better error diagnostics. Also fix a bug
where (str url) was used instead of (str uri) in the
host-missing exception path.

Update the existing blocked-target test to verify the new :uri
key, and add three new tests covering scheme rejection, missing
host, and DNS failure error paths. All 27 tests pass with 60
assertions and 0 failures.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-18 15:57:55 +00:00
Belén Albeza 5c423c3678 🐛 Fix measurement guides not showing up in wasm when user has viewer role 2026-05-18 17:17:18 +02:00
Eva MarcoandAndrey Antukh 53530e958a 🐛 Fix incorrect warning when token applied (#9708)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-18 16:26:28 +02:00
Andrey Antukh 4d9c6eba38 📎 Add missing bugfix entries to changelog 2026-05-18 16:20:27 +02:00
Andrey Antukh 208182cab1 Merge remote-tracking branch 'origin/main' into staging 2026-05-18 15:23:46 +02:00
Andrey Antukh f5acea7cd7 📎 Update opencode 'update-changelog' skill 2026-05-18 15:22:32 +02:00
Andrey Antukh 7e522ae777 📎 Fix inconsistencies on CHANGES.md 2026-05-18 15:11:11 +02:00
Andrés MoyaandAndrey Antukh 82169bc0a3 🐛 Fix loss of swap slot in some cases of variant switch (#9147)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-18 14:25:32 +02:00
Andrey Antukh 725a0c966c 📎 Fix incorrect entries on changelog 2026-05-18 14:23:18 +02:00
Andrés Moya ab284febf7 🐛 Fix token application to grid padding (#9630) 2026-05-18 13:32:28 +02:00
Andrey Antukh 9de25c5404 🐛 Fix incorrect content-type on doc endpoint response (#9681)
The /api/main/doc endpoint was returning HTML content with a
text/plain content-type header instead of text/html. This caused
browsers to render the response as plain text.

Added content-type: text/html; charset=utf-8 header to the
response in the doc handler and added a regression test to
verify the fix.

Closes #9680

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-18 12:54:16 +02:00
Alonso Torres 9928249d4f ⬆️ Downgrade archive dependency (#9704) 2026-05-18 12:47:41 +02:00
Alejandro Alonso 0956becd12 🎉 Reduce heap allocations 2026-05-18 12:35:16 +02:00
Andrés Moya 25ee8dee78 🐛 Fix editing a text element detaches applied tokens (#9525) 2026-05-18 12:28:48 +02:00
Alejandro Alonso 1ac503f6bc Merge pull request #9510 from penpot/alotor-fix-viewer-texts
🐛 Fix problem with viewer texts
2026-05-18 11:24:02 +02:00
alonso.torres b2bfd627ae 🐛 Fix problem with viewer texts 2026-05-18 11:00:45 +02:00
andrés gonzálezandCursor 24fe5559c5 📚 Update 2.16 changelog (#9689)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 10:31:24 +02:00
Belén Albeza 0300058605 🐛 Fix delete page icon being clipped (#9685) 2026-05-15 13:41:38 +02:00
Andrey Antukh ff23f786b4 🐛 Fix broken authentication on /assets handlers
- Add ::setup/props and ::db/pool to :app.http.assets/routes config
  so session renewal works correctly for asset requests.
- Add actoken/authz middleware to the assets middleware chain so
  access tokens are properly recognized.
- Add authenticated? helper that checks both ::session/profile-id
  and ::actoken/profile-id, fixing 401 errors when accessing
  protected assets with a valid access token.
- Add comprehensive test suite for assets auth scenarios.

Closes #9677

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-15 12:05:02 +02:00
Belén Albeza fc36fb0959 🐛 Fix text editor being hidden to Playwright when empty text (#9682) 2026-05-15 12:04:51 +02:00
Andrey Antukh 6ac8012258 Merge remote-tracking branch 'origin/main' into staging 2026-05-15 11:57:16 +02:00
Andrey Antukh 6cc36e4fcc 📎 Backport more changes for opencode 2026-05-15 11:56:30 +02:00
Andrey Antukh fe76567180 📎 Backport opencode skills from staging 2026-05-15 11:51:49 +02:00
Andrey Antukh 3db0e5ee0d 📎 Update changelog 2026-05-15 11:31:58 +02:00
Andrey Antukh 1f8ab6fed2 📎 Update the 'update-changelog' skill
And add specific tool for extracting info from github
2026-05-15 11:31:58 +02:00
Andrey Antukh 0b65431137 📎 Add taiga skill and script for opencode
Allows easy extraction of information from taiga urls
2026-05-15 11:10:02 +02:00
Elena Torró 053d4a23f5 🐛 Fix shape deletion after tiles refactor (#9678) 2026-05-15 11:06:17 +02:00
andrés gonzálezandAndrey Antukh 27ac0b7469 🐛 Unify layout creation telemetry for plugins and MCP (#9654)
* 🐛 Unify layout creation telemetry for plugins and MCP

* 📚 Update changelog for version 2.15.4

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-15 10:53:43 +02:00
Andrey Antukh 1dea84b7b1 📚 Update mcp readme 2026-05-15 10:19:29 +02:00
Aitor Moreno 58c42df37e 🐛 Fix atlas texture leak 2026-05-14 17:17:06 +02:00
310bf6fd6a 💄 Change auth illustration (#9552)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 16:25:53 +02:00
andrés gonzálezandAndrey Antukh 7e7bf7c458 Update Open Graph and link preview metadata (#9557)
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 16:23:57 +02:00
andrés gonzález c62ce866a8 📚 Change sentence at MCP docs (#9568) 2026-05-14 16:23:13 +02:00
andrés gonzález 846958d79e 📚 Change slogan at Help Center footer (#9554) 2026-05-14 16:22:19 +02:00
Alonso Torres dc878572da 🐛 Fix problem with set activation after renaming (#9545) 2026-05-14 16:04:07 +02:00
Pablo AlbaandAndrey Antukh 5dafd44966 🐛 Fix library update button freezes and does not apply updates (#9513)
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 16:03:47 +02:00
Xaviju fb2734cd02 🐛 Save numeric input value on unmount (#9548) 2026-05-14 16:02:34 +02:00
Andrey Antukh 9021544c05 Merge remote-tracking branch 'origin/main' into staging 2026-05-14 15:24:29 +02:00
Andrey Antukh 05d40e3370 📚 Update changelog 2026-05-14 15:19:24 +02:00
Andrey Antukh 237f61fda0 📎 Add update changelog opencode skill 2026-05-14 15:19:03 +02:00
Andrey Antukh eb1707788b 📎 Add gh-issue-from-pr SKILL for opencode 2026-05-14 15:01:26 +02:00
Alonso Torres 8afe8a5dfa 🐛 Fix plugins schema validation error (#9632) 2026-05-14 15:00:41 +02:00
Andrey Antukh fd19bf121f 📎 Update changelog 2026-05-14 14:23:59 +02:00
Alejandro Alonso 134391bc3a Merge pull request #9633 from penpot/elenatorro-fix-auto-width
🐛 Fix regression on auto-width
2026-05-14 14:01:19 +02:00
Andrey Antukh 67d9567971 🐛 Prevent CSS injection vulnerability in font family names
Add a shared `schema:font-family` whitelist validator in
app.common.types.font that only allows letters, digits, spaces,
hyphens, underscores, and dots in font family names. Apply the schema
to create-font-variant and update-font RPC endpoints on the
backend, and add client-side validation in the dashboard fonts UI.
Include unit tests for the schema and integration tests for the RPC
handlers.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 13:46:02 +02:00
Elena Torro b13aedb231 🐛 Fix regression on auto-width 2026-05-14 13:43:00 +02:00
Alejandro Alonso 009e505ba1 Merge pull request #9570 from penpot/superalex-interactive-drag-crop-atlas-snapshopt
🎉 Rebuild drag-crop cache from tile textures with hybrid atlas fill
2026-05-14 13:14:36 +02:00
Alejandro Alonso 575f4b9df0 🎉 Optimize drag-crop cache rebuild path 2026-05-14 13:00:25 +02:00
Alejandro Alonso d4be6686c7 🎉 Rebuild drag-crop cache from tile textures with hybrid atlas fill 2026-05-14 13:00:25 +02:00
Aitor Moreno 64f73ef23b ♻️ Remove Mutex from mem buffer (#9479) 2026-05-14 12:57:10 +02:00
Belén Albeza f62ee7d1ae 🐛 Fix asset icon (#9612) 2026-05-14 12:56:54 +02:00
Belén Albeza 78e3077a37 🔧 Use polyfilled helpers instead of raf (#9628) 2026-05-14 12:42:58 +02:00
Alonso Torres f389fcf468 🐛 Fix problem with copy-as-image action (#9586) 2026-05-14 12:01:30 +02:00
Andrey Antukh 8b06096019 🐛 Fix playwright version inconsistencies 2026-05-14 11:40:33 +02:00
Andrey Antukh 29f940fb7a 🐛 Sanitize comment content on rendering (#9605)
Add escape-html function that escapes HTML special characters and apply
it in the comment editor at four dom/set-html! call sites where
user-provided text is inserted as innerHTML, preventing stored XSS.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-14 11:20:11 +02:00
Alonso Torres c752e194d6 🐛 Fix problem with preview version in svg render (#9626) 2026-05-14 11:11:01 +02:00
Andrey Antukh eec05271e0 Merge remote-tracking branch 'refs/remotes/origin/staging' into staging 2026-05-14 11:07:59 +02:00
Andrey Antukh d78074307f Merge remote-tracking branch 'origin/main' into staging 2026-05-14 11:07:42 +02:00
Alejandro Alonso 4bd0049ddf Merge pull request #9622 from penpot/superalex-fix-grid-not-visible-2
🐛 Fix grid not visible
2026-05-14 10:56:18 +02:00
Andrey AntukhandYamila Moreno 55dd6d2b00 🔧 Add cache to github tests CI worflow. (#9621)
*  Remove usage of RELEASE placeholder on deps.edn

* 🔧 Add Maven cache to CI

---------

Co-authored-by: Yamila Moreno <yamila.moreno@kaleidos.net>
2026-05-14 10:54:22 +02:00
Alejandro Alonso 8d51a88326 🐛 Fix grid not visible 2026-05-14 10:48:31 +02:00
Alejandro Alonso a961008188 Merge pull request #9550 from penpot/ladybenko-14033-fix-pasted-text-styles
🐛 Fix text styles sometimes being overidden when chaging shape text attrs
2026-05-14 10:09:06 +02:00
Belén Albeza abc290582d 🐛 Fix text styles sometimes being overidden when chaging shape text attrs 2026-05-14 09:42:55 +02:00
Alejandro Alonso 49d76d0f1c Merge pull request #9618 from penpot/luis-fix-linting-issue
🐛 Fix linting error
2026-05-14 08:59:54 +02:00
Luis de Dios 4c9e1d4015 🐛 Fix linting error 2026-05-14 08:53:23 +02:00
Alejandro Alonso 14fca46886 Merge pull request #9610 from penpot/ladybenko-14185-fix-exit-focus
🐛 Fix rendering glitch when exiting focus mode
2026-05-14 07:58:28 +02:00
Belén Albeza ae282eb6e2 🐛 Fix rendering glitch when exiting focus mode 2026-05-14 07:45:51 +02:00
Alejandro Alonso 009d805394 Merge pull request #9614 from penpot/elenatorro-fix-text-focus-on-empty-text-shape
🐛 Fix text focus on empty text
2026-05-14 07:26:30 +02:00
Elena Torro 374c64da74 🐛 Fix text focus on empty text 2026-05-14 07:12:30 +02:00
Alejandro Alonso 2685389aad Merge pull request #9562 from penpot/elenatorro-fix-layout-freeze
🐛 Reflow only when needed
2026-05-13 16:26:05 +02:00
Elena Torro 38c62a465f 🐛 Reflow only when needed 2026-05-13 16:22:21 +02:00
Alejandro Alonso 757aae1df3 Merge pull request #9572 from penpot/azazeln28-refactor-target-and-backbuffer-rendering
♻️ Refactor how target and backbuffer works
2026-05-13 16:11:17 +02:00
Aitor Moreno a5da9449b5 ♻️ Refactor how target and backbuffer works 2026-05-13 16:05:19 +02:00
Alonso Torres c56f5cc01b 🐛 Fix problem with colorpicker in multiselect color with texts (#9549) 2026-05-13 15:57:30 +02:00
Alonso Torres c65b24495b 🐛 Fix several issues with color picker (#9558) 2026-05-13 15:54:05 +02:00
Luis de Dios b125c2cabb 🐛 Fix remove resize cursor CSS for inputs (#9590)
* 🐛 Remove cursor CSS for all inputs

- Restore the default cursor for the dashboard inputs.
- Make the numeric-input component from DS to work as expected.

* 🐛 Fix remove drag-to-change behaviour from old numeric-input
2026-05-13 15:51:24 +02:00
Pablo Alba bf880467b4 🐛 Fix dependency libraries visible in UI after unlinking main library (#9511) 2026-05-13 15:35:42 +02:00
Andrey Antukh da85e02a6f ⬆️ Update dependencies (#9597)
* ⬆️ Update dependencies

* 📎 Fix playwright dep
2026-05-13 14:14:10 +02:00
Alejandro Alonso 7617e42547 Merge pull request #9576 from penpot/superalex-fix-atlas-issues
🐛 Fix atlas corruption when dragging large shapes after zoom change
2026-05-13 13:08:20 +02:00
Alejandro Alonso 1a3b057814 🐛 Fix atlas corruption when dragging large shapes after zoom change 2026-05-13 13:00:56 +02:00
Alejandro Alonso 4f5d269313 Merge pull request #9599 from penpot/superalex-fix-drag-crop-cache-viewport-clamp
🐛 Fix clamp backbuffer crop origin for partially off-screen shapes
2026-05-13 13:00:03 +02:00
Alejandro Alonso 9c61aa4f17 🐛 Fix clamp backbuffer crop origin for partially off-screen shapes 2026-05-13 12:48:05 +02:00
Yamila Moreno 4df707c0ff 🐳 Add enable-mcp to docker-compose as default behaviour 2026-05-13 11:53:10 +02:00
Pablo Alba fffafdab93 🐛 Fix library updates reappear after file is reloaded (#9563)
* 🐛 Fix library updates reappear after file is reloaded

Summary
Migrate synced_at timestamps to a standalone file_library_sync table to ensure sync state is tracked for both direct and transitive libraries.

Problem
Transitive libraries (libraries imported by other libraries) are not stored as direct rows in file_library_rel. Because the system previously coupled synced_at directly to the file_library_rel schema, transitive libraries lacked a persistent location for their sync timestamps. This caused sync states to be lost or incorrectly reported for nested dependencies.

Changes
Schema Migration: Created file_library_sync and migrated existing synced_at values from file_library_rel.

Decoupling: Removed tight Foreign Key coupling to allow sync rows to exist independently of specific relationship records.

Persistent Writes: Added upsert-file-library-sync! helper. Updated all import, duplication, and RPC write paths (v1/v2/v3 importers, link-file-library) to ensure every write persists a sync row.

Unified Reads: Updated both direct and recursive/transitive library queries to fetch synced_at from the new table.

Testing: Added regression tests to verify that sync rows are correctly created/updated even when a transitive relation is absent in file_library_rel.

Impact
This fix ensures that the system accurately records and retrieves sync states for the entire library dependency tree, resolving the bug where nested libraries appeared out of sync.

*  MR review
2026-05-13 11:29:05 +02:00
Yamila Moreno d4dade2c3e 🐳 Pin minor version in docker-compose.yaml 2026-05-13 07:48:06 +02:00
Andrey Antukh 382efe3449 📚 Update changelog 2026-05-12 23:33:39 +02:00
Yamila Moreno 4289cad9ab 🐳 Improve nginx configuration for MCP server (#9565) 2026-05-12 23:28:20 +02:00
Andrey Antukh db7fcfcb1a 🐛 Fix metrics for rpc methods 2026-05-12 19:06:25 +02:00
Andrey Antukh e5c99231da 📚 Update changelog 2026-05-12 18:43:08 +02:00
Yamila Moreno 02c3d2c27c 🐳 Add mcp server to release workflow 2026-05-12 18:38:17 +02:00
Yamila Moreno eb22c59e5a 🐳 Add penpot-mcp service to official docker-compose.yml 2026-05-12 18:31:13 +02:00
Andrey Antukh 947f6d392d 🎉 Add chunked upload support for font variants (#9551)
*  Add additional logging and validation for image upload

* 🎉 Add chunked upload support for font variants

Extend the font variant upload flow across frontend, backend, and common
to support the standardized chunked upload protocol.

**Backend:**
- Add \`:font-max-file-size\` config default (30 MiB) and schema entry
- Add \`validate-font-size!\` in \`media.clj\` (mirrors
  \`validate-media-size!\`, raises \`:font-max-file-size-reached\`)
- Extend \`schema:create-font-variant\` to accept either \`:data\`
  (legacy bytes or chunk-vector) or \`:uploads\` (new chunked session
  map), with a validator requiring exactly one
- Add \`prepare-font-data-from-uploads\`: assembles each chunked
  session via \`cmedia/assemble-chunks\`, validates type+size
- Add \`prepare-font-data-from-legacy\`: normalises legacy byte/chunk
  entries, writing to a tempfile (joining via SequenceInputStream),
  validates type+size
- Add structured logging ("init"/"end") with \`:size\`, \`:mtypes\`,
  and \`:elapsed\` in \`create-font-variant\`

**Frontend:**
- \`upload-blob-chunked\` accepts a per-caller \`:chunk-size\` option
- Add \`font-upload-chunk-size\` (10 MiB) and \`upload-font-variant\`
  fn that uploads each mtype as a separate chunked session
- \`on-upload*\` in dashboard fonts now calls \`upload-font-variant\`
  instead of issuing \`create-font-variant\` RPC directly
- \`process-upload\` stores raw ArrayBuffer instead of chunking
  client-side

**Common:**
- Replace \`"font/opentype"\` with \`"font/woff2"\` in \`font-types\`

**Tests:**
- 25 tests / 224 assertions covering all three upload paths (direct
  bytes, legacy chunk-vector, new chunked sessions), size validation,
  and media type validation

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

* 📎 Add a script for check the commit format locally

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-12 18:30:19 +02:00
Andrey Antukh ade0d2d0a8 📎 Update changelog with PR info 2026-05-12 13:01:42 +02:00
Yamila Moreno 328efd4e16 📚 Add notice regarding architectural constraints with MCP Server (#9423) 2026-05-12 10:06:13 +02:00
Andrey Antukh 11a72abdcd 📎 Update version on mcp server 2026-05-12 10:04:12 +02:00
Andrey Antukh bd3ca6f8e5 📚 Update changelog 2026-05-12 09:33:33 +02:00
1772 changed files with 34473 additions and 11272 deletions

No files matched your search

+3 -2
View File
@@ -1,7 +1,8 @@
description: Create a report to help us improve
labels: ["bug"]
name: Bug report
title: "bug: "
type: Bug
labels: ["needs triage"]
body:
- type: markdown
attributes:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+3 -2
View File
@@ -1,7 +1,8 @@
description: Suggest an idea for this project.
labels: ["needs triage", "enhancement"]
labels: ["needs triage"]
name: "Feature request"
title: "feature: "
type: Enhancement
body:
- type: markdown
attributes:
@@ -1,38 +0,0 @@
---
name: New Render Bug Report
about: Create a report about the bugs you have found in the new render
title: ''
labels: new render
assignees: claragvinola
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps to Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or screen recordings**
If applicable, add screenshots or screen recording to help illustrate your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-1
View File
@@ -15,6 +15,5 @@
- [ ] Add or modify existing integration tests in case of bugs or new features, if applicable.
- [ ] Refactor any modified SCSS files following the refactor guide.
- [ ] Check CI passes successfully.
- [ ] Update the `CHANGES.md` file, referencing the related GitHub issue, if applicable.
<!-- For more details, check the contribution guidelines: https://github.com/penpot/penpot/blob/develop/CONTRIBUTING.md -->
+10 -1
View File
@@ -6,7 +6,6 @@ on:
jobs:
build-and-push:
name: Build and push DevEnv Docker image
environment: release-admins
runs-on: penpot-runner-02
steps:
@@ -39,3 +38,13 @@ jobs:
tags: ${{ env.DOCKER_IMAGE }}:latest
cache-from: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.DOCKER_IMAGE }}:buildcache,mode=max
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@master
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
🚀 *[PENPOT] New devenv available*
📄 You may want to update your devenv.
@alvaro
+1 -2
View File
@@ -19,7 +19,6 @@ permissions:
jobs:
release:
environment: release-admins
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.vars.outputs.gh_ref }}
@@ -63,7 +62,7 @@ jobs:
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "storybook")
IMAGES=("frontend" "backend" "exporter" "mcp" "storybook")
SHORT_TAG=${TAG%.*}
for image in "${IMAGES[@]}"; do
+84
View File
@@ -0,0 +1,84 @@
name: "CI: Backend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'backend/**'
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'backend/**'
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
mkdir -p /tmp/penpot;
clojure -M:dev:test --reporter kaocha.report/documentation
+57
View File
@@ -0,0 +1,57 @@
name: "CI: Common"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Tests
working-directory: ./common
run: |
./scripts/test
+71
View File
@@ -0,0 +1,71 @@
name: "CI: Frontend"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components
+93
View File
@@ -0,0 +1,93 @@
name: "CI: Integration"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'frontend/**'
- 'common/**'
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result
path: frontend/test-results/
overwrite: true
retention-days: 3
+58
View File
@@ -0,0 +1,58 @@
name: "CI: Library"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'common/**'
- 'library/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'common/**'
- 'library/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Tests
working-directory: ./library
run: |
./scripts/test
+5
View File
@@ -45,3 +45,8 @@ jobs:
pnpm run fmt:check;
pnpm -r run build;
pnpm -r run types:check;
- name: Tests
working-directory: ./mcp
run: |
pnpm -r run test;
+83
View File
@@ -0,0 +1,83 @@
name: "CI: Plugins"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'plugins/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'plugins/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example
+57
View File
@@ -0,0 +1,57 @@
name: "CI: WASM"
defaults:
run:
shell: bash
on:
pull_request:
paths:
- 'render-wasm/**'
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
paths:
- 'render-wasm/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container:
image: penpotapp/devenv:latest
volumes:
- /var/cache/github-runner/m2:/root/.m2
- /var/cache/github-runner/gitlib:/root/.gitlibs
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
-363
View File
@@ -1,363 +0,0 @@
name: "CI"
defaults:
run:
shell: bash
on:
pull_request:
types:
- opened
- synchronize
- ready_for_review
push:
branches:
- develop
- staging
concurrency:
group: ${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
if: ${{ !github.event.pull_request.draft }}
name: "Linter"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lint Common
working-directory: ./common
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:clj
pnpm run check-fmt:js
pnpm run lint:clj
- name: Lint Frontend
working-directory: ./frontend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt:js
pnpm run check-fmt:clj
pnpm run check-fmt:scss
pnpm run lint:clj
pnpm run lint:js
pnpm run lint:scss
- name: Lint Backend
working-directory: ./backend
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Exporter
working-directory: ./exporter
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
- name: Lint Library
working-directory: ./library
run: |
corepack enable;
corepack install;
pnpm install;
pnpm run check-fmt
pnpm run lint
test-common:
if: ${{ !github.event.pull_request.draft }}
name: "Common Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./common
run: |
./scripts/test
test-plugins:
if: ${{ !github.event.pull_request.draft }}
name: Plugins Runtime Linter & Tests
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- uses: actions/checkout@v6
- name: Setup Node
id: setup-node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Install deps
working-directory: ./plugins
shell: bash
run: |
corepack enable;
corepack install;
pnpm install;
- name: Run Lint
working-directory: ./plugins
run: pnpm run lint
- name: Run Format Check
working-directory: ./plugins
run: pnpm run format:check
- name: Run Test
working-directory: ./plugins
run: pnpm run test
- name: Build runtime
working-directory: ./plugins
run: pnpm run build:runtime
- name: Build doc
working-directory: ./plugins
run: pnpm run build:doc
- name: Build plugins
working-directory: ./plugins
run: pnpm run build:plugins
- name: Build styles
working-directory: ./plugins
run: pnpm run build:styles-example
test-frontend:
if: ${{ !github.event.pull_request.draft }}
name: "Frontend Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Unit Tests
working-directory: ./frontend
run: |
./scripts/test
- name: Component Tests
working-directory: ./frontend
env:
VITEST_BROWSER_TIMEOUT: 120000
run: |
./scripts/test-components
test-render-wasm:
if: ${{ !github.event.pull_request.draft }}
name: "Render WASM Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Format
working-directory: ./render-wasm
run: |
cargo fmt --check
- name: Lint
working-directory: ./render-wasm
run: |
./lint
- name: Test
working-directory: ./render-wasm
run: |
./test
test-backend:
if: ${{ !github.event.pull_request.draft }}
name: "Backend Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
services:
postgres:
image: postgres:17
# Provide the password for postgres
env:
POSTGRES_USER: penpot_test
POSTGRES_PASSWORD: penpot_test
POSTGRES_DB: penpot_test
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: valkey/valkey:9
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./backend
env:
PENPOT_TEST_DATABASE_URI: "postgresql://postgres/penpot_test"
PENPOT_TEST_DATABASE_USERNAME: penpot_test
PENPOT_TEST_DATABASE_PASSWORD: penpot_test
PENPOT_TEST_REDIS_URI: "redis://redis/1"
run: |
clojure -M:dev:test --reporter kaocha.report/documentation
test-library:
if: ${{ !github.event.pull_request.draft }}
name: "Library Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run tests
working-directory: ./library
run: |
./scripts/test
build-integration:
if: ${{ !github.event.pull_request.draft }}
name: "Build Integration Bundle"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Build Bundle
working-directory: ./frontend
run: |
./scripts/build
- name: Store Bundle Cache
uses: actions/cache@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
test-integration-1:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 1/3"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="1/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-1
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-2:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 2/3"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="2/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-2
path: frontend/test-results/
overwrite: true
retention-days: 3
test-integration-3:
if: ${{ !github.event.pull_request.draft }}
name: "Integration Tests 3/3"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: build-integration
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Restore Cache
uses: actions/cache/restore@v5
with:
key: "integration-bundle-${{ github.sha }}"
path: frontend/resources/public
- name: Run Tests
working-directory: ./frontend
run: |
./scripts/test-e2e --shard="3/3";
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-tests-result-3
path: frontend/test-results/
overwrite: true
retention-days: 3
+4
View File
@@ -86,3 +86,7 @@
/**/.yarn/*
/.pnpm-store
/.vscode
/.idea
/.claude
/.playwright-mcp
/tools/__pycache__
+3 -8
View File
@@ -1,6 +1,6 @@
---
name: commiter
description: Git commit assistant following CONTRIBUTING.md commit rules
description: Git commit assistant
mode: all
---
@@ -15,16 +15,11 @@ including the rationale if proceed.
* Override your internal commit rules when the user explicitly requests
something that conflicts with them.
* Read `CONTRIBUTING.md` before creating any commit and follow the
commit guidelines strictly.
* Use commit messages in the form `:emoji: <imperative subject>`.
* Keep the subject capitalized, concise, 70 characters or fewer, and
without a trailing period.
* Read `.serena/memories/workflows/creating-commits.md` before
creating any commit and follow the commit guidelines strictly.
* Keep the description (commit body) with maximum line length of 80
characters. Use manual line breaks to wrap text before it exceeds
this limit.
* Separate the subject from the body with a blank line.
* Write a clear and concise body when needed.
* Use `git commit -s` so the commit includes the required
`Signed-off-by` line.
* Do not guess or hallucinate git author information (Name or
+13 -25
View File
@@ -1,37 +1,25 @@
---
name: Penpot Engineer
name: Engineer
description: Senior Full-Stack Software Engineer
mode: primary
---
Role: You are a high-autonomy Senior Full-Stack Software Engineer working on
Penpot, an open-source design tool. You have full permission to navigate the
codebase, modify files, and execute commands to fulfill your tasks. Your goal is
to solve complex technical tasks with high precision while maintaining a strong
focus on maintainability and performance.
## Role
Tech stack: Clojure (backend), ClojureScript (frontend/exporter), Rust/WASM
(render-wasm), TypeScript (plugins/mcp), SCSS.
You are a high-autonomy Senior Full-Stack Software Engineer working on Penpot, an
open-source design tool. You have full permission to navigate the codebase, modify files,
and execute commands to fulfill your tasks. Your goal is to solve complex technical tasks
with high precision while maintaining a strong focus on maintainability and performance.
Requirements:
## Before Start
* Read the root `AGENTS.md` to understand the repository and application
architecture. Then read the `AGENTS.md` **only** for each affected module.
Not all modules have one — verify before reading.
* Before writing code, analyze the task in depth and describe your plan. If the
task is complex, break it down into atomic steps.
* When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
**Read `AGENTS.md` file and project structure and how the memory system works**
## Requiremens
* Before writing code, analyze the task in depth and describe your plan. If the task is
complex, break it down into atomic steps.
* Do **not** touch unrelated modules unless the task explicitly requires it.
* Only reference functions, namespaces, or APIs that actually exist in the
codebase. Verify their existence before citing them. If unsure, search first.
* Be concise and autonomous — avoid unnecessary explanations.
* After making changes, run the applicable lint and format checks for the
affected module before considering the work done (see module `AGENTS.md` for
exact commands).
* Make small and logical commits following the commit guideline described in
`CONTRIBUTING.md`. Commit only when explicitly asked.
- Do not guess or hallucinate git author information (Name or Email). Never include the
`--author` flag in git commands unless specifically instructed by the user for a unique
case; assume the local environment is already configured. Allow git commit to
automatically pull the identity from the local git config `user.name` and `user.email`.
+3 -7
View File
@@ -1,13 +1,11 @@
---
name: Penpot Planner
name: Planner
description: Software architect for planning and analysis only
mode: primary
permission:
edit: ask
---
# Penpot Planner
## Role
You are a Senior Software Architect working on Penpot, an open-source design
@@ -29,10 +27,8 @@ or problem domain. Assume they don't know good test design very well.
## Requirements
* Analyze the codebase architecture and identify affected modules.
* Read `AGENTS.md` files (root and per-module) to understand structure and
conventions.
* Search code using `ripgrep` skill (`rg`) to trace dependencies, find patterns,
and understand existing implementations.
* Read `AGENTS.md` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* Break down complex features or bugs into atomic, actionable steps.
* Propose solutions with clear rationale, trade-offs, and sequencing.
* Identify risks, edge cases, and testing considerations.
+8 -11
View File
@@ -4,8 +4,6 @@ description: Refines and improves prompts for maximum clarity and effectiveness
mode: all
---
# Prompt Assistant
## Role
You are an expert Prompt Engineer with strong knowledge of
@@ -15,15 +13,14 @@ well-structured version possible — ready to be used with any AI model.
## Requirements
* You do NOT execute tasks. You do NOT write code. You only design and
refine prompts
* Read the root `AGENTS.md` to understand the repository and application
architecture. Then read the `AGENTS.md` **only** for each affected module.
* Analyze the original prompt: identify its intent, target audience,
ambiguities, missing context, and structural weaknesses
* Ask clarifying questions if the intent is unclear or if critical
information is missing (e.g. target model, expected output format,
tone, constraints). Keep questions concise and grouped
* You do NOT execute tasks. You do NOT write code. You only design and refine prompts
* Read `AGENTS.md` file and project structure and how the memory system works and how to
navigate and read relevant information conventions.
* Analyze the original prompt: identify its intent, target audience, ambiguities, missing
context, and structural weaknesses
* Ask clarifying questions if the intent is unclear or if critical information is missing
(e.g. target model, expected output format, tone, constraints). Keep questions concise
and grouped
* Rewrite the prompt using prompt engineering best practices
+1 -6
View File
@@ -70,12 +70,7 @@ module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
indentation, verify the logic is still semantically correct. All checks must
pass before moving on.
### 6. Port the changelog entry (if any)
If the original commit added or modified a `CHANGES.md` entry, port that entry
too — adapting wording and version references for the target branch.
### 7. Commit
### 6. Commit
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
(exclude unrelated untracked files) and provide the original commit message as
+124
View File
@@ -0,0 +1,124 @@
---
name: create-pr
description: Create or update a GitHub PR following Penpot conventions, with a concise engineer-focused description
---
# Pull Request (Create or Update)
Create or update a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
## When to Use
- Opening a new pull request
- Updating an existing PR's title or description
- The user asks to create or update a PR
- Code changes are ready and committed
## Conventions
All PR conventions (title format, body structure, writing principles, what NOT to include) are documented in `mem:workflow/creating-prs`. This skill covers the procedure only.
## Workflow A: Creating a New PR
### A1. Verify Prerequisites
```bash
git branch --show-current
git log --oneline main..HEAD
```
### A2. Check if Branch is Pushed
```bash
BRANCH=$(git branch --show-current)
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
echo "Branch is pushed, proceeding with PR creation"
else
echo "ERROR: Branch '$BRANCH' is not pushed to remote. Please push the branch first."
exit 1
fi
```
**If the branch is not pushed, STOP here and ask the user to push it. The LLM does not have push permissions.**
### A3. Create PR Body
Write the body to `/tmp/pr-body.md` using the template from `mem:workflow/creating-prs` (Description Body section):
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
```
### A4. Create the PR
```bash
gh pr create --base main --project "Main" --title "<title>" --body-file /tmp/pr-body.md
```
---
## Workflow B: Updating an Existing PR
Use this when a PR already exists and you need to change its title or description (or both).
### B1. Find the PR Number
```bash
BRANCH=$(git branch --show-current)
gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number'
```
If the result is empty, there is no open PR for this branch — use Workflow A instead.
If multiple PRs match, pick the first one (typically there should only be one).
### B2. Determine What to Update
- **Title only:** use `--title "<new title>"`
- **Description (body) only:** write to `/tmp/pr-body.md` and use `--body-file /tmp/pr-body.md`
- **Both title and description:** supply both flags
### B3. Prepare Description (if updating)
Use the same template from `mem:workflow/creating-prs`:
```bash
cat > /tmp/pr-body.md << 'EOF'
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
EOF
```
### B4. Update the PR
```bash
gh pr edit <NUMBER> --title "<title>" --body-file /tmp/pr-body.md
```
Omit either flag if only one field is being updated.
**The LLM has permissions to edit PRs. No push required.**
+227
View File
@@ -0,0 +1,227 @@
---
name: gh-issue-from-pr
description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type.
---
# Skill: gh-issue-from-pr
Create a GitHub issue that captures the **WHAT** (user-facing feature or
bug) from an existing PR that describes the **HOW** (implementation).
Used when the project board needs an issue as the primary changelog/release unit.
## When to Use
- Create a tracking issue from a PR for changelog purposes
- Extract the user-facing problem/feature from a PR's implementation details
- Assign milestone, project, labels, and issue type to a new issue derived from a PR
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Permission to create issues and edit PRs in the target repository
## Workflow
### 1. Understand the PR
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
```
Identify:
- **WHAT** — user-facing problem or feature. Goes into the issue.
Describe symptoms and impact, not internal mechanisms.
- **HOW** — implementation details. These belong in the PR, not the issue.
### 2. Determine metadata
| Field | Source | Rule |
|-------|--------|------|
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. |
| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). |
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. |
### 3. Write the issue body
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
### 4. Create the issue
Write the body to a temp file to avoid shell quoting issues:
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
```
Create:
```bash
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
### 5. Assign to the PR author
Assign the issue to the PR author so they're responsible for it:
```bash
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
```
### 6. Set the Issue Type
`gh issue create` can't set the Issue Type directly. Use GraphQL.
Get the issue's GraphQL node ID:
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
```
Issue Type IDs for the Penpot repo:
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
Set it:
```bash
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
### 7. Verify
```bash
gh issue view <ISSUE_NUMBER> --repo penpot/penpot \
--json title,milestone,projectItems,labels \
--jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
### 8. Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue
— it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
### 9. Clean up
```bash
rm -f /tmp/issue-body.md /tmp/pr-body.md
```
## Label rules
| PR has | Issue gets |
|--------|-----------|
| `community contribution` | `community contribution` |
| `bug`, `enhancement` | *(skip — redundant with Issue Type)* |
| `backport candidate` | *(skip — workflow label)* |
| `team-qa` | *(skip — workflow label)* |
## Issue Type mapping
| PR label(s) / title prefix | Issue Type |
|----------------------------|-----------|
| `bug` or `:bug:` | Bug |
| `enhancement` or `:sparkles:` or `:tada:` | Enhancement |
| Feature / epic | Feature |
| Documentation | Docs |
| None of the above | Task |
## Key Principles
- **Issue = WHAT, PR = HOW.** Never put implementation details in the
issue body. The issue is for users, QA, and changelog readers.
- **Copy the milestone from the PR.** Don't guess based on branch names.
If the PR has no milestone, create the issue without one.
- **Set Issue Type via GraphQL** — `gh issue create` can't set it.
- **Link via PR body** — `Closes #<NUMBER>` creates the "Development"
sidebar link automatically.
- **One issue per PR** — even if a PR fixes multiple things, create a
single issue that summarizes the overall change.
- **Community attribution:** if the PR has the `community contribution`
label or the author is not a core team member, add the label to the issue.
+123
View File
@@ -0,0 +1,123 @@
---
name: issue-title
description: Derive a clear, well-formatted title for a GitHub issue from its description body, using descriptive present-tense for bugs and imperative mood for features, always including the "where" (location in the UI/module).
---
# Skill: issue-title
Derive a concise, descriptive title for a GitHub issue based on its body
content. Use **descriptive present tense for bugs** (e.g. "Plugin API
crashes when setting text fills") and **imperative mood for features** (e.g.
"Add customizable dash and gap controls"). No emoji or type prefixes
(`feat:`, `bug:`, `feature:`, etc.).
Can be used both when **creating a new issue** and when **updating an
existing one** that has a vague or outdated title.
## When to Use
- Creating a new issue and need a well-formatted title from the draft body
- An existing issue has a vague, outdated, or auto-generated title (e.g.
`[PENPOT FEEDBACK]: ...`, `feature: ...`)
- The current title doesn't reflect the actual content of the description
- The title is missing the "where" (which part of the UI/module is affected)
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
## Workflow
### 1. Get the issue body
For an **existing issue**, fetch it:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title,body
```
For a **new issue**, read the draft body from wherever it was provided
(Taiga link, user report, discussion, etc.).
### 2. Read the body and derive a title
Extract the core problem or request from the description. Distinguish between
bug reports and feature requests:
**Bug titles (descriptive, present tense):**
Describe the symptom as it appears to the user. Format:
`[Where] [present-tense verb] when [condition]`
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
- *"Text layer content is not deleted when WebGL render is enabled"*
Do **not** start bug titles with "Fix" or any imperative verb. The title
should state what's broken, not command a fix.
**Feature / Enhancement titles (imperative mood):**
Command what should be built. Format:
`[Imperative verb] [what] in/on [where]`
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
- *"Validate shape on add-object to catch malformed inputs early"*
**Universal rules (both types):**
- **Include the "where"** — specify the UI location or module (e.g.
"in the sidebar", "in the workspace history panel", "on the stroke
options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`,
`[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality. If the
description mentions two related problems, capture both.
**Examples:**
| Original / draft title | Type | New title |
|---|---|---|
| `[PENPOT FEEDBACK]: WebGL` | Bug | `Canvas renders glitches when zooming quickly — text appears distorted and nodes have background-colored rectangles` |
| `bug: flatten-nested-tokens-json uses $type instead of $value as the DTCG token/group discriminator` | Bug | `Token import fails when group-level type inheritance is used — parser misidentifies groups as tokens` |
| `feature: Dashed stroke customization` | Feature | `Add customizable dash and gap length controls to dashed strokes in the sidebar` |
| `feature: Add more detail to history of actions` | Feature | `Show user, timestamp, and hash in the workspace history panel like git commits` |
### 3. Apply the title
**If updating an existing issue:**
```bash
gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>"
```
**If creating a new issue:**
```bash
gh issue create --repo penpot/penpot --title "<NEW TITLE>" --body "<BODY>"
```
### 4. Confirm
For updates, the command returns the issue URL. Verify by optionally fetching
again:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title
```
## Key Principles
- **Bug titles describe the symptom** — present tense, 3rd person:
"crashes", "fails", "shows", "is cut off", "does not load". Do not
start with "Fix" or "Bug:".
- **Feature titles use imperative mood** — command form: "Add", "Show",
"Use", "Validate", "Support", "Toggle".
- **Always include the "where"** — a title like "Crashes when zooming"
is too vague; "Canvas crashes when zooming quickly" is clear.
- **No prefixes, no emoji** — strip all type labels and decorative
characters from the title.
- **Derive from the body, not the current title** — the body contains
the real detail; the current title may be auto-generated or stale.
- **Two problems → cover both** — if the description has two distinct
but related issues, capture both in the title joined by "and".
+110
View File
@@ -0,0 +1,110 @@
---
name: taiga
description: Fetch information from Taiga public API for the Penpot project (id 345963) — issues, user stories, and tasks, without authentication.
metadata: {"clawdbot":{"requires":{"bins":["python3"]}}}
---
# Taiga API Skill
Fetch information from Taiga public API for the **Penpot** project
(project id: `345963`, slug: `penpot`).
**No authentication required** — only public project data is accessed.
## Prerequisites
- `python3` — the `tools/taiga.py` CLI script is self-contained (stdlib only)
## Quick Start
The easiest way is to use the bundled Python script:
```bash
# Pass a Taiga URL directly
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# Or use "<type> <ref>" syntax
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
# Add --json for raw output
python3 tools/taiga.py --json issue 13714
# See full usage
python3 tools/taiga.py --help
```
## URL Pattern Reference
Taiga web URLs follow these patterns:
| Type | Web URL Pattern |
|------|----------------|
| Issue | `https://tree.taiga.io/project/penpot/issue/<REF>` |
| User Story | `https://tree.taiga.io/project/penpot/us/<REF>` |
| Task | `https://tree.taiga.io/project/penpot/task/<REF>` |
To extract the **type** and **ref** from a URL:
- `issue/13714` → type=`issue`, ref=`13714`
- `us/14128` → type=`us`, ref=`14128`
- `task/13648` → type=`task`, ref=`13648`
## Python Script Reference
The `tools/taiga.py` script wraps the Taiga API into a single convenient CLI
with sensible defaults.
### Usage
```
python3 tools/taiga.py <taiga-url>
python3 tools/taiga.py <type> <ref>
python3 tools/taiga.py [--json] <taiga-url>
python3 tools/taiga.py [--json] <type> <ref>
```
### Examples
```bash
# By URL (recommended — no need to think about type/ref)
python3 tools/taiga.py https://tree.taiga.io/project/penpot/issue/13714
# By type and ref
python3 tools/taiga.py us 14128
python3 tools/taiga.py task 13648
# Raw JSON output
python3 tools/taiga.py --json issue 13714
```
### Output
The script prints a clean, structured summary:
```text
User Story #11964 — 🔴 [DESIGN TOKENS] Typography Composite Input
================================
Status: Defining
Milestone: design-systems-sprint-26
Points: 3 role(s)
Assignee: Natacha Menjibar
Author: Natacha Menjibar
Created: 2025-09-01
Tags: iop-design-tokens
URL: https://tree.taiga.io/project/penpot/us/11964
================================
<full description text, unmodified>
```
The fields section includes type-specific information:
- **Issues:** Status, Type ID, Severity ID, Priority ID
- **User Stories:** Status, Milestone, Points
- **Tasks:** Status, Milestone, Parent US
## Reference
- API docs: https://docs.taiga.io/api.html
- Taiga instance: https://tree.taiga.io
- API base: https://api.taiga.io/api/v1
- Penpot project id: `345963`
- Penpot project slug: `penpot`
+758
View File
@@ -0,0 +1,758 @@
---
name: update-changelog
description: Update the project CHANGES.md with issues from a given GitHub milestone, with correct categorization and references.
---
# Skill: update-changelog
Update `CHANGES.md` with entries for all issues and PRs in a given GitHub
milestone. Each entry references the user-facing issue (not the PR) as the
primary link, with the fix PR inline on the same line.
## When to Use
- Before a new release, to populate the changelog with all fixed issues
- When new issues are added to an existing milestone and the changelog needs
to be refreshed
- To ensure every entry follows the correct format for the changelog
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Python 3.8+
- `tools/gh.py` helper script available
## Workflow
### 1. Determine the target version
The version is typically a semver string like `2.15.3`. Confirm with the user
if not specified.
### 2. Fetch all issues in the milestone
Use the helper script. It uses GraphQL for efficient single-pass fetching
(closing PRs are included in the same query — no N+1):
```bash
# All closed issues (default)
python3 tools/gh.py issues "2.16.0"
# Include open issues too
python3 tools/gh.py issues "2.16.0" --state all
# Exclude entries that should not go in the changelog
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog"
```
**Exclusion rules (issue-level):**
- `no changelog` label — Chore/refactor work that doesn't need a changelog entry
- `release blocker` label — Blocked issues not yet ready for changelog
- `Task` issue type — Internal chores are not user-facing; automatically excluded by `gh.py`. Use `--include-tasks` to override.
- **Rejected project status** — Issues with a "Rejected" status in the "Main" project board are automatically excluded by `gh.py`. This project-level status (independent of the GitHub issue `state`) indicates the issue was rejected from the release. Use `--include-rejected` to override.
**Exclusion rules (PR-level):**
In addition to issue-level exclusions, PRs with these labels should be
excluded regardless of their linked issue's labels:
- `release blocker` — PR is part of a pending release blocker batch
- `no issue required` — Trivial fix not tracked as an issue
The script outputs JSON with each entry containing `number`, `title`, `state`,
`issue_type`, `labels`, `closing_prs` (the PRs that fix each issue), and
`project_status` (the "Main" project board status, e.g. "Done", "Rejected",
or `null` if not tracked in a project).
### 3. Identify missing entries (optional)
If updating from an existing `CHANGES.md`, find issues in the milestone that
are NOT yet referenced in the changelog:
```bash
python3 tools/gh.py issues "2.16.0" --exclude "release blocker,no changelog" --compare CHANGES.md
```
This returns a filtered JSON array with only the missing issues.
> **Note:** The `--compare` flag checks **issues** only (via issue number
> references in the changelog). To find merged **PRs** not yet referenced,
> use the milestone PR cross-reference described in step 10 below.
### 4. Fetch additional PR details when needed
When you need more context for specific PRs (e.g. to find the PR author for
community contribution attribution, or to read the PR body for
"Fixes/Closes #NNN" patterns):
```bash
# One or more PR numbers
python3 tools/gh.py prs 9179 9204 9311
# From a file
python3 tools/gh.py prs --file prs.txt
# From stdin
cat prs.txt | python3 tools/gh.py prs --stdin
```
The `prs` command also supports listing all PRs in a milestone in one call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
```
The `prs` command returns JSON with `number`, `title`, `body`, `state`,
`merged_at`, `author`, `labels`, and `closing_issues`. PRs are fetched in
batches of 50 via GraphQL to stay within API limits (milestone mode uses
paginated GraphQL on the milestone's `pullRequests` connection).
You can also list all PRs in a milestone in a single call:
```bash
# All merged PRs in a milestone (default)
python3 tools/gh.py prs --milestone "2.16.0"
# All states (merged, open, closed)
python3 tools/gh.py prs --milestone "2.16.0" --state all
# Open PRs only
python3 tools/gh.py prs --milestone "2.16.0" --state open
```
The milestone path uses paginated GraphQL on the milestone's `pullRequests`
connection (100 per page), avoiding one-by-one fetches.
### 5. Categorize entries — strictly by issue type, never by labels or emoji
Use the **Issue Type** field (GitHub's native issue type, exposed as
`issue_type` in the `gh.py` JSON output) to determine which section an entry
belongs to.
> **⚠️ CRITICAL: Never use labels or title emoji prefixes for categorization.**
> Labels like `bug` and `enhancement`, as well as title prefixes like `:bug:`
> and `:sparkles:`, are frequently inaccurate, missing, or contradictory to the
> actual issue type. The `issue_type` field from `gh.py` is the single source
> of truth.
| `issue_type` value | Changelog section |
|--------------------|-------------------|
| `Bug` | `### :bug: Bugs fixed` |
| `Feature` or `Enhancement` | `### :sparkles: New features & Enhancements` |
| `Task` | **Exclude** — internal chores are not user-facing |
| `null` (not set) | Check labels as a fallback: `bug` label → bugs, otherwise enhancements |
The `gh.py` issues command already includes `issue_type` in every entry's
output. **No separate GraphQL query is needed.**
**Community contribution attribution:** If the issue or its fix PR has the
`community contribution` label, add an attribution `(by @<github_username>)`
on the changelog entry line, **before** the GitHub issue/PR references.
The attribution should reference the **PR author**, not the issue author.
The `prs` subcommand includes the `author` field — use that:
```bash
python3 tools/gh.py prs <PR_NUMBER> | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['author'])"
```
Placement in the entry line:
```markdown
- Fix description of the bug (by @username) [#<ISSUE>](...) (PR: [#<PR>](...))
```
**Only closed issues are included.** An issue must have `state: "closed"` to
appear in the changelog. Open/unresolved issues are omitted, even if they are
tracked in the milestone.
**Pairing rules:**
| Pattern | Changelog format |
|---------|-----------------|
| Closed issue + one or more PRs fix it | Primary link = issue, PR inline comma-separated |
| PR exists with no linked issue | If a corresponding closed issue exists in the same milestone, link the issue. Otherwise, skip the entry (the issue must be the changelog unit). |
| Closed issue with no fix PR in milestone | Link the issue directly, without a PR reference. |
> **False-positive associations:** A PR may incorrectly claim to close an issue
> from a different context (e.g., a very old PR referencing a modern issue, or a
> cross-project reference). If the PR title and issue title are clearly unrelated,
> or the PR was created years before the issue, treat it as a data glitch and
> skip it. PR [#3](https://github.com/penpot/penpot/pull/3) (ancient License PR
> claiming to close a plugin API issue) is a known example.
### 5a. ⚠️ Verify PR merge status before writing
A closed issue may list closing PRs that were **closed without merging**
(e.g., a community PR that was superseded by another). The changelog must
only reference **merged** PRs. Verify before writing:
```bash
# Collect all PR numbers from the candidate entries and check them
python3 tools/gh.py prs <ALL_PR_NUMBERS> | python3 -c "
import json, sys
for pr in json.load(sys.stdin):
if pr['state'] != 'MERGED':
print(f'WARNING: #{pr[\"number\"]} is {pr[\"state\"]} (not merged)')
"
```
If a closing PR is closed-unmerged, find the actual merged PR that
superseded it:
1. Check the issue's closing PRs list for other PRs (there may be multiple)
2. Look for other PRs with similar titles or descriptions referencing the same issue
3. Inspect the closed PR's conversation timeline for a pointer to the replacement
Replace the reference in the changelog entry with the correct merged PR number.
### 6. Read the current CHANGES.md
Read the top of `CHANGES.md` to understand the existing format and find the
insertion point (newest version goes at the top, after the `# CHANGELOG`
header).
Key format rules from the existing file:
```markdown
## <VERSION>
### :bug: Bugs fixed
- Fix description of the bug [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- Fix another bug (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
### :sparkles: New features & Enhancements
- Add new feature description [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
Format details:
- Entries start with `- ` followed by a short description in imperative mood
- Primary link is **always the issue** (user-facing artifact)
- PR references are inline on the same line: `(PR: [#<N>](<url>))`
If an issue has multiple fix PRs, they are comma-separated:
`(PR: [#<N>](<url>), [#<M>](<url>))`
- The description should describe the fix/feature from the user's perspective
- Community contributions get `(by @<username>)` **before** the issue link
- Sections are separated by a blank line between the last entry and the next
section title
- Only include a section if there are entries for it
- When an entry already exists in an earlier version section, it must be removed
from the current version to avoid duplicates
### 6a. Pre-flight checks — fix rule violations in the changelog
**The LLM must apply these checks during the workflow and fix any
violations directly in `CHANGES.md`. They are not anomalies — they are
process errors that should be corrected before writing the new section.**
The changelog is a *snapshot* of the milestone at a point in time, but
milestones and changelog entries can drift. The LLM must reconcile the
existing changelog against the current state of the milestone and the
existing changelog entries.
For each entry that already exists in `CHANGES.md` (in any version
section) or in the candidate set for the current milestone, check:
1. **Duplicate across versions.** Is the same issue already documented
in another (older) version section? If yes, this is a *backport*:
- The user-facing fix was already released. Remove the duplicate
from the current section. The earlier version is the canonical
reference.
2. **Stale milestone assignment.** Has the issue been moved out of the
current milestone since the changelog was last updated (e.g., a fix
arrived late and the issue was reassigned to a future milestone)?
- Verify the issue is still in the current milestone via
`python3 tools/gh.py issues <MILESTONE> --state all`. If it's no
longer there, remove the entry from the current section. (If the
target section doesn't exist yet, the entry is simply dropped.)
3. **Exclusion labels newly applied.** Did the issue acquire a
`no changelog` or `release blocker` label since the changelog was
last updated? If yes, remove the entry from the current section.
4. **Issue state changed.** Is the issue still closed? Has it been
reopened, deleted, or moved to a `Rejected` project status? If yes,
remove the entry.
5. **Unmerged or removed PR references.** For every PR referenced in
the entry, is the PR still merged? Was the PR closed without
merging (superseded)? Was the PR moved to a different milestone?
If the only referenced PR is no longer merged, fix the reference
(find the actual merged fix PR) or remove the entry. A PR that is
merged in a *different* milestone is reported as an anomaly in
step 11 — do not silently remove it.
6. **Issue type changed.** Did the issue type change (e.g., from Bug to
Task)? If the new type is `Task`, the issue is internal and should
be removed.
7. **Cross-section completeness.** For every closed, non-excluded
milestone issue that is *not* referenced in any version section of
the changelog, add it to the current section (per the categorization
rules in step 5).
After these checks, the changelog should be internally consistent with
the milestone. **Do not defer these fixes to step 11 — they are
workflow errors, not anomalies.** Step 11 only reports milestone
mismatches that require human judgment about the team's release
intent.
### 7. Build the description text
Derive the description from the issue title, not the PR title. Strip leading
emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`) and focus on the
user-facing behavior.
Examples:
| Issue title | Changelog description |
|-------------|----------------------|
| `Plugin API token methods fail with schema validation error on PRO` | `Fix Plugin API token methods failing with schema validation error on PRO` |
| `Comment content is not sanitized before rendering, enabling stored XSS` | `Sanitize comment content on rendering` |
| `Custom uploaded font family names are not sanitized` | `Sanitize font family names on custom uploaded fonts` |
### 8. Insert the section into CHANGES.md
Insert the new version section right after the `# CHANGELOG` header (before
the previous version entry). Use the `edit` tool with enough context to make
a unique match.
### 9. Verify
Read the top of `CHANGES.md` and confirm:
- The version header is correct
- Every entry has a GitHub link
- Entries with a fix PR have the PR sub-line
- The section ordering is correct (newest first)
- Formatting matches the surrounding entries
### 10. Cross-reference milestone PRs against the changelog
Issues can be fixed by PRs that aren't in the milestone, and merged PRs in
the milestone may not close any tracked issue. After writing, run a full
cross-reference to catch gaps:
```bash
# List all merged PRs in the milestone
python3 tools/gh.py prs --milestone "<MILESTONE>" --state merged > /tmp/milestone-prs.json
# Extract PR numbers from the changelog section
python3 -c "
import json, re
with open('CHANGES.md') as f:
content = f.read()
# Extract the version section (adjust regex to match the actual version)
match = re.search(r'## <MILESTONE> \(Unreleased\)\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = match.group(1)
# Collect all PR numbers referenced
changelog_prs = set()
for m in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(m))
# Collect all milestone PRs (filtered)
with open('/tmp/milestone-prs.json') as f:
milestone_prs = json.load(f)
milestone_merged = {pr['number'] for pr in milestone_prs}
# PRs in milestone but not in changelog
missing = sorted(milestone_merged - changelog_prs)
print(f'Milestone merged PRs: {len(milestone_merged)}')
print(f'Changelog referenced PRs: {len(changelog_prs)}')
print(f'PRs in milestone but NOT in changelog: {len(missing)}')
for num in missing:
pr = next(p for p in milestone_prs if p['number'] == num)
print(f' #{num} {pr[\"title\"][:80]}')
"
```
For each missing PR found, decide whether it should be added to the
changelog or is legitimately excluded (check its labels).
Also verify that no closed-unmerged PRs remain in the changelog:
```bash
python3 tools/gh.py prs --milestone "<MILESTONE>" --state all | python3 -c "
import json, sys
data = json.load(sys.stdin)
closed = [p for p in data if p['state'] == 'CLOSED']
if closed:
print('WARNING: CLOSED (unmerged) PRs in milestone:')
for p in closed:
print(f' #{p[\"number\"]} {p[\"title\"][:80]}')
"
```
**Post-edit audit checklist:**
- ✅ All referenced PRs are merged (no closed-unmerged artifacts)
- ✅ Every merged milestone PR is either in the changelog or excluded by label
- ✅ PR and issue counts are internally consistent
- ✅ No false-positive PR-to-issue associations
## Version section template
```markdown
## <VERSION>
### :bug: Bugs fixed
- <fix description> [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
- <fix description> (by @contributor) [#<ISSUE>](https://github.com/penpot/penpot/issues/<ISSUE>) (PR: [#<PR>](https://github.com/penpot/penpot/pull/<PR>))
```
### 11. Generate anomaly report and save to CHANGES-ISSUES.md
After all edits and cross-referencing are complete, generate a structured
report and save it to `CHANGES-ISSUES.md` (overwriting if exists).
This provides a persistent record of any discrepancies between the milestone
and the changelog.
**Every issue and PR number in the report must be rendered as a full GitHub
Markdown link** using the same URL format as `CHANGES.md`:
- Issue N → `[#N](https://github.com/penpot/penpot/issues/N)`
- PR N → `[#N](https://github.com/penpot/penpot/pull/N)`
The titles and notes should also link to the corresponding issue/PR page
where applicable, so the report is self-contained and clickable from any
Markdown viewer.
## What is an anomaly
**An anomaly is a milestone-mismatch between an issue and its referenced
PR.** It indicates that the changelog claim "this issue is fixed by this PR,
all in milestone M" is inconsistent with the actual milestone assignments.
There are exactly two types:
1. **Issue is in the milestone, but its referenced PR is in a different
milestone (or has no milestone).** The changelog claims a fix in this
release, but the PR is being released elsewhere — the fix may not
actually ship here.
2. **PR is in the milestone, but the issue it closes is in a different
milestone (or has no milestone).** The PR is being released here, but
the issue it fixes is being released in a different version (or never
tracked in a milestone) — the changelog pairing is misleading.
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
references, duplicates across versions, stale milestone assignments)
are **rule violations** that the LLM must fix directly in `CHANGES.md`
during step 6a (pre-flight checks). They should not appear in this
report — if they do, the LLM has skipped the pre-flight step and
needs to re-run the workflow.
The changelog's primary unit is the **issue**, not the PR, so a missing or
mismatched PR only matters when its issue is part of this milestone.
Run this self-contained script:
```bash
python3 << 'PYEOF'
import json, re, subprocess, sys
from datetime import datetime, timezone
MILESTONE = "<MILESTONE>"
CHANGES_MD = "CHANGES.md"
OUTPUT = "CHANGES-ISSUES.md"
REPO = "penpot/penpot"
# --- URL helpers (match CHANGES.md format exactly) ---
def issue_url(n): return f"https://github.com/{REPO}/issues/{n}"
def pr_url(n): return f"https://github.com/{REPO}/pull/{n}"
def issue_link(n): return f"[#{n}]({issue_url(n)})"
def pr_link(n): return f"[#{n}]({pr_url(n)})"
def issue_link_title(n, title):
url = issue_url(n)
if title:
return f"[#{n}]({url}) — [{title}]({url})"
return f"[#{n}]({url})"
def pr_link_title(n, title):
url = pr_url(n)
if title:
return f"[#{n}]({url}) — [{title}]({url})"
return f"[#{n}]({url})"
def fmt_pr_list(nums):
return ", ".join(pr_link(n) for n in nums)
def fmt_issue_list(nums):
return ", ".join(issue_link(n) for n in nums)
# --- Fetch milestone data ---
result = subprocess.run(
["python3", "tools/gh.py", "issues", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_issues = json.loads(result.stdout)
issue_by_num = {i['number']: i for i in all_issues}
result = subprocess.run(
["python3", "tools/gh.py", "prs", "--milestone", MILESTONE, "--state", "all"],
capture_output=True, text=True)
all_prs = json.loads(result.stdout)
pr_by_num = {p['number']: p for p in all_prs}
# --- Read changelog section ---
with open(CHANGES_MD) as f:
content = f.read()
m = re.search(rf'## {re.escape(MILESTONE)}(?:\s*\([^)]*\))?\n(.*?)(?:\n## |\Z)', content, re.DOTALL)
section = m.group(1) if m else ""
changelog_issues = set()
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/issues/\d+\)', section):
changelog_issues.add(int(num))
for num in re.findall(r'\[Github #(\d+)\]', section):
changelog_issues.add(int(num))
changelog_prs = set()
for num in re.findall(r'\[#(\d+)\]\(https://github\.com/penpot/penpot/pull/\d+\)', section):
changelog_prs.add(int(num))
for num in re.findall(r'PR:\[(\d+)\]', section):
changelog_prs.add(int(num))
# --- Milestone lookup caches ---
# PRs and issues returned by milestone queries are KNOWN to be in MILESTONE.
# For everything else, fall back to `gh` per-item lookups.
pr_milestone_cache = {p['number']: MILESTONE for p in all_prs}
issue_milestone_cache = {i['number']: MILESTONE for i in all_issues}
def get_pr_milestone(pr_num):
"""Return the milestone title for a PR, or None if unassigned / unknown."""
if pr_num in pr_milestone_cache:
return pr_milestone_cache[pr_num]
try:
r = subprocess.run(
["gh", "pr", "view", str(pr_num), "--json", "milestone"],
capture_output=True, text=True, check=True)
data = json.loads(r.stdout)
ms = data.get('milestone')
pr_milestone_cache[pr_num] = (ms or {}).get('title')
except (subprocess.CalledProcessError, json.JSONDecodeError):
pr_milestone_cache[pr_num] = None
return pr_milestone_cache[pr_num]
def get_issue_milestone(issue_num):
"""Return the milestone title for an issue, or None if unassigned / unknown."""
if issue_num in issue_milestone_cache:
return issue_milestone_cache[issue_num]
try:
r = subprocess.run(
["gh", "issue", "view", str(issue_num), "--json", "milestone"],
capture_output=True, text=True, check=True)
data = json.loads(r.stdout)
ms = data.get('milestone')
issue_milestone_cache[issue_num] = (ms or {}).get('title')
except (subprocess.CalledProcessError, json.JSONDecodeError):
issue_milestone_cache[issue_num] = None
return issue_milestone_cache[issue_num]
# --- Exclusion rules (shared) ---
EXCLUDED_LABELS = {'release blocker', 'no changelog'}
EXCLUDED_ISSUE_TYPES = {'Task'}
EXCLUDED_PROJECT_STATUS = {'Rejected'}
def issue_excluded(issue):
if not issue: return True
if issue.get('state') != 'CLOSED': return True
if issue.get('issue_type') in EXCLUDED_ISSUE_TYPES: return True
if issue.get('project_status') in EXCLUDED_PROJECT_STATUS: return True
if EXCLUDED_LABELS & set(issue.get('labels', [])): return True
return False
# --- ANOMALIES: milestone mismatches between issues and their referenced PRs ---
# These are the ONLY items that should appear in the report. All other
# discrepancies (exclusion labels, missing valid issues, unmerged PRs,
# duplicates, stale milestone assignments) are workflow errors that the
# LLM must fix in step 6a (pre-flight checks) — they are not anomalies.
# Type A: issue in MILESTONE, referenced PR in different milestone or no milestone
anomalies_a = [] # list of dicts: {issue, issue_title, pr, pr_milestone}
for issue_num in sorted(changelog_issues):
issue = issue_by_num.get(issue_num)
if not issue: continue
if get_issue_milestone(issue_num) != MILESTONE: continue
for pr_num in issue.get('closing_prs', []):
pr_ms = get_pr_milestone(pr_num)
if pr_ms != MILESTONE:
anomalies_a.append({
'issue': issue_num,
'issue_title': issue.get('title', ''),
'pr': pr_num,
'pr_milestone': pr_ms, # may be None
})
# Type B: PR in MILESTONE, the issue it closes is in different milestone or no milestone
anomalies_b = [] # list of dicts: {pr, pr_title, issue, issue_milestone}
for pr_num in sorted(changelog_prs):
pr = pr_by_num.get(pr_num)
if not pr: continue
if get_pr_milestone(pr_num) != MILESTONE: continue
for issue_num in pr.get('closing_issues', []):
issue_ms = get_issue_milestone(issue_num)
if issue_ms != MILESTONE:
anomalies_b.append({
'pr': pr_num,
'pr_title': pr.get('title', ''),
'issue': issue_num,
'issue_milestone': issue_ms, # may be None
})
# --- Write report ---
def fmt_ms(ms):
return ms if ms else "_none_"
with open(OUTPUT, 'w') as f:
f.write(f'# Changelog Anomaly Report — {MILESTONE}\n\n')
f.write(f'Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}\n\n')
f.write('---\n\n')
n_a = len(anomalies_a)
n_b = len(anomalies_b)
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
# --- Anomalies section ---
if n_a or n_b:
f.write('## Anomalies\n\n')
f.write('These are milestone mismatches between an issue in the changelog '
'and its referenced PR (or vice-versa). The changelog claim '
'"this issue is fixed by this PR, all in this milestone" is '
'inconsistent with the actual milestone assignments. '
'Resolve by either updating the milestone on the issue/PR or '
'removing the misleading entry from the changelog.\n\n')
if n_a:
f.write(f'### Issue in {MILESTONE}, PR in different milestone or no milestone\n\n')
by_issue = {}
for a in anomalies_a:
by_issue.setdefault(a['issue'], []).append(a)
for issue_num in sorted(by_issue):
entries = by_issue[issue_num]
title = entries[0]['issue_title']
f.write(f'- {issue_link_title(issue_num, title[:80])}\n')
for e in entries:
ms_label = fmt_ms(e['pr_milestone'])
badge = '🔴' if e['pr_milestone'] is None else '⚠️'
f.write(f' - {badge} Referenced {pr_link(e["pr"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n')
if n_b:
f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n')
by_pr = {}
for b in anomalies_b:
by_pr.setdefault(b['pr'], []).append(b)
for pr_num in sorted(by_pr):
entries = by_pr[pr_num]
title = entries[0]['pr_title']
f.write(f'- {pr_link_title(pr_num, title[:80])}\n')
for e in entries:
ms_label = fmt_ms(e['issue_milestone'])
badge = '🔴' if e['issue_milestone'] is None else '⚠️'
f.write(f' - {badge} Closing {issue_link(e["issue"])} is in milestone **{ms_label}** (expected: {MILESTONE})\n')
f.write('\n')
else:
f.write('✅ No anomalies found. All (issue, PR) pairs in the changelog have aligned milestone assignments.\n\n')
# --- Context ---
f.write('---\n\n')
f.write('## Context\n\n')
f.write(f'- Milestone: **{MILESTONE}**\n')
f.write(f'- Milestone total issues (all states): {len(all_issues)}\n')
f.write(f'- Closed issues in milestone: {sum(1 for i in all_issues if i.get("state") == "CLOSED")}\n')
f.write(f'- Valid issues after exclusions (after step 5/6a): {len([i for i in all_issues if not issue_excluded(i)])}\n')
f.write(f'- Issues referenced in changelog: {len(changelog_issues)}\n')
f.write(f'- PRs referenced in changelog: {len(changelog_prs)}\n')
print(f"Anomaly report written to {OUTPUT}")
PYEOF
```
This generates `CHANGES-ISSUES.md` containing **only the anomalies**
milestone mismatches between issues and their referenced PRs:
1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere.
2. **PR in milestone, closing issue in different milestone or no milestone**
the PR is released here, but the issue it fixes belongs to another version.
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
If the report contains a rule violation, the LLM has skipped the pre-flight
step and needs to re-run the workflow before re-generating the report.
The report is overwritten each time it's generated, reflecting the current
state of the milestone and changelog. Every number is rendered as a full
`[#N](https://github.com/penpot/penpot/issues/N)` or
`[#N](https://github.com/penpot/penpot/pull/N)` link so the report is
self-contained and clickable in any Markdown viewer.
## Key Principles
- **Issue = changelog unit.** The primary link always points to the
user-facing issue, not the implementation PR.
- **PR = implementation detail.** Reference the PR inline so readers
can find the code changes.
- **Latest version first.** New sections are inserted at the top of the
changelog, below the `# CHANGELOG` header.
- **Issue Type determines section — exclusively.** Use the `issue_type` field from `gh.py` output (Bug → `:bug:`, Feature/Enhancement → `:sparkles:`). **Do not** use labels (`bug`, `enhancement`) or title emoji prefixes (`:bug:`, `:sparkles:`) — they are frequently wrong or contradictory. The `issue_type` is the single source of truth.
- **User-facing descriptions.** Write from the user's perspective — describe
what broke and what was fixed, not internal implementation details.
- **Community attribution.** When the issue or fix PR has the
`community contribution` label, add `(by @<username>)` on the entry line
between the description and the issue link. Use the **PR author** (not the
issue author) for the attribution.
- **Only closed issues.** An issue must have `state: "closed"` to appear in
the changelog. Open/unresolved issues are omitted.
- **Rejected project status.** Issues marked as "Rejected" in the "Main"
project board are automatically excluded by `gh.py`, even if they are
closed. The project status is distinct from the GitHub issue state.
Use `--include-rejected` to override this behavior.
- **Excluded issues.** Issues with `no changelog` label must be excluded.
Issues with `issue_type: "Task"` must also be excluded — they are internal
chores, not user-facing changes.
- **Multiple PRs per issue.** If multiple PRs fix the same issue, list them
comma-separated inline: `(PR: [#A](url), [#B](url))`.
- **Duplicate removal.** If an entry already exists in a prior version section,
remove it from the current version. Check for text-level duplicates (after
stripping links and attributions) across version sections.
- **Taiga references.** If a changelog entry references a Taiga URL
(`tree.taiga.io`), attempt to find a corresponding GitHub issue via the
Taiga description text or by searching GitHub PRs that reference the Taiga
URL. Replace the Taiga reference with the GitHub issue link and add the PR
reference if applicable.
- **Re-fetch before editing.** Milestones can change — always re-fetch issues
before making edits, don't rely on cached data.
- **Use `tools/gh.py`.** Prefer the helper script over raw `gh api` calls for
milestone issue listing and PR detail fetching. It handles GraphQL
pagination, batching, and label filtering automatically.
- **Verify PR merge status.** Not all closing PRs are merged — community PRs
can be superseded and closed without merging. Always check that every PR
referenced in the changelog has `state: MERGED`.
- **PR-level exclusions apply.** A PR can carry its own exclusion labels
(`release blocker`, `no issue required`) independent of its linked issue's
labels. Check both.
- **Cross-reference milestone PRs, not just issues.** The `--compare` flag on
the `issues` command only compares issue numbers. Merged PRs not linked to
any milestone issue can be missed. Use `python3 tools/gh.py prs --milestone`
for a full PR cross-reference.
- **False-positive PR-to-issue associations.** A PR may claim to close an
issue from a different project or context. If the PR title and issue title
are clearly unrelated, or the PR predates the issue by years, treat it as a
data glitch and skip it.
- **Anomaly = milestone mismatch only.** The report contains only milestone
mismatches: (1) the issue is in this milestone but the referenced PR is
in a different milestone (or unassigned), and (2) the PR is in this
milestone but the issue it closes is in a different milestone (or
unassigned). These are anomalies because the changelog pairing is
*misleading* — the human needs to decide whether the milestone or the
changelog is wrong. All other discrepancies (exclusion labels, missing
valid issues, unmerged PR references, duplicates, stale milestone
assignments) are **rule violations** that the LLM must fix directly in
`CHANGES.md` during step 6a (pre-flight checks). They never appear in
the report — if they do, the pre-flight step was skipped.
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
@@ -0,0 +1,40 @@
# Backend Auth, Permissions, and Product Domain Subtleties
## Auth and sessions
- Main auth RPC commands live in `app.rpc.commands.auth`; LDAP and OIDC provider logic live in `app.auth.ldap` and `app.auth.oidc`, with LDAP-specific RPC checks in `app.rpc.commands.ldap`.
- Public auth endpoints must explicitly set `::rpc/auth false`; RPC auth defaults to enabled. Session cookie creation/deletion is usually attached as an RPC response transform.
- Basic Penpot registration is token staged: prepare/register creates or verifies temporary tokens, then profile creation/session setup is reused by other auth backends. The frontend `/auth/verify-token` flow is a hub for registration confirmation, email change, and invitation tokens.
- OIDC-compatible providers share a generic flow: redirect to provider, validate callback/request token, fetch identity data, then login an existing profile or register a new one. Known providers may have hardcoded endpoints; generic OIDC can use discovery/configured endpoints.
- LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow.
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`.
## Permission model
- `app.rpc.permissions` provides predicate/check factories. Failed permission checks intentionally raise `:not-found` / `:object-not-found`, not an authorization-specific error, to avoid leaking object existence.
- Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read.
- File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics.
- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries.
## Teams, projects, and invitations
- Team/project commands mix DB changes, email, message bus notifications, media/storage cleanup, feature flags, quotas, and audit metadata. Keep mutations transactional when the existing command does so.
- Invitation flows validate muted/bounced emails before sending and use tokenized invitation state. Accepting an invitation is tied to the invited member identity, not just possession of a token.
- Logical deletion is used for many product objects; prefer existing logical-deletion helpers over hard deletes unless the command already performs permanent cleanup.
- Bounced/spam-complaint emails can mute/block a profile for login/registration and email sending. Devenv MailCatcher is the normal local path for registration/email-flow testing.
## Comments, webhooks, and audit
- Comment thread queries join file/project/profile state and exclude deleted files/projects. Unread comment counts depend on `comment_thread_status.modified-at` and profile notification preferences.
- Webhook edits are allowed for team editors/admins or the webhook creator. Webhook validation performs a synchronous HEAD request with a short timeout; validation errors are mapped through `app.loggers.webhooks`.
- Audit events are prepared from RPC metadata, result metadata, params, request context, and selected auth identifiers. Webhook event batching can be controlled through audit/webhook metadata on commands or results.
- Webhook and audit logging are cross-cutting side effects of product commands; when adding a command, check nearby command metadata and result metadata patterns before inventing a new event shape.
## Local testing notes
- Enable LDAP login locally with frontend flag `enable-login-with-ldap`; the devenv includes a configured test LDAP service.
- OIDC testing requires external provider app credentials plus matching backend/frontend config.
- Backend domain tests usually live under `backend/test/backend_tests/rpc/commands/*_test.clj` or nearby backend test namespaces. Use focused `clojure -M:dev:test --focus ...` from `backend/` when possible.
- For auth/session or HTTP behavior, combine backend tests with the HTTP/session notes in `mem:backend/http-storage-filedata-subtleties` because RPC-level tests may not exercise cookie/header transforms.
+105
View File
@@ -0,0 +1,105 @@
# Backend Architecture and Workflow
Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; mail; audit/logging; workers.
## Focused memories
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
## Stable namespace map
- `app.rpc.commands.*`: RPC command implementations exposed under `/api/rpc/command/<cmd-name>`.
- `app.rpc.permissions`: permission predicate/check helper factories.
- `app.http.*`: HTTP routes and middleware.
- `app.auth.*`: provider-specific authentication helpers such as LDAP/OIDC.
- `app.loggers.*`: audit, webhook, database, and external log integrations.
- `app.db.*` / `app.db`: next.jdbc wrapper and SQL helpers.
- `app.tasks.*`: background task handlers.
- `app.worker`: task execution/cron plumbing.
- `app.main`: Integrant system map and component wiring.
- `app.config`: `PENPOT_*` env config and feature flags.
- `app.srepl.*`: development REPL helpers for manual backend operations (data inspection, migration helpers, one-off admin tasks).
- `app.nitrate`, `app.rpc.commands.nitrate`, and `app.rpc.management.nitrate`: external Nitrate subscription/organization integration, gated by the `:nitrate` feature flag and shared-key HTTP calls.
## RPC conventions
RPC commands are defined with `app.util.services/defmethod` and schemas. Use `get-` prefixes for read operations. Command metadata usually includes auth, docs version, params schema, and result schema. Return plain maps/vectors or raise structured exceptions from `app.common.exceptions`.
Backend RPC command areas without focused memories include access tokens, binfile, demo, feedback, file snapshots, fonts, management, Nitrate, and webhooks beyond the notes in `mem:backend/auth-permissions-product-domains`; inspect nearby command tests and command metadata before changing them.
## DB conventions
`app.db` helpers accept cfg, pool, or conn in most places and convert kebab-case to snake_case:
- `db/get`, `db/get*`, `db/query`, `db/insert!`, `db/update!`, `db/delete!`.
- Use `db/run!` for multiple operations on one connection.
- Use `db/tx-run!` for transactions.
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
## Background tasks
A task handler is an Integrant component with `ig/assert-key`, `ig/expand-key`, and `ig/init-key`, returning the function run by the worker. New tasks also need wiring in `app.main`: handler config, worker registry entry, and cron entry if scheduled.
For worker dispatch, cron, retry semantics, deduplication, and queue internals: `mem:backend/rpc-db-worker-subtleties`.
## REPL
In devenv, backend nREPL is exposed on port 6064.
### Non-interactive eval (preferred for agents)
`./tools/nrepl-eval.mjs` connects to an already-running nREPL server and evaluates code. Session state (defs, `in-ns`) persists across invocations via a stored session ID in `/tmp/penpot-nrepl-session-<host>-<port>`.
```bash
./tools/nrepl-eval.mjs '(+ 1 2)' # single expression
./tools/nrepl-eval.mjs "(require '[my.ns :as ns] :reload)" # reload after edits
./tools/nrepl-eval.mjs -e # inspect last exception (*e)
./tools/nrepl-eval.mjs --reset-session '(def x 0)' # discard session, start fresh
./tools/nrepl-eval.mjs <<'EOF' # multi-expression heredoc
(def x 10)
(+ x 20)
EOF
```
Default port is 6064. Use `-p <PORT>` for a different port. Use `-t <MS>` to override the 120s timeout. Do not start the nREPL server — assume it is already running.
### Interactive REPL
`backend/scripts/nrepl` starts a REPLy client connected to the running nREPL.
For an in-process backend REPL (where you control the JVM lifecycle), stop the running backend first so port 9090 is free, then run `backend/scripts/repl`. Useful top-level helpers include `(start)`, `(stop)`, `(restart)`, `(run-tests)`, and `(repl/refresh-all)`. Many `app.srepl.main` helpers accept the global `system` var, e.g. manual email or maintenance operations.
## Fixtures
Fixtures can populate local data for manual testing/perf work. From the backend REPL, run `(app.cli.fixtures/run {:preset :small})`; fixture users conventionally look like `profileN@example.com` with password `123123`. Standalone fixture aliases may exist, but check current `backend/deps.edn` before relying on old command names.
## Performance
* **Type Hinting:** Use explicit JVM type hints (e.g. `^String`, `^long`) in performance-critical paths to avoid reflection overhead.
* **Batch inserts:** Use `db/insert-many!` for bulk row inserts — generates a single SQL with multiple parameter tuples. Avoid on very large datasets (SQL length / parameter count limits).
* **Server-side cursors:** Use `db/plan` (fetch-size 1000, forward-only, read-only) or `db/cursor` for large result sets. Never fetch large collections into memory at once.
* **Transaction discipline:** Use `tx-run!` for writes (opens a transaction), `run!` for reads (single connection, no transaction). Set `:read-only` on `tx-run!` when applicable to let PostgreSQL optimize.
## Lint and Format
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Linting:** `pnpm run lint` from the repository root.
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:tools/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
* **Coverage:** If code is added or modified in `src/`, corresponding tests in `test/backend_tests/` must be added or updated.
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
@@ -0,0 +1,31 @@
# Backend HTTP, Storage, Media, and File Data Subtleties
## Config and HTTP/session middleware
- `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them.
- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag.
- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags.
- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`.
- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling.
- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens.
- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects.
- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids.
- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config.
## Storage and media
- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
- Font processing shells out to FontForge and WOFF conversion tools and can derive TTF/OTF/WOFF variants from uploaded fonts.
## File data persistence
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
@@ -0,0 +1,26 @@
# Backend RPC/DB/Worker Subtleties
## RPC exposure and wrappers
- RPC commands are discovered from vars created by `app.util.services/defmethod`; adding a command namespace is not enough unless `backend/src/app/rpc.clj` includes it in `resolve-methods`.
- `GET`/`HEAD` RPC calls are only allowed for method names starting with `get-`. Other methods are method-not-allowed even if they are read-only internally.
- RPC auth defaults to enabled. Public endpoints must set `::auth false` metadata explicitly.
- The wrapper stack does auth before params validation, then auditing/rate/concurrency/metrics/retry/condition handling, with DB transaction handling inside that stack. `::db/transaction` metadata controls transaction wrapping.
- Params with `::sm/params` are decoded/conformed through the JSON transformer and successful IObj results get `:encode/json` metadata. Legacy spec conforming only applies when no Malli params schema exists.
- Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set.
## DB helpers
- Most `app.db` helpers accept a pool, connection, or map containing `::db/pool` / `::db/conn`; preserve that convention in shared code.
- `db/tx-run!` uses `next.jdbc.transaction/*nested-tx* :ignore`: nested transaction calls reuse the outer transaction, not a savepoint. Use explicit savepoints when nested rollback semantics matter.
- `db/run!` opens/reuses one connection but does not create a transaction.
- `db/tjson` is Transit JSON for jsonb storage; `db/json` is plain JSON. Worker task props use Transit and are decoded with `decode-transit-pgobject`.
- Advisory transaction locks accept UUIDs or ints. UUID locks are hashed using a zero-UUID seeded siphash.
## Workers and cron
- Task queues are tenant-prefixed. Submit dedupe only removes not-yet-due `new` tasks with the same name/queue/label; it does not dedupe due, scheduled, retry, running, or completed work.
- The dispatcher selects `new`/`retry` tasks with `FOR UPDATE SKIP LOCKED`, marks them `scheduled`, and publishes Redis payload `[id scheduled-at]`. The runner skips Redis messages whose scheduled timestamp no longer matches DB state.
- Lost `scheduled` tasks are rescheduled after about 5 minutes; `running` tasks older than about 24 hours are marked failed as orphans.
- A task handler that is missing or returns an invalid result currently defaults to completed after warning. Throwing with `ex-data :type ::retry` controls retry behavior; `:strategy ::noop` retries without incrementing retry count.
- Cron jobs lock their `scheduled_task` row with `FOR UPDATE SKIP LOCKED`, disable statement/idle-in-transaction timeouts locally, and reschedule themselves in `finally` unless interrupted. Worker, dispatcher, and cron components do not start when the DB pool is read-only.
@@ -0,0 +1,39 @@
# File Mutations: Changes and Undo Architecture
Penpot mutates file data through change records. A change set is both the persistence payload and the basis for undo/redo, so UI actions, tests, backend file updates, and library/file tooling should drive the production change pipeline instead of ad hoc object-map mutation.
## Change shape
Each change is a map such as `{:type ... :id ... :page-id ...}`. Common families:
- `:add-obj`, `:mod-obj`, `:del-obj`: shape lifecycle. `:mod-obj` contains `:operations`, commonly `{:type :set :attr ... :val ... :ignore-geometry ... :ignore-touched ...}` or `{:type :set-touched ...}`.
- `:add-component`, `:mod-component`, `:del-component`: component/library metadata.
- `:add-children`, `:remove-children`, `:reg-objects`: tree and object-map edits.
- `:set-option`, `:add-page`, `:mov-page`, and related file/page metadata changes.
Each transaction carries `:redo-changes` and inverse `:undo-changes`. The undo stack stores transactions and can move its index backward/forward.
## changes-builder API
`common/src/app/common/files/changes_builder.cljc` (usually alias `pcb`) is the fluent builder. Start from `(pcb/empty-changes <it> <page-id>)` or `(pcb/empty-changes nil <page-id>)` for tests.
High-value builder operations:
- `pcb/with-page-id`, `pcb/with-objects`, `pcb/with-library-data`: set context for following operations.
- `pcb/update-shapes ids update-fn`: emits `:mod-obj` with diff-derived `:set` ops. Options include `{:with-objects? true}`, `{:ignore-touched true}`, and `{:attrs #{...}}`.
- `pcb/add-objects`, `pcb/change-parent`, `pcb/remove-objects`, `pcb/resize-parents`: shape/tree edits.
- `pcb/add-component`, `pcb/update-component`, `pcb/mod-component`: component/library edits.
- `pcb/set-translation? true`: marks the whole change set as translation-only, which lets component sync skip expensive work.
## Applying changes in tests
`thf/apply-changes` in `app.common.test-helpers.files` is the test analog of the production applier. It validates by default; pass `:validate? false` only for intentionally-invalid intermediate states.
The applier uses the same `process-operation` multimethod as production (`common/src/app/common/files/changes.cljc`), so tests that use it exercise production behavior.
## :touched and geometry
For component touched semantics and sync groups, read `mem:common/component-data-model`. For the exact `set-shape-attr` / second-pass behavior during change application, read `mem:common/file-change-validation-migration-subtleties`. For transform-specific ignore-geometry behavior, read `mem:frontend/workspace-transform-subtleties`.
## Inspection
To inspect what a UI action emitted, use `mem:frontend/cljs-repl` with the snippets in `mem:common/component-debugging-recipes` rather than adding temporary source instrumentation.
@@ -0,0 +1,41 @@
# Component and Variant Data Model
## Shape roles relative to components
A shape can occupy multiple roles at once:
1. Master/main instance: defines a component and has `:main-instance true` plus `:component-id`.
2. Copy/non-main instance: produced by instantiating a component and carries `:shape-ref` pointing at the master shape. `(ctk/in-component-copy? shape)` is essentially `(some? (:shape-ref shape))`.
3. Component root: topmost shape of an instance, marked `:component-root true` and carrying surface attrs such as `:component-id` and `:component-file`.
Variant masters are main instances and component roots. Their descendants may themselves be component copies, so master/copy logic must handle nested instances rather than assuming those roles are exclusive.
## :shape-ref chains
`:shape-ref` walks up the inheritance hierarchy and can cross files for remote libraries. `find-ref-shape` and `get-ref-chain-until-target-ref` in `app.common.types.file` follow this chain.
`find-shape-ref-child-of` in `app.common.logic.variants` walks the chain looking for the first ref-shape whose ancestors include a specific parent. Variant switch uses this to locate the equivalent master child in the target variant.
## :touched flags
`:touched` is a set of override-group keywords such as `:geometry-group`, `:fill-group`, and `:text-content-group`. It means a copy diverged from its master for attrs in that sync group.
`sync-attrs` in `app.common.types.component` maps attrs to groups. `set-touched-group` is the legitimate setter; the central `set-shape-attr` path calls it only for copies and only when ignore flags allow it.
Masters are not normally touched through `set-shape-attr`, but touched flags can appear on master shapes through cloning/duplication paths. `add-touched-from-ref-chain` in `app.common.logic.variants` unions touched flags from ancestors into the copy being processed, so upstream/master touched state can affect downstream switch behavior.
## Cloning paths
`make-component-instance` in `app.common.types.container` produces a clean component copy through `update-new-shape`, dissociating attrs such as `:touched`, `:variant-id`, and `:variant-name` on cloned shapes.
`duplicate-component` in `app.common.logic.libraries` creates a new component master by cloning existing component shapes, setting component metadata, and applying a position delta. It does not have the same clean-copy semantics as `make-component-instance`, so inherited attrs on the source can matter.
When a bug depends on touched state, identify which cloning path produced the shape before changing sync logic.
## Variant containers
A variant container is a frame with `:is-variant-container true`. Its children are variant masters with `:variant-id` pointing at the container and `:variant-name` naming the variant value. Component records in the library carry `:variant-properties`.
Predicates are broad: `ctk/is-variant?` checks `:variant-id` and applies to both variant master shapes and component rows; `ctk/is-variant-container?` checks the container shape flag.
Moving/dropping a shape into a variant container through the move-to-frame path can auto-convert it into a variant via `generate-make-shapes-variant`, which may duplicate the underlying component. Treat drag/drop into variant containers as a component/variant operation, not a plain reparent.
@@ -0,0 +1,58 @@
# Common Component and Change Debugging Recipes
Keep source changes out of these recipes unless the task requires a durable fix.
## Inspect recent workspace changes
From `cljs_repl` after triggering an action:
```clojure
(let [items (get-in @app.main.store/state [:workspace-undo :items])
n (count items)]
(->> items
(drop (max 0 (- n 5)))
(map-indexed (fn [i it]
{:idx (+ i (max 0 (- n 5)))
:tags (:tags it)
:n (count (:redo-changes it))
:types (frequencies (map :type (:redo-changes it)))
:ids (mapv :id (:redo-changes it))}))))
```
To inspect operations within the latest `:mod-obj`:
```clojure
(let [items (get-in @app.main.store/state [:workspace-undo :items])
mod-obj (->> (:redo-changes (last items))
(filter #(= :mod-obj (:type %)))
first)]
(:operations mod-obj))
```
## Trace variant switch attribute copying
To capture what `update-attrs-on-switch` saw during a real UI swap, patch it temporarily in `cljs_repl`:
```clojure
(def orig (deref #'app.common.logic.libraries/update-attrs-on-switch))
(def trace-buf (atom []))
(set! app.common.logic.libraries/update-attrs-on-switch
(fn [& args]
(swap! trace-buf conj
(let [[_ curr prev _ _ origin _] args]
{:curr (select-keys curr [:name :x :y :selrect :points :touched])
:prev (select-keys prev [:name :x :y :selrect :points :touched])
:origin-ref (select-keys origin [:id :name :x :y :width :height :selrect])}))
(apply orig args)))
;; trigger UI action, then inspect @trace-buf
(set! app.common.logic.libraries/update-attrs-on-switch orig)
```
Runtime patching is faster than adding temporary source instrumentation and avoids recompilation cleanup. Restore the var or reload the frontend when finished.
## Test-side helpers
- Use `thf/dump-file file :keys [...]` to print a shape tree with selected keys during common tests.
- Prefer production-path helpers such as `cls/generate-update-shapes` plus `thf/apply-changes` for shape mutations.
- For component swaps with keep-touched behavior, use `tho/swap-component-in-shape` with `{:keep-touched? true}`.
- Temporary `prn` calls in production code are acceptable while investigating but should be removed before committing.
@@ -0,0 +1,42 @@
# Component Swap and Variant Switch Pipeline
## Entry points
Frontend entry points under `frontend/src/app/main/data/workspace/`:
- `variants.cljs`: `variants-switch` and `variant-switch` events feed property-toggle UI and Plugin API `switchVariant` behavior into `dwl/component-swap`.
- `libraries.cljs`: `component-swap` is the single-swap workhorse; `component-multi-swap` batches swaps and calls `component-swap` with `keep-touched? = false`.
`keep-touched? = true` is the discriminator for preserving user overrides during variant switch. Batch/multi-swap paths intentionally bypass that logic.
## Common-side pipeline
For a single swap with `keep-touched? = true`:
1. `cll/generate-component-swap` in `common/src/app/common/logic/libraries.cljc` builds the base changes: remove old shape and instantiate the target component in its place through `generate-new-shape-for-swap`, `generate-instantiate-component`, and `make-component-instance`.
2. `clv/generate-keep-touched` in `common/src/app/common/logic/variants.cljc` walks pre-swap children, augments each with chain-derived touched flags through `add-touched-from-ref-chain`, finds the equivalent target shape through `find-shape-ref-child-of`, then calls `update-attrs-on-switch`.
3. `update-attrs-on-switch` in `app.common.logic.libraries` decides which touched attrs from the previous shape should be copied onto the freshly instantiated target shape.
## update-attrs-on-switch hazards
The routine compares `current-shape` (fresh target copy), `previous-shape` (pre-swap shape with chain-derived touched), and `origin-ref-shape` (source variant master's equivalent shape). It loops over sync attrs except `swap-keep-attrs` and copies only attrs that pass several guards:
- skip equal previous/current values;
- skip equal composite geometry for selected attrs;
- require the corresponding touched group;
- for most attrs, require source and target masters to agree;
- for fixed-size selrect/points/width/height, use dedicated fixed-layout geometry handling;
- text and path shapes have specialized value conversion paths.
The generic fallback branch copies from `previous-shape`. It represents the intended "carry user override through switch" behavior, but bugs usually appear when guards fail to reject incompatible geometry or master differences before reaching that branch.
## Known sharp edges
- Composite `:selrect` and `:points` bypass the simple different-master skip; width/height checks catch some but not all positional differences.
- `previous-shape` may be repositioned by destination-root minus origin-root before copying. For normal variant switch this is often zero, but do not assume it for all swap entry points.
- Touched flags can be inherited through ref chains, so a shape that looks untouched locally may still behave as touched after `add-touched-from-ref-chain`.
## Test harness
`common/src/app/common/test_helpers/compositions.cljc` has `swap-component-in-shape`, which drives `generate-component-swap` plus `generate-keep-touched` with the production `keep-touched?` flag. Use it for focused common tests of variant-switch behavior.
`common/test/common_tests/logic/variants_switch_test.cljc` is the canonical reference suite for swap+touched scenarios. Read nearby tests before adding another case.
+55
View File
@@ -0,0 +1,55 @@
# Common Architecture and Workflow
`common/` intro: shared CLJC for frontend, backend, exporter, library/file tooling, tests. Small semantic changes can affect multiple runtimes.
## Stable namespace map
- `app.common.data` and `app.common.data.macros`: generic data helpers and performance macros that do not depend on Penpot domain entities.
- `app.common.types.*`: shared shape/file/page/component/token data types, schemas, predicates, and entity-local operations. `app.common.types.nitrate-permissions` contains shared fail-closed Nitrate organization/team permission rules.
- `app.common.files.*`: file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic.
- `app.common.logic.*`: higher-level workflows/algorithms over files, shapes, components, variants, libraries, tokens, etc.
- `app.common.geom.*`: geometry helpers and transformations.
- `app.common.schema` / `app.common.schema.*`: Malli abstraction layer.
- `app.common.math`, `app.common.time`, `app.common.uuid`, `app.common.json`, etc.: cross-runtime utilities.
- `app.common.test_helpers.*`: test builders and production-path helpers.
## Layering and cross-runtime rules
Use reader conditionals for platform-specific code. Because CLJC runs on JVM and CLJS targets, avoid assuming browser-only or JVM-only behavior unless the reader conditional isolates it.
Respect the intended abstraction direction in new/refactored code:
- generic data utilities should not know Penpot domain concepts;
- `types.*` should preserve invariants for a single domain entity or ADT;
- `files.*` can coordinate several entities inside a file and preserve referential integrity;
- `changes*` should adapt serializable change records to lower-level operations and avoid embedding broad business algorithms;
- `logic.*` and frontend/backend event layers own higher workflow/business behavior.
Some legacy code violates this layering; do not copy those violations into new code when a focused refactor is practical.
## Focused memory routing
Model, schema, and persistence shape:
- File/page/shape/component attr changes, import/export surfaces, inspector/codegen, and cross-module checklist: `mem:common/data-model-change-checklist`.
- Token data structures, token import/export, active theme/set semantics, and schema/coercion behavior: `mem:common/tokens-schema-subtleties`.
Geometry and layout:
- Shape geometry invariants, redundant geometry fields, and geometry-sensitive tests: `mem:common/geometry-invariants`.
- Coordinate drift and approximate float comparisons: `mem:common/decimals-and-coordinates`.
- Layout/grid assignment, deassignment, metadata cleanup, and auto-positioning: `mem:common/layout-grid-subtleties`.
Change pipeline, validation, and migrations:
- Change records, undo/redo architecture, changes-builder API, and production-path mutation guidance: `mem:common/changes-architecture`.
- Change application, shape-tree edits, validation/repair, migrations, and second-pass touched behavior: `mem:common/file-change-validation-migration-subtleties`.
Components, variants, and debugging:
- Component/variant data model, ref chains, touched override semantics, and cloning paths: `mem:common/component-data-model`.
- Component swap, variant switch, and keep-touched pipeline: `mem:common/component-swap-pipeline`.
- Live inspection snippets, temporary runtime patching, and test-side debugging helpers for common change/component behavior: `mem:common/component-debugging-recipes`.
Text and tests:
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
## Areas without focused memories
Common areas with little or no dedicated memory include colors, media/SVG helpers, path operations, thumbnail helpers, generic pools, weak refs, and some utility namespaces. Treat work there as source/test-led unless a focused memory exists.
@@ -0,0 +1,25 @@
# Common Data Model Change Checklist
## Attribute conventions
- Prefer optional page/shape attrs with default behavior when absent. Reverting to default should usually remove the attr instead of storing nil.
- Do not treat nil as a distinct persisted state from absence. Import/export and cleanup paths may filter nil attrs away.
- Avoid Clojure-special naming in exported object attrs, especially boolean names ending in `?`; exported/imported data must survive JSON/SVG/Transit and external tooling.
- Any new shape attr that participates in component sync must be listed in `app.common.types.component/sync-attrs` with the correct touched group. Attrs absent from `sync-attrs` are ignored by component synchronization.
## Cross-module update checklist
When changing the file data model, check the relevant paths:
- Schema/type definitions under `common/src/app/common/types*` and helpers under `common/src/app/common/files*` / `logic*`.
- File migrations in `common/src/app/common/files/migrations.cljc` when old files cannot safely use absence/default behavior.
- Frontend edit forms under `frontend/src/app/main/ui/workspace/sidebar/options/`; multi-selection behavior is usually in `multiple.cljs` and must handle `:multiple` values.
- SVG/file render and export metadata under `frontend/src/app/main/ui/shapes/*`, especially `export.cljs` when an attr is not a native SVG property.
- SVG import/parser paths under `frontend/src/app/worker/import/parser.cljs`; attrs not exported and imported will be lost on reimport.
- Viewer inspect and code generation under `frontend/src/app/main/ui/viewer/inspect/*` and `frontend/src/app/util/code_gen.cljs` / markup/style helpers when handoff output should expose the attr.
- Exporter/library consumers when the change affects file construction, rendering, or packaged `.penpot` archives.
## Migrations
Existing files should keep working unchanged when possible. If absence cannot preserve old behavior, add a migration and preserve append/order semantics described in `mem:common/file-change-validation-migration-subtleties`.
Model changes can also require file feature flags or migration metadata updates; check nearby migrations and `common/src/app/common/features.cljc` before inventing a new pattern.
@@ -0,0 +1,54 @@
# Decimals and Coordinates in Penpot
Penpot stores all geometry as JS numbers (doubles in CLJS, doubles in
CLJ for the JVM-side common code). Several Penpot-specific facts
about how this plays out are not obvious from reading the code.
## Sub-pixel drift is routine
Coordinate values that "should" be integers are routinely off by ~1e-5
in production data. A `:width` of 107 will frequently appear as
`107.00001275539398` after the value has passed through:
- the modifier propagation pipeline (`apply-wasm-modifiers` and the
Rust WASM transform engine)
- any rotation/scale composition
- repeated translations
This drift is invisible in the UI (the renderer rounds at draw time)
but defeats exact equality comparisons in business logic. It does NOT
appear in JVM-only test setups because the WASM pipeline isn't
involved — tests that build shapes via `setup-shape` and `add-sample-shape`
get clean integer values. Bugs that depend on drift will pass tests
but fire in production unless tests explicitly inject drift.
## Use the close? helpers, not `=`
For comparing coordinate-like floats, the established convention is:
- `app.common.math/close?` — scalar tolerance comparison.
Default precision 0.001 (sub-pixel; tight enough to keep distinct
shapes distinct, loose enough to absorb arithmetic noise).
Two-arity uses default precision; three-arity takes a custom one.
- `app.common.geom.point/close?` — element-wise close on `gpt/Point`
records. Compares :x and :y via `mth/close?`.
- `app.common.geom.matrix/close?` — close on transform matrices.
- `app.common.geom.shapes/close-attrs?` — used inside `set-shape-attr`
to decide whether a re-assigned `:width`/`:height` should be treated
as a no-op (suppresses spurious touched marking from drift).
Treat `=` on `:x`, `:y`, `:width`, `:height`, `:selrect`, or `:points`
fields as a code smell when the inputs may have flowed through any
transform. The `set-shape-attr`-style precedent (already using
`close-attrs?`) is the right model.
## The redundancy multiplies failure modes
A shape's position lives in `:x/:y`, `:selrect`, AND `:points` (see
`mem:common/geometry-invariants` memory). Each is a separate set of float
values. After any operation that touches geometry, all three should
agree, but each is computed by a different path and accumulates
its own drift. Comparing `:selrect.width` from shape A to
`:selrect.width` from shape B is comparing two values that
"semantically" should be equal but were computed via different
operation chains — exact equality will often be false.
@@ -0,0 +1,28 @@
# Common File Change, Validation, and Migration Subtleties
## Change application
- `process-changes` validates the whole change vector once by default, reduces changes, then performs a second pass for collected touched changes. Callers that already validated can pass `verify? false`.
- `process-operation :set` delegates to `ctn/set-shape-attr`; `:assign` first decodes attrs with the shape-attrs JSON transformer and then emits per-attr set operations.
- `set-shape-attr` treats `:position-data` as derived and never touched. Geometry/content-path changes use approximate equality; geometry differences under about 1px can be ignored for touched purposes.
- Width/height are excluded from the `is-geometry?` branch in `set-shape-attr`; do not assume all geometry-group attrs follow identical ignore-geometry behavior.
- `process-touched-change` marks the owning component modified when a touched shape belongs to a main instance; component-data changes can come from shape ops through this second pass.
## Shape tree edits
- `shape-tree/add-shape` falls back invalid/missing parent or frame ids to root (`uuid/zero`), ensures parent `:shapes` is a vector, avoids duplicate child ids, and clears `:remote-synced` on copy parents unless `ignore-touched` is true.
- `shape-tree/delete-shape` removes the shape and all descendants from the objects map and removes the id from its parent. This is different from render-wasm deletion, which may keep deleted children for undo/redo internals.
- Page object maps can carry metadata indexes such as cached frame lists. `start-page-index` / `update-page-index` rebuild those metadata indexes; `frontend` commit application calls `ctst/update-object-indices` after page changes.
## Validation and repair
- Full referential/semantic validation currently runs only when file features contain `"components/v2"`.
- Validation starts at root plus orphan shapes, then validates component records. `validate-file!` raises `:validation :referential-integrity` with collected details.
- `repair-file` does not mutate data directly; it reduces validation errors into redo changes using `changes-builder`. Callers must apply or persist those changes.
## Migrations
- Prefer optional attrs/default behavior so old files continue working without migration. If absence cannot preserve old behavior, add a migration.
- Migrations are an ordered set mixing legacy version-derived ids and newer named ids. Keep append order stable; `migrate` applies the set difference between available migrations and file migrations.
- `migrate-file` synthesizes legacy migration ids from old numeric versions when `:migrations` is absent, migrates legacy features, and records feature flags created through `cfeat/*new*`.
- When a file had no previous `:migrations`, `migrate-file` marks all migrations as migrated in metadata so callers persist the complete migration set, not only transformations that changed data.
@@ -0,0 +1,48 @@
# Geometry Invariants in Penpot Shapes
Core invariant: shape position is stored redundantly, and all geometry fields must stay coherent.
## Redundant fields
For a shape at `(x, y)` with width `w` and height `h`:
- `:x`, `:y`, `:width`, `:height`: top-left and dimensions.
- `:selrect`: `{:x :y :width :height :x1 :y1 :x2 :y2}`, where `x2 = x + w` and `y2 = y + h`.
- `:points`: four corners for an axis-aligned rect, clockwise from top-left.
- `:transform` and `:transform-inverse`: identity for axis-aligned shapes; populated for transformed shapes.
After a geometric mutation, equivalent fields such as `:y`, `(:y :selrect)`, and the first point's `:y` should agree. The renderer and hit-testing read `:selrect` / `:points`, so a shape can render or select incorrectly even when `:x` / `:y` look right.
## Helpers that preserve the invariant
- `gsh/move`: translates by delta and updates geometry consistently.
- `gsh/absolute-move`: moves to an absolute position by computing a delta from the current selrect.
- `gsh/transform-shape`: applies a full transform.
- `cts/setup-shape`: initializes geometry for new shapes; variant test helpers such as `thv/add-variant-with-child` use it.
## Edits that break the invariant
- `(assoc shape :x ...)` or `(assoc shape :y ...)`: updates only one field and leaves `:selrect` / `:points` stale.
- `ths/update-shape file label :y val`: goes through `set-shape-attr`, but does not repair all position fields for `:y` alone.
- Direct `update-in` edits to `:selrect`, `:points`, or dimensions.
## Test setup warning
When positioning test shapes, use `gsh/absolute-move`, `gsh/move`, or production change helpers. Do not set only `:x` / `:y`.
```clojure
(cls/generate-update-shapes
(pcb/empty-changes nil page-id)
#{(:id child)}
#(gsh/absolute-move % (gpt/point (:x %) 101))
(:objects page)
{})
```
Using `(ths/update-shape file label :y 101)` leaves `:selrect.y` stale. Downstream code that reads `:selrect` can then fail in ways that look like product bugs but are only invalid test setup.
## :touched and geometry mutation
When a copy shape changes geometry through the proper pipeline (`set-shape-attr` via `process-operation :set`), `:touched` gains `:geometry-group` unless ignored. Tests can either drive the production update with `cls/generate-update-shapes`, or inject `(assoc shape :touched #{:geometry-group})` when only touched state matters.
If a test needs both a new position and touched state, move the shape first with geometry-preserving helpers, then inject or assert touched state.
@@ -0,0 +1,13 @@
# Common Layout and Grid Subtleties
## Layout metadata
- Layout container data and child layout-item data are removed by different helpers. Do not assume clearing a layout frame also clears all child layout metadata.
- Layout data can affect both container attrs and immediate child attrs; validate behavior for both sides when changing cleanup or propagation.
## Grid assignment
- Grid `assign-cells` ensures at least one column and row, skips absolute-position children, creates non-tracked rows/cols when children exceed tracked cells, and asserts that assigned cells do not overlap.
- Grid deassignment removes cells for shapes that are no longer direct children or have become absolute-positioned.
- Auto-positioning is not just sorting: some auto cells are converted to manual when empty/manual/span state would break the auto sequence, then auto single-span items can be compacted.
- `fix-overlaps` is marked dev-only and removes one overlapping cell, preferring empty cells first. Avoid depending on it as normal production repair.
+51
View File
@@ -0,0 +1,51 @@
# Common Testing and Verification
`common/` is CLJC shared code. Tests should cover the relevant runtime(s): JVM for backend/common logic and JS for frontend/exporter behavior. For geometry, component, and file-model changes, JVM tests are common and fast, but JS/browser behavior can differ when WASM modifier math or CLJS-specific state is involved.
## Unit tests
Common tests live under `common/test/common_tests/` and use `clojure.test`.
They are CLJC and run on both JVM and JS.
From `common/`:
- Full JVM test run: `clojure -M:dev:test`
- Full JS test run: `pnpm run test:quiet`
- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test`
- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch`
- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`
- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute`
- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`)
- Build JS test target only: `pnpm run build:test`
- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`
- Watch tests: `pnpm run watch:test`
New common JS test namespaces must be required/listed in `common_tests/runner.cljc`;
new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags
compose as a union.
## Test helpers
Helpers live under `common/src/app/common/test_helpers/` and are usually aliased with short `th*` prefixes. Test namespaces using label->uuid helpers should start with `(t/use-fixtures :each thi/test-fixture)` so labels reset between tests.
Useful builders:
- `thf/sample-file` creates a base file.
- `tho/add-simple-component` creates a simple component.
- `thc/instantiate-component` instantiates a component copy.
- `thv/add-variant-with-child` creates a variant container with two child variants.
- `thv/add-variant-with-copy` creates variants whose children are component instances.
`add-variant-with-copy` does not accept position params for children; use `gsh/absolute-move` after creation if positions matter.
## Driving production paths
For shape mutations, prefer production-path helpers such as `cls/generate-update-shapes` plus `thf/apply-changes`. For component swaps with keep-touched behavior, use `tho/swap-component-in-shape` with `{:keep-touched? true}`.
`thf/apply-changes` validates by default and usually gives the most useful invariant failure. Pass `:validate? false` only for intentionally malformed intermediate state.
## Geometry setup caution
For geometry-sensitive tests, read `mem:common/geometry-invariants` before positioning shapes. Use geometry-preserving helpers or production change helpers rather than direct single-field edits.
## Debugging
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
@@ -0,0 +1,14 @@
# Common Text Subtleties
## DraftJS compatibility
- `app.common.text` is legacy DraftJS conversion support. New text work should prefer the newer text type namespaces unless specifically touching DraftJS conversion.
- DraftJS style values are encoded as Transit strings under `PENPOT$$$<key>$$$<encoded>` style names. `PENPOT_SELECTION` is a special marker.
- Text conversion uses Unicode code points on both CLJ and CLJS paths, not UTF-16 code units. This matters for offsets around emoji and astral characters.
- Draft conversion fixes gradient type strings back to keywords.
## Modern text content
- Modern text content schema is narrow: root -> paragraph-set -> paragraph -> text nodes.
- `position-data` is derived layout/geometry/font fragment data and should be treated as generated state, not source-of-truth file data.
- Token propagation and some non-current-page text updates drop `:position-data` so it can be regenerated in the right runtime context.
@@ -0,0 +1,17 @@
# Common Tokens and Schema Subtleties
## Tokens
- `TokensLib` always ensures an internal hidden theme exists and defaults active themes to that hidden theme path. That hidden theme represents the UI state where active sets are controlled without modifying a user-created theme.
- `get-tokens-in-active-sets` merges tokens from sets selected by active themes in set order, so later active sets with the same token name override earlier ones.
- Activating a real theme in `common.logic.tokens` removes the hidden theme from the active-theme set unless it is the only active theme. Toggling active sets directly copies current active sets into the hidden theme.
- DTCG import/export deliberately hides the internal hidden theme: exports omit it from `$themes` and activeThemes, while `activeSets` records the hidden/current active sets.
- Single-set DTCG/legacy imports throw if no supported tokens are found. Multi-set import normalizes set names, keeps `tokenSetOrder`, rejects conflicting token path names, discards unsupported token types, and validates theme sets against existing sets.
- Token values stored on shapes are token names in `:applied-tokens`, not token ids. Renames and group renames must update those name paths.
- Token serialization has both Transit handlers for frontend/backend transport and Fressian handlers for internal file-data storage, with migrations for older token-lib internal versions.
## Schema
- `app.common.schema/json-transformer` has custom map-of key decoding/encoding, so map keys can be transformed based on the key schema instead of only the value schema.
- `check-fn` throws `ex-info` with default `:type :assertion`, `:code :data-validation`, and `::explain`. Prefer reusable `check-fn`/lazy validators in hot or repeated paths; `sm/check` creates a checker every call.
- `coercer` decodes with the JSON transformer and then checks. This is the common pattern for accepting external JSON-shaped data into internal types.
+70
View File
@@ -0,0 +1,70 @@
You are working on the GitHub project `penpot/penpot`, a monorepo.
# Memory system
- Memories are the primary project guidance (not docs or other readme files).
- A section's top-level memory is `<section>/core`. When a section is relevant, read the core memory
before focused memories.
- Edits/stale refs/duplication cleanup: `mem:memory-maintenance`.
# Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
*After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`).
- Align `let` binding values: when a `let` form has multiple bindings spanning
several lines, align the value forms to the same column with spaces.
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
See `mem:tools/paren-repair` for usage.
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
# Project modules
This is a monorepo. Principles that apply to one module do *not* generally apply to others. Do not make assumptions.
- `frontend/`: ClojureScript + SCSS SPA/design editor.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers.Runtime services and the task-queue vs Pub/Sub topology that constrains horizontal scaling: `mem:prod-infra/core`.
- `common/`: shared CLJC data types, geometry, schemas, file/change logic, and utilities.
- `render-wasm/`: Rust -> WebAssembly Skia renderer consumed by frontend.
- `exporter/`: ClojureScript/Node headless Playwright SVG/PDF export.
- `mcp/`: TypeScript Model Context Protocol integration.
- `plugins/`: TypeScript plugin runtime/examples and Plugin API types.
- `library/`: design library workflows.
- `docs/`: documentation site.
The memory is structured in a way that you can get the critical information about the
module. You can read it from `mem:<MODULE>/core`
# Low-centrality project paths
- `docker/` contains devenv related code, not needed unless specifically instructed.
When working on devenv startup, compose layout, instance config (`defaults.env`),
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
`*-devenv` commands, read `mem:devenv/core`.
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and
`taiga.py` / `gh.py` (issue management helpers).
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
# Dependency graph
`frontend -> common`, `backend -> common`, `exporter -> common`, and `frontend -> render-wasm`. Changes in `common` can
affect frontend, backend, exporter, file migrations, and design-library behavior; validate across consumers when
semantics change.
# Working with Penpot designs
- Before automating or inspecting Penpot designs through the Plugin API, call the Penpot MCP `high_level_overview` tool.
- connection between the JavaScript plugin API and the ClojureScript code: `mem:frontend/plugin-api-to-cljs-binding`.
- executing ClojureScript code in the frontend: `mem:frontend/cljs-repl`.
- handling Clojure compiler errors, runtime patching and debug helpers: `mem:frontend/handling-errors-and-debugging`.
## Detecting Crashes
The Penpot frontend can crash silently from the JS API's perspective: `execute_code` calls return successfully, but 1-2s later the workspace becomes unusable (Internal Error page).
The `execute_code` tool then stops working, but `cljs_repl` still works. Use it to detect a crash via `(some? (:exception @app.main.store/state))`.
For details on handling crashes, read memory `mem:frontend/handling-crashes`.
+73
View File
@@ -0,0 +1,73 @@
# Devenv startup and configuration
Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Parallel instances share infra + Postgres + MinIO; each instance has its own `main` container, Valkey, source checkout, tmux session.
## Compose project layout
- `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`.
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer.
- All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands.
## Source-of-truth files
- `docker/devenv/defaults.env`: ws0 baseline — container/volume names, runtime env, published host ports, tmux defaults. `manage.sh` aborts if unreadable.
- For ws1+, `instance-env-overrides` computes the per-instance overrides (container/volume names, host ports offset `10000·N`, `PENPOT_PUBLIC_URI`, `PENPOT_REDIS_URI`, `PENPOT_BACKEND_WORKER=false`) and `instance-compose` injects them as env vars at compose time — never written to disk, recomputed each call so they can't drift. ws0 uses `defaults.env` as-is.
- `backend/scripts/_env`: backend-internal only — secret keys, `PENPOT_FLAGS` (with `enable-backend-worker` gated on `PENPOT_BACKEND_WORKER`), `JAVA_OPTS`, `setup_minio()`. Never duplicates `defaults.env`.
- Compose files use pure `${VAR}` substitution; missing var = compose fails.
## Invariants
- `infra-compose` / `instance-compose` wrap `docker compose` with `env -i`, then re-inject what compose needs. Stripping is required because `defaults.env` is sourced into manage.sh's shell at startup (stale values would leak); the ws1+ overrides are deliberately re-injected as shell env vars precisely because Compose gives shell precedence over `--env-file`, so they override the `defaults.env` baseline.
- Volume names pinned via `name:` (PENPOT_*_VOLUME), decoupled from the compose project name. ws1+ inject distinct per-instance volume names; ws0 keeps the historical `penpotdev_*` physical names so project renames never require data migration.
- Network aliases (`- main`, `- redis`) are not declared in main.yml. Compose's auto-service-alias still registers `redis` on the shared network, so DNS for `redis` is non-deterministic with multiple instances. Backend uses `PENPOT_REDIS_URI=redis://penpot-devenv-wsN-valkey/0` (container_name) instead.
- No cross-project `depends_on`. `manage.sh ensure-infra-up` `docker wait`s on the `minio-setup` one-shot.
- `JAVA_OPTS` in `manage.sh` is shadowed inside the container by `_env`. The `-e JAVA_OPTS=...` flag only matters for processes that don't source `_env`.
## Worker policy
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv-agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
## Port layout
Container-internal ports fixed; host side offset `10000·N`.
| ws0 | ws1 | wsN | container | role |
|---|---|---|---|---|
| 3449 | 13449 | 3449+10000·N | 3449 | public HTTPS (Caddy; `/mcp/ws` same-origin) |
| 3449/udp | 13449/udp | … | 3449/udp | HTTP/3 |
| 4401 | 14401 | … | 4401 | MCP HTTP stream |
| 4403 | 14403 | … | 4403 | MCP REPL |
| 14181 | 24181 | … | 14281 | Serena MCP |
| 14182 | 24182 | … | 24282 | Serena dashboard |
Everything else (frontend dev, backend API, exporter, storybook, REPLs, plugin dev, MCP inspector/WebSocket) is in-process or same-origin via Caddy/nginx. Infra publishes: mailer 1080, ldap 10389/10636 (singletons, not offset).
## Tmux + MCP routing
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv-agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv-agentic` — the conditional windows are only added at session create time.
MCP plugin routing is same-origin: frontend uses `<public-uri>/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`.
## Workspace orchestration (ws1+)
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv-agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
`sync-workspace wsN`:
1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape.
2. `rsync -a --delete $PWD/.git/ $workspace/.git/`.
3. `git ls-files -z --cached --others --exclude-standard``rsync --files-from` (Git is the authority on tracked files; rsync's gitignore filter would drop committed files under gitignored parents like `.clj-kondo/config.edn`).
4. Initial-only copy of `frontend/resources/public/js/config.js` (gitignored, but agentic mode needs it). After the first sync the workspace's copy belongs to the developer — subsequent syncs leave it alone.
5. `git switch -C "wsN/<current-branch>"` inside the workspace.
No `--delete` on the working-tree pass: gitignored caches in the workspace survive. Workspace dir + named volumes survive `compose down`.
## CLI surface
- `run-devenv-agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.
- `run-devenv-shell [--instance 0|wsN|N] [cmd...]`: bash in target instance. (`--instance` flag not yet renamed to `--ws`.)
- `start-devenv` / `log-devenv` / `drop-devenv`: legacy paths around ws0 + shared infra. `drop-devenv` never removes volumes.
`exporter/scripts/run` and `wait-and-start.sh` source `backend/scripts/_env` then `_env.local` if present.
+33
View File
@@ -0,0 +1,33 @@
# Exporter Architecture and Workflow
`exporter/`: CLJS/Node headless export service. Depends on `common/`; uses Playwright plus export JS/CLJS deps for SVG/PDF/assets.
## Layout and commands
- Source: `exporter/src/`; config: `deps.edn`, `shadow-cljs.edn`, `package.json`; runtime helpers/assets: `vendor/`, `scripts/`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
## HTTP and browser pool
- POST body limit is about 60 MB. Exporter supports `application/transit+json`; request params merge query params and body params.
- Map response bodies are Transit JSON and force HTTP 200; nil 200 bodies become 204.
- Auth token comes from cookie `auth-token`, then uploads use Bearer auth plus the management shared key.
- Each export job gets a fresh Playwright browser context. On success, the context closes and the browser returns to the pool; on error, the browser is destroyed instead of reused.
- Borrow validates browser connection. Pool acquire timeout is about 10s; font loading timeout logs a warning and continues after about 15s.
## Export batching and async behavior
- `prepare-exports` groups entries by `[scale type]` and partitions groups into chunks of 50. Each partition uses file/page/share/name from its first item, so be careful if entries might cross those boundaries.
- Single-export response is used only when multiple export is not forced and there is exactly one prepared export containing exactly one object.
- Multi-object export can run async: when `wait` is false it returns a resource immediately and publishes progress/end/error to Redis by profile topic; when `wait` is true it waits for upload and returns the uploaded resource.
- Frame export returns a resource immediately and publishes Redis updates; it does not follow the same `wait` option path.
- ZIP entry names are sanitized and duplicates receive numeric suffixes.
## Render details
- Bitmap export differs for WASM vs non-WASM render paths: WASM forces Playwright `deviceScaleFactor` to 1 and passes scale through the render URL; non-WASM uses `deviceScaleFactor = scale`.
- WebP is produced by taking a PNG screenshot and converting it with ImageMagick.
- SVG export rasterizes text foreignObjects to PNG, converts through PPM/color masks/potrace, and reassembles SVG paths. It also replaces non-breaking spaces for SVG compatibility and drops empty defs/paths.
- PDF export injects `@page` sizing through raw browser `evaluate` JavaScript; that code cannot rely on CLJS runtime helpers.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
+94
View File
@@ -0,0 +1,94 @@
# ClojureScript REPL and Frontend Debugging
Execute code in the live frontend via the Penpot MCP `cljs_repl` tool. For browser-console debugging, the frontend also exports a `debug` JS namespace in development builds.
## Accessing app state
The main store is `app.main.store/state`. It contains workspace metadata, selection, UI state, profile, route, etc. Page objects are not under a `:workspace-data` key; use derived refs.
```clojure
;; Current selection
(mapv str (get-in @app.main.store/state [:workspace-local :selected]))
;; Current page objects
(let [objects @app.main.refs/workspace-page-objects
shape (get objects (parse-uuid "some-uuid-here"))]
(select-keys shape [:name :type :x :y :width :height :fills :strokes :rotation :opacity :frame-id :parent-id]))
```
Shape keys use kebab-case keywords. Internal `:rect` corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board".
Component instance shapes carry `:component-id` and `:component-file` directly; `:component-root` flags the root of an instance. Use `app.common.types.container/get-head-shape` for nearest head and `get-instance-root` for outermost root; they differ for nested instances.
## Navigation recipe
To programmatically open a workspace file, all three ids are required:
```clojure
(do (require '[app.main.data.common :as dcm])
(app.main.store/emit! (dcm/go-to-workspace
:team-id (parse-uuid "<team-id>")
:file-id (parse-uuid "<file-id>")
:page-id (parse-uuid "<page-id>"))))
```
Get `team-id` from `(:current-team-id @app.main.store/state)`. Get file ids from `(vals (:files @app.main.store/state))`. Get page ids by fetching file data, e.g. through `rp/cmd! :get-file` with current features.
## Reload the live runtime
`(.reload js/location)` (alias `app.util.dom/reload-current-window`) from `cljs_repl` reloads the browser page: clears `set!` runtime patches, re-fetches file state, and is the simplest crash recovery while the repl is live (`mem:frontend/handling-crashes`). To re-fetch only the current file's data without a full page reload, emit `(app.main.store/emit! (potok.v2.core/event :app.main.data.workspace/reload-current-file))`.
## Useful lookup helpers
`app.plugins.utils` contains state lookup helpers that are useful from any CLJS, despite living under `plugins/`:
- `locate-shape`, `locate-objects`, `locate-file`.
- `locate-component` resolves through the outermost instance root.
- `locate-head-component` resolves through the nearest component head.
- `locate-library-component` does direct file-id/component-id lookup.
## Runtime patching with `set!`
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching. From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as `app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`, `app.main.errors/last-report`, or `app.main.errors/last-exception`. These patches affect only the live browser runtime and disappear on reload or recompilation.
```clojure
;; Log non-noisy Potok events temporarily.
(set! app.main.store/on-event
(fn [event]
(when (potok.v2.core/event? event)
(.log js/console (potok.v2.core/repr-event event)))))
```
Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure; it is not the normal way to patch live CLJS browser vars.
## Browser-console debug namespace
In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`:
```javascript
debug.set_logging("namespace", "debug");
debug.dump_state();
debug.dump_buffer();
debug.get_state(":workspace-local :selected");
debug.dump_objects();
debug.dump_object("Rect-1");
debug.dump_selected();
debug.dump_tree(true, true);
```
Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids.
For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing.
## Runtime targeting
`cljs_repl` may connect to the wrong runtime when several are attached, such as workspace plus rasterizer. Verify with `(.-title js/document)`; it should show the workspace file name, not "Penpot - Rasterizer".
To list or target shadow-cljs runtimes, run from `/home/penpot/penpot/frontend`:
```bash
printf '(shadow.cljs.devtools.api/repl-runtimes :main)\n' | timeout 10 npx shadow-cljs clj-eval --stdin
printf '(shadow.cljs.devtools.api/cljs-eval :main "<cljs-code>" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin
```
Use command timeouts so a disconnected browser does not hang the session.
@@ -0,0 +1,22 @@
# Frontend Compile Diagnostics
Separate from runtime crash recovery.
## First check the shadow-cljs build
Use the Penpot MCP `cljs_compiler_output` tool to inspect the latest shadow-cljs `:main` build status. This is the fastest way to distinguish a bad build from a runtime error in the browser.
Recommended order after CLJ/CLJC/CLJS source edits:
1. Run `cljs_compiler_output`.
2. If the compiler reports a Clojure syntax problem, especially unmatched delimiters or a confusing location, run `clj_check_parentheses` on the absolute path of the suspect `.clj`, `.cljc`, or `.cljs` file.
3. After the build is healthy, use `mem:frontend/cljs-repl`, browser tools, or runtime crash checks for behavior.
## Parentheses checker
`clj_check_parentheses` analyzes one Clojure/ClojureScript source file and reports the area likely responsible for unclosed parentheses/brackets/braces. Use it when compiler output points near EOF, points at a misleading later form, or says delimiter-related syntax errors.
## Hot reload notes
When the frontend shadow-cljs watch process is running, edits to CLJC files in `common/` are normally recompiled into the browser automatically. Do not restart the frontend before checking `cljs_compiler_output`; stale behavior is often a failed build.
For production/minified stack traces, build the production bundle from `frontend/` with `pnpm run build:app`. Output and source maps are generated under `frontend/resources/public/js`; inspect source maps or shadow-cljs reports using build ids from `shadow-cljs.edn`.
+59
View File
@@ -0,0 +1,59 @@
# Frontend Architecture and Workflow
Frontend: CLJS SPA; React/Rumext; Potok; RxJS; okulary refs; SCSS modules; shared `common/`; JS/TS workspace packages.
## Stable namespace map
- `app.main.ui.*`: Rumext/React UI components for workspace, dashboard, viewer, settings, auth, nitrate, etc.
- `app.main.data.*`: Potok event handlers and side effects.
- `app.main.refs`: reactive refs/lenses over store and derived workspace data.
- `app.main.store`: Potok store and `emit!`.
- `app.plugins.*` and `app.plugins`: CLJS implementation of Plugin JS API proxies.
- `app.render_wasm.*`: frontend bridge to Rust/WASM renderer.
- `app.util.*`: DOM, HTTP, i18n, keyboard, codegen, and general frontend utilities.
- `frontend/packages/*` and `frontend/text-editor`: JS/TS workspace packages consumed by the app.
- Nitrate subscription/organization UI and flows live under `app.main.data.nitrate` and `app.main.ui.nitrate*`; backend/API behavior is covered by backend memories, and shared permission rules are in `common/src/app/common/types/nitrate_permissions.cljc`.
## Lint and Format
From `frontend/`:
- CLJ/CLJS lint: `pnpm run lint:clj`.
- JS lint currently no-ops via `pnpm run lint:js`.
- SCSS lint: `pnpm run lint:scss`.
- Format checks: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss`.
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
- Translation formatting after i18n edits: `pnpm run translations`.
**Before linting:** if delimiter errors are suspected (after LLM edits, or
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
affected files first. Delimiter errors produce misleading linter output.
See `mem:tools/paren-repair`.
## Focused memory routing
UI and packages:
- App UI components, SCSS modules, style-system boundaries, accessibility, i18n, and render performance: `mem:frontend/ui-conventions-and-style-system`.
- JS/TS packages, shared UI package, text editor, Storybook, and package builds: `mem:frontend/ui-packages-text-editor-workflow`.
Workspace behavior:
- Workspace state, commits, persistence, undo, repo calls, and refs: `mem:frontend/workspace-state-persistence-subtleties`.
- Workspace transforms, modifier previews, WASM modifier integration, and transform commits: `mem:frontend/workspace-transform-subtleties`.
- Workspace token application/propagation: `mem:frontend/workspace-token-subtleties`; shared token data/schema: `mem:common/tokens-schema-subtleties`.
App shell and product flows:
- Routing, root app shell, websocket, and global errors: `mem:frontend/routing-app-shell-subtleties`.
- Dashboard and viewer flows: `mem:frontend/dashboard-viewer-subtleties`.
- Plugin JS API runtime inside the frontend app: `mem:frontend/plugin-api-to-cljs-binding`.
Diagnostics and validation:
- Runtime inspection and navigation: `mem:frontend/cljs-repl`.
- Source-edit compile/hot-reload diagnostics: `mem:frontend/compile-diagnostics`.
- Runtime crash recovery: `mem:frontend/handling-crashes`.
- Tests and live verification: `mem:frontend/testing`.
- Real pointer/keyboard gesture reproduction: `mem:frontend/playwright-gestures`.
## Areas without focused memories
These frontend areas currently have no dedicated Serena memory beyond this architecture entry and nearby source/tests: clipboard, drawing tools, boolean/path operations, interactions/prototyping, color/style asset management, grid-layout editing UI, comments UI, fonts UI, and many dashboard/settings subflows. Treat work there as less memory-covered and inspect source/tests more carefully.
@@ -0,0 +1,16 @@
# Frontend Dashboard and Viewer Subtleties
## Dashboard
- Dashboard initialization fetches projects and fonts for the team, then listens to websocket messages only for global topic `uuid/zero` or the current profile id.
- Project fetch replaces each project map completely instead of merging, so fields such as `deleted-at` can disappear cleanly.
- Dashboard file/project mutations are often optimistic local updates with fire-and-forget RPC watchers. Bulk permanent delete/restore paths use SSE progress and progress notifications.
- File creation/duplication strips file `:data` before putting file summaries into dashboard state.
## Viewer
- Viewer initialization sets `:current-file-id`, `:current-share-id`, and `:viewer-local`, then fetches the view-only bundle. Comment threads are fetched only for logged-in users.
- Viewer bundle fetch sends the full supported feature set because anonymous shared viewers may not know team-enabled features.
- View-only bundles can contain pointer values in `:pages-index` and file data. Viewer resolves those fragments with `:get-file-fragment` before storing the bundle.
- `bundle-fetched` indexes pages and precomputes viewer frames/all-frames, stores libraries/users/thumbnails/permissions under `:viewer`, then navigates to frame id, query index, or auto-selected frame.
- Viewer zoom and interaction mode changes update both `:viewer-local` and the `:viewer` route query params.
@@ -0,0 +1,38 @@
# Frontend Runtime Crash Handling
## Detect a runtime workspace crash
Runtime crashes usually show the Internal Error page with title text "Something bad happened" and class `main_ui_static__download-link`. A common pattern is: changes go through via JS API / `execute_code`, then 1-2s later an `update-file` request reaches the backend and is rejected.
After a crash, `execute_code` can become unusable because no plugin instances are connected and any data in its `storage` is lost, but `cljs_repl` usually still works.
Check crash state:
```clojure
(some? (:exception @app.main.store/state))
```
It returns `true` when the Internal Error page is showing and `false` on a healthy workspace or after a successful reload.
## Read the runtime cause
The exception is stored at `(:exception @app.main.store/state)`. Useful keys:
- `:type`, `:code`, `:status`: error class, e.g. `:validation`, `:referential-integrity`, `400`.
- `:hint`, `:details`: human-readable explanation; `:details` often contains validation problems with `:shape-id`, `:page-id`, `:args`, etc.
- `:uri`: API endpoint that returned the error, e.g. `update-file`.
- `:app.main.errors/instance`: underlying JS Error object.
- `:app.main.errors/trace`: JS stack trace string, usually response-handling path rather than the dispatch site that produced the bad change.
```clojure
(let [ex (:exception @app.main.store/state)]
(select-keys ex [:type :code :status :hint :details :uri]))
```
For backend validation errors (`:type :validation`), `:details` is usually the most informative field; it identifies the shape and invariant that failed.
## Recover and continue testing
Simplest path when `cljs_repl` is still live (usually true after a crash): reload via repl with `(.reload js/location)` — see `mem:frontend/cljs-repl`. Alternatively via Playwright: find the workspace tab (URL contains `/#/workspace`, title ends `- Penpot`), select it if not current, then `playwright:browser_navigate` to that same URL. Either way, confirm recovery with `(some? (:exception @app.main.store/state))` returning `false`.
For backend-rejected changes, such as validation errors on `update-file`, changes are not persisted. Reload restores the pre-crash state, so it is safe to retry after fixing the cause.
@@ -0,0 +1,55 @@
# Handling Errors and Debugging
## Finding source errors
You have access to two tools for finding errors in Clojure source code (which you may introduce yourself through edits):
1. cljs_compiler_output
2. clj_check_parentheses
The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second
tool can often find the exact location of such errors.
When delimiter errors are detected (typically from lint or compiler output),
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
MCP tool can also pinpoint the error location when available, but it is not
required — standard build errors are usually enough.
See `mem:tools/paren-repair`.
## Runtime patching with `set!`
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching.
From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as
`app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`,
`app.main.errors/last-report`, or `app.main.errors/last-exception`.
These patches affect only the live browser runtime and disappear on reload or recompilation.
```clojure
;; Log non-noisy Potok events temporarily.
(set! app.main.store/on-event
(fn [event]
(when (potok.v2.core/event? event)
(.log js/console (potok.v2.core/repr-event event)))))
```
Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure;
it is not the normal way to patch live CLJS browser vars.
## Browser-console debug namespace
In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`:
```javascript
debug.set_logging("namespace", "debug");
debug.dump_state();
debug.dump_buffer();
debug.get_state(":workspace-local :selected");
debug.dump_objects();
debug.dump_object("Rect-1");
debug.dump_selected();
debug.dump_tree(true, true);
```
Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids.
For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing.
@@ -0,0 +1,117 @@
# Penpot Canvas → Playwright Viewport Coordinate Mapping
## Goal
Map Penpot shape coordinates (from the JS/ClojureScript API) to browser viewport CSS pixels
so that Playwright mouse actions (click, drag, hover) can target specific canvas objects.
## Key Facts
### Playwright coordinate system
Playwright mouse coordinates are **viewport CSS pixels**: `(0, 0)` is the top-left of the
browser's rendered content area (not the screen, not the OS window chrome).
`getBoundingClientRect()` returns the same coordinate system — they are directly compatible.
### Canvas element location
The Penpot canvas is rendered by two co-located elements:
- `<canvas>` — the rasterised render
- `<svg id="render">` — vector overlay
- `<svg class="...viewport-controls ...">` — interaction/control layer (has the `viewBox`)
Get the canvas origin with:
```js
document.querySelector("#render").getBoundingClientRect()
// => { left: 318, top: 0, width: 514, height: 586, ... } (values vary with window size/panels)
```
The left offset (currently ~318 px) is caused by the left-side panel (layers, assets).
### Zoom and pan state
Available in two equivalent ways:
**1. App state (ClojureScript):**
```clojure
(let [wl (get @app.main.store/state :workspace-local)]
{:zoom (get wl :zoom) ; scale factor: penpot-units → CSS pixels
:vbox (get wl :vbox)}) ; Rect {:x :y :width :height} — penpot coords of visible area
```
**2. SVG viewBox attribute (DOM):**
```js
document.querySelector("svg.viewport-controls, [class*='viewport-controls']")
.getAttribute("viewBox")
// => "670 658.31 224 255.38" i.e. "vbox.x vbox.y vbox.width vbox.height"
```
Both sources are live and always in sync.
### Coordinate conversion formula
```
viewport_x = canvas_left + (penpot_x - vbox.x) * zoom
viewport_y = canvas_top + (penpot_y - vbox.y) * zoom
```
Sanity check: `vbox.width * zoom ≈ canvas CSS width` (and same for height). ✓
### Device Pixel Ratio
The canvas physical pixel size = CSS size × DPR (observed DPR = 1.25, so canvas internal
size 642×732 vs CSS size 514×586). This does **not** affect the formula — both
`getBoundingClientRect()` and Playwright use CSS pixels.
### Ruler label offset
The on-screen rulers show coordinates offset from absolute Penpot coordinates (they display
frame-relative values, offset by ~the top-level frame's x/y). **Ignore for coordinate
mapping** — use `vbox` directly.
---
## ClojureScript Helper (paste into cljs REPL session)
```clojure
(defn penpot->viewport-coords
"Convert Penpot canvas coordinates to browser viewport CSS pixel coordinates.
Returns {:vp-x <number> :vp-y <number>} — pass directly to Playwright mouse actions."
[penpot-x penpot-y]
(let [state @app.main.store/state
wl (get state :workspace-local)
vbox (get wl :vbox)
zoom (get wl :zoom)
canvas (js/document.querySelector "svg.viewport-controls, #render")
canvas-rect (.getBoundingClientRect canvas)]
{:vp-x (+ (.-left canvas-rect) (* (- penpot-x (:x vbox)) zoom))
:vp-y (+ (.-top canvas-rect) (* (- penpot-y (:y vbox)) zoom))}))
```
Usage example — click the center of a shape:
```clojure
(let [shape (get-in @app.main.store/state [:files file-id :data :pages-index page-id :objects shape-id])
cx (+ (:x shape) (/ (:width shape) 2))
cy (+ (:y shape) (/ (:height shape) 2))
{:keys [vp-x vp-y]} (penpot->viewport-coords cx cy)]
;; pass vp-x, vp-y to Playwright page.mouse.click(vp-x, vp-y)
{:vp-x vp-x :vp-y vp-y})
```
---
## JavaScript equivalent (for use in Playwright scripts directly)
```js
function penpotToViewport(penpotX, penpotY) {
// Read viewBox from the controls SVG (always in sync with app state)
const svg = document.querySelector('[class*="viewport-controls"]');
const [vbX, vbY, vbW, vbH] = svg.getAttribute('viewBox').split(' ').map(Number);
const rect = svg.getBoundingClientRect();
const zoom = rect.width / vbW; // == rect.height / vbH
return {
x: rect.left + (penpotX - vbX) * zoom,
y: rect.top + (penpotY - vbY) * zoom,
};
}
```
This function can be injected and called via `page.evaluate()` in Playwright:
```js
const {x, y} = await page.evaluate(
([px, py]) => penpotToViewport(px, py),
[penpotX, penpotY]
);
await page.mouse.click(x, y);
```
@@ -0,0 +1,47 @@
# Driving Real User Gestures via Playwright
Use Playwright when the bug or behavior depends on Penpot's real input pipeline: pointer gestures, keyboard modifiers, drag/drop targeting, modifier propagation, hover/focus behavior, or alt-drag duplication. The plugin JS API and `penpot:execute_code` can bypass these paths by dispatching store/API operations directly.
## When `execute_code` Is Not Enough
`execute_code` runs in the plugin sandbox. It is excellent for creating shapes, calling Plugin API methods, and querying design data, but it does not faithfully reproduce all user gestures. If the issue involves interactive transforms, frame targeting during drop, drag previews, modifier keys, or canvas hit-testing, drive the browser with Playwright and inspect results via cljs-repl.
## Gesture Pattern
A reliable drag gesture generally needs:
- focus on the canvas first;
- key modifiers held from before mouse down until after mouse up;
- intermediate mouse move events, not just start/end;
- short waits so Penpot's drag pipeline observes the gesture;
- a trailing wait for the transaction to commit.
Alt-drag duplication example:
```javascript
async (page) => {
await page.mouse.click(700, 700);
await page.waitForTimeout(200);
const startX = 821, startY = 565, endX = 821, endY = 815;
await page.keyboard.down('Alt');
await page.mouse.move(startX, startY);
await page.waitForTimeout(100);
await page.mouse.down();
await page.waitForTimeout(100);
for (let i = 1; i <= 10; i++) {
const t = i / 10;
await page.mouse.move(startX + (endX - startX) * t,
startY + (endY - startY) * t);
await page.waitForTimeout(20);
}
await page.waitForTimeout(100);
await page.mouse.up();
await page.waitForTimeout(100);
await page.keyboard.up('Alt');
await page.waitForTimeout(500);
}
```
## Coordinate Planning
For reliably finding pixel positions of objects, see `mem:frontend/penpot-to-browser-coords`.
@@ -0,0 +1,34 @@
# Frontend Plugin API Runtime Subtleties
## Type declarations vs runtime
- The Plugin API is a public facade over internal frontend/common data. Do not expect Plugin API property names, value shapes, or behavior boundaries to match internal CLJS attrs or helper APIs; inspect the relevant proxy and internal code path before using Plugin API observations in production internals or tests.
- `plugins/libs/plugin-types/index.d.ts` contains TypeScript declarations only. Runtime objects are CLJS proxies built under `frontend/src/app/plugins/*.cljs` with `obj/reify`.
- `shape.cljs` builds shape proxies with hidden ids and per-property CLJS implementations. `library.cljs` builds library proxies such as `LibraryComponentProxy`.
- `shape.cljs`, `library.cljs`, and related namespaces break circular dependencies with mutable nil vars patched from `app.plugins` at load time. If a proxy constructor appears nil, check the patching path in `frontend/src/app/plugins.cljs`.
## Key Domain Namespaces
- `app.common.types.component` (aliased `ctk`) — component predicates: `instance-root?`, `instance-head?`, `in-component-copy?`, `is-variant?`
- `app.common.types.container` (aliased `ctn`) — container/tree operations: `in-any-component?`, `get-instance-root`, `get-head-shape`, `inside-component-main?`
- `app.common.types.file` (aliased `ctf`) — file-level operations: `resolve-component`, `get-ref-shape`
## Runtime initialization and permissions
- The frontend initializes `@penpot/plugins-runtime` only after `features/initialize` and only when feature `plugins/runtime` is active. It also installs the runtime `isPluginError` predicate into frontend error handling.
- Manifest parsing expands write permissions to read permissions (`content:write` => `content:read`, etc.). Permission checks also allow the all-zero plugin id and the hard-coded MCP plugin id.
- Manifest URL origin differs by manifest version: v1 clears the path; v2 joins `.` to the plugin URL. Existing plugin ids are reused by matching manifest name and host.
- The MCP plugin id is defined in `app.plugins.register` to avoid a circular dependency with workspace MCP code.
## Proxy behavior
- Public Plugin API objects are lightweight handles, not durable snapshots. Most getters locate fresh state from `app.main.store/state` using hidden `$id`, `$file`, `$page`, etc.
- `not-valid` logs by default but throws when the plugin flag `throwValidationErrors` is enabled. The MCP execute-code handler deliberately enables that flag while running code.
- `naturalChildOrdering` and `throwValidationErrors` are stored per plugin under `[:plugins :flags plugin-id ...]`; changing default behavior affects automation and MCP diagnostics.
- Plugin data is stored under keyword namespaces: private data uses `(keyword "plugin" plugin-id)`, shared data uses `(keyword "shared" namespace)`.
## Events and history
- Plugin listeners are watches on the global store and callbacks are debounced about 10ms. Callback exceptions are caught and logged so plugin code does not crash the app.
- `selectionchange` callbacks receive arrays of shape id strings, while `filechange`, `pagechange`, and `shapechange` return proxies.
- `contentsave` fires only when persistence status transitions to `:saved`; it calls the callback with no value.
- Plugin history `undoBlockBegin` creates a workspace undo transaction with a JS `Symbol`; `undoBlockFinish` commits that symbol. Missing finish eventually relies on the workspace transaction timeout.
@@ -0,0 +1,17 @@
# Frontend Routing, App Shell, Websocket, and Error Subtleties
## Router, app shell, and errors
- Routing uses browser-history hash tokens, but `on-navigate` rejects navigation if the current origin/path does not match `cf/public-uri`.
- Route params are split into `:path` and `:query`; duplicate query params can become vectors, so use `rt/get-query-param` when a scalar is required.
- Unknown/empty routes trigger an extra `get-profile`/`get-teams` check before redirecting. This avoids invitation and root-route race conditions.
- The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`.
- Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload.
- Plugin-originated uncaught errors are identified through the plugin runtime hook and logged rather than turning into the global exception page.
## Store and websocket
For general store mechanics such as `emit!`, `last-events`, persistence, and undo, read `mem:frontend/workspace-state-persistence-subtleties`.
- Websocket initialization uses `cf/public-uri` joined with `ws/notifications`, converting `http/https` to `ws/wss`, and includes the current `session-id` as query param.
- Reinitializing or finalizing websocket stops the previous receive stream. Incoming websocket payloads become Potok data events under `app.main.data.websocket/message`.
+35
View File
@@ -0,0 +1,35 @@
# Frontend Testing and Live Verification
Frontend validation: CLJS + React/Rumext + RxJS/Potok; SCSS modules; shared CLJC from `common/`.
## Unit tests
Frontend unit tests live under `frontend/test/frontend_tests/` and use `cljs.test`. They should be deterministic, avoid DOM/UI integration where possible, and mock side effects such as RPC, storage, timers, or network access.
From `frontend/`:
- Full unit test run: `pnpm run test:quiet`.
- Focus a frontend CLJS test namespace: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens`.
- Focus one frontend CLJS test var: `pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
- Quiet `app.*` logging during a run: append `--log-level warn` (or `trace|debug|info|warn|error`).
- Build test target only: `pnpm run build:test`.
- After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout`.
- Watch tests: `pnpm run watch:test`.
New frontend test namespaces must be required/listed in `frontend_tests/runner.cljs`; new vars in existing namespaces need no runner change.
## Playwright integration tests
Do not add, modify, or run Playwright integration tests under `frontend/playwright` unless explicitly asked. When explicitly asked, use `pnpm run test:e2e` or `pnpm run test:e2e --grep "pattern"` from `frontend/`; ensure dependencies are installed through `./scripts/setup` if the environment is not prepared.
Integration tests fake backend behavior by intercepting network/websocket traffic, so every RPC or websocket the page needs must be mocked. Use existing Page Object Models:
- `BasePage.mockRPC` intercepts RPC calls and already prefixes `/api/rpc/command/`; pass command names such as `get-profile`, not full URLs.
- Workspace or other websocket-using pages should extend/use `BaseWebSocketPage`, initialize websocket mocks before each test, and mock `/ws/notifications` with the provided helpers.
- Prefer common locators/actions in POMs; ad-hoc locators can stay in a single test.
Locator priority should follow user-facing semantics: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then semantic alternatives such as alt/title, with `getByTestId` as the last resort. Name tests from the user's perspective and prefer positive, single-purpose assertions.
## Live browser verification
Because CLJC compiles to both JVM and CLJS, JVM/common tests can miss frontend-only state caused by browser runtime, WASM modifier math, or real pointer events. Use `mem:frontend/cljs-repl` to inspect live app state and `mem:frontend/playwright-gestures` when real input is needed.
For stale hot reload or failed CLJ/CLJC/CLJS source builds, read `mem:frontend/compile-diagnostics`. For Internal Error pages or delayed runtime crashes after automation/API actions, read `mem:frontend/handling-crashes`. Translation `.po` changes are bundled into `index.html` and require a browser refresh.
@@ -0,0 +1,56 @@
# Frontend UI Conventions and Style System
## CLJS app UI
- Main app components live under `frontend/src/app/main/ui*` and normally use Rumext `mf/defc` with a `*` suffix for component vars and `[:> component* props]` call sites.
- Components should have clear ownership. Use `children` for normal composition; use slotted props only when separate owned regions are needed. Do not style or structurally manipulate child DOM that the component did not instantiate.
- Accept and merge a `class` prop when callers reasonably need layout/positioning customization. Use `mf/spread-props` so Rumext prop transformations such as `:class` -> `className` still apply.
- Avoid boolean prop names ending in `?`; they do not translate cleanly to JavaScript props. Use type hints such as `^boolean` where JS truthiness/semantics matter.
- Split large components into smaller private components when useful; `::mf/private true` is the local convention for private Rumext components.
## Styling
- Co-located SCSS modules are preferred. Use `app.main.style/stl` helpers from CLJS and design-system SCSS tokens/mixins instead of legacy global selectors or high-specificity nesting.
- Keep CSS specificity low. Avoid nested selectors unless they target elements the component owns; CSS Modules already prevent class-name collisions.
- Prefer CSS logical properties for directional spacing/layout (`padding-inline-start`, etc.). Physical `width`/`height` are still acceptable where they are clearer.
- Use named design-system variables/tokens for spacing, borders, fixed dimensions, colors, and typography. Avoid hardcoded px/rem values and deprecated `resources/styles/common/refactor/spacing.scss` variables.
- Use component-local CSS custom properties for variants and theming instead of one-off Sass variables when a component has multiple visual states.
- Prefer DS typography components (`heading*`, `text*`) and typography mixins instead of plain text wrappers or deprecated typography mixins.
## Accessibility
- Prefer semantic HTML first: anchors for navigation/download/email links, buttons for actions, correct heading levels, and keyboard-focusable controls.
- If native elements cannot be used, apply appropriate ARIA roles/patterns. Follow WAI-ARIA APG patterns for standard widgets.
- Icon-only controls need an accessible name via surrounding text, `aria-label`, `alt`, or equivalent. Decorative images/icons should be hidden from assistive tech.
## I18n
- Translations must be resolved during render or render-time memoization, not at namespace load time. For static option lists, memoize inside render so locale changes still update labels.
- Translation files live in `frontend/translations/*.po`. Translation changes are bundled into `index.html`; refresh the browser after changing translations because there is no hot reload for translation strings.
- Run `pnpm run translations` from `frontend/` after adding/updating translation text.
- Adding a new supported locale requires updates in both `frontend/src/app/util/i18n.cljs` (`supported-locales`) and `frontend/scripts/_helpers.js` (`langs`).
## Performance
- Keep expensive derived data in refs, memoized selectors, or pure helpers. In hot render paths, prefer existing `app.common.data.macros` helpers where local code already uses them.
- Avoid creating new callback functions/objects inside hot renders when a named function, memoized callback, data attribute, or precomputed JS props object works.
- Destructure props/state values used repeatedly. Avoid repeated deref/property access in render loops.
## Shared React UI package
- `frontend/packages/ui` is the shared React/Vite package. It should remain framework-neutral relative to the CLJS app store; reusable primitives belong here only when they do not depend on Potok/Rumext app state.
- Package styles are emitted through the package build and copied into `frontend/resources/public/css/ui.css`; stale shared styles are often a build artifact issue.
- Storybook is the primary visual harness for shared UI/package behavior. Use `mem:frontend/ui-packages-text-editor-workflow` for package build/test commands.
## Choosing a location
- Put editor/dashboard/viewer workflow logic in CLJS app namespaces close to the owning feature.
- Put reusable presentational React primitives in `frontend/packages/ui` when they can be consumed without Penpot app state.
- Put CLJS design-system components under `frontend/src/app/main/ui/ds`; new DS components need implementation, CSS module, Storybook story, optional MDX docs, and export from `frontend/src/app/main/ui/ds.cljs` with a JavaScript-friendly name.
- Put text editing internals in `frontend/text-editor` when the behavior belongs to the JS editor package; use `mem:common/text-subtleties` for shared text data-model behavior.
## Validation
- For CLJS app UI, use `mem:frontend/testing`, `mem:frontend/compile-diagnostics`, and live browser/REPL checks when behavior depends on store or canvas state.
- For shared UI package changes, run the package build plus Storybook/component tests when relevant.
- For text editor changes, run `frontend/text-editor` tests and refresh/copy WASM artifacts if render-wasm output is involved.
@@ -0,0 +1,34 @@
# Frontend UI Packages and Text Editor Workflow
`frontend/packages/`, `frontend/text-editor/`, Storybook/component tests. Separate from CLJS app UI under `frontend/src/app/main/ui`.
## Package boundaries
- `frontend/packages/ui` builds `@penpot/ui`, a React/Vite library package. It exports ESM and type declarations from `dist/`; React and ReactDOM are peer dependencies and must stay external in the Vite library build.
- The UI package build copies generated `dist/index.css` into `frontend/resources/public/css/ui.css`. If shared UI styles look stale in the app, rebuild the package or check this copy step before debugging CLJS style code.
- `frontend/text-editor` builds `@penpot/text-editor` from `src/editor/TextEditor.js`. It is a Vite JS package, not CLJS, and has its own Vitest/browser-test setup.
- The text editor consumes render-wasm artifacts copied from `frontend/resources/public/js` into `frontend/text-editor/src/wasm`. Use `pnpm run wasm:update` after rebuilding `render-wasm` if tests or local dev use stale WASM files.
- Other packages under `frontend/packages/` such as `tokenscript`, `draft-js`, and `mousetrap` are workspace dependencies used by the frontend app; do not assume their runtime behavior lives in CLJS namespaces.
## Commands
From `frontend/`:
- Build app-side JS package assets: `pnpm run build:app:libs`.
- Watch app-side JS package assets: `pnpm run watch:app:libs`.
- Storybook build: `pnpm run build:storybook`; local Storybook: `pnpm run watch:storybook`.
- Storybook/component tests: `pnpm run test:storybook`.
From `frontend/packages/ui`:
- Build library and CSS artifact: `pnpm run build`.
- Watch library build: `pnpm run watch`.
From `frontend/text-editor`:
- Local Vite dev: `pnpm run dev`.
- Tests: `pnpm run test`; coverage: `pnpm run coverage`; browser watch: `pnpm run test:watch:e2e`.
- Format check: `pnpm run fmt:js`.
## Validation notes
- Frontend root `check-fmt:js` covers stories, Playwright scripts, frontend scripts, and `text-editor/**/*.js`; it does not replace package-specific builds/tests.
- Changes to shared UI package exports should be validated both in the package build and in the consuming app/Storybook path.
- Changes that alter text rendering/editing can involve `frontend/text-editor`, `render-wasm`, CLJS text integration, and `mem:common/text-subtleties`; verify the runtime that actually owns the changed behavior.
@@ -0,0 +1,33 @@
# Frontend Workspace State and Persistence Subtleties
## Store and interaction streams
- `app.main.store/state` is the Potok store; `emit!` always returns nil. Store errors flow through the mutable `on-error` atom.
- `last-events` keeps a filtered rolling buffer of about 50 event type strings and commit hint origins. It intentionally omits noisy websocket/persistence/pointer events.
- `ongoing-tasks` controls `window.onbeforeunload`: any non-empty set blocks tab unload.
- `app.main.streams/wasm-modifiers` and `workspace-selrect` are behavior subjects used for high-frequency interactive preview state that bypasses normal store updates and lenses.
- Keyboard modifier streams merge a window blur signal so stuck modifier-key state is cleared after focus loss.
## Repo calls
- `app.main.repo/send!` uses GET only when the RPC name starts with `get-`, when all params are query params, or for configured special cases. Only GET requests are retried.
- GET retry is limited to transient `:network`, `:bad-gateway`, `:service-unavailable`, and `:offline` errors with exponential backoff. Mutations are not retried.
- A server SSE response is only accepted when the command is configured `:stream?`; otherwise it raises an unexpected-response assertion.
## Commits, undo, persistence
- `commit-changes` refuses to create commits unless `:permissions :can-edit` is true. It captures file revn/vern, selected-before, features, tags, undo group, and translation flag into a `::commit` event.
- Applying a remote commit first rolls back pending local commits, applies the remote changes, then replays pending local redo changes. Index updates are emitted for undo, remote redo, and replayed redo paths.
- Local commits are independently consumed by undo, persistence, WASM model updates, thumbnail/library watchers, and text position-data recalculation.
- Persistence buffers local commits: status becomes pending after about 200ms, commits are flushed after about 3s or `::force-persist`, and buffered commits are merged per file before `:update-file`.
- Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker.
- Persistence is skipped in version preview/read-only mode or without edit permission.
- Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50.
- Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path.
- After local commits and when render-wasm is active, text shapes get derived `:position-data` recomputed in a separate commit tagged `#{:position-data}`; that tag is excluded from the position-data watcher to avoid loops.
## Refs
- `refs/libraries` is explicitly deprecated for performance; prefer derefing `refs/files` and memoizing `select-libraries` in components.
- `refs/workspace-page-objects` uses `identical?` equality, so preserving object map identity matters for avoiding derived-ref churn.
- Selected-shapes refs use a small `{objects selected}` wrapper with custom equality before running `process-selected`; avoid bypassing that pattern in hot UI paths.
@@ -0,0 +1,17 @@
# Frontend Workspace Token Subtleties
## Token refs and visibility
- Workspace token refs intentionally hide the internal hidden theme from theme trees/lists and expose active tokens through `get-tokens-in-active-sets`.
- Token values stored on shapes are token names under `:applied-tokens`, not token ids. Renames/group renames must update those paths in common token logic.
## Token application
- Token application refuses to run while a text shape is in text-editing mode and shows a warning instead.
- Applying a token writes token names into shape `:applied-tokens`, resolves active tokens through Style Dictionary or `tokenscript` depending on feature flags, updates concrete shape attrs, and wraps the operation in an undo transaction.
- Applying composite typography removes atomic typography token attrs; applying atomic typography removes the composite typography token attr.
- Spacing tokens have a special split path: layout containers receive gap/padding updates, while immediate children of layouts receive margin updates.
## Propagation
- Token propagation resolves active tokens, buffers many `update-shapes` commits, walks the current page first then the remaining pages, clears affected frame/component thumbnails, and drops `:position-data` for text shapes on non-current pages so it can be regenerated.
@@ -0,0 +1,20 @@
# Frontend Workspace Transform Subtleties
## Preview vs committed transforms
- High-frequency previews use `app.main.streams/wasm-modifiers` and `workspace-selrect` behavior subjects instead of normal store commits; components consume them through refs that wrap plain atoms.
- `apply-modifiers*` is the lower-level commit path once object/text modifiers are ready. It updates frame guides, frame comment threads, and then emits `update-shapes` with `:reg-objects? true`.
- Transform commits restrict diff attrs to `transform-attrs` to avoid scanning unrelated shape attrs.
- Text transforms may carry derived `:position-data`; `assoc-position-data` attaches it while preserving the original text shape context.
## Component-copy touched suppression
- `calculate-ignore-tree` walks modified shapes and descendants to decide per copy-shape `ignore-geometry?`.
- `check-delta` compares a copy's relative position/rotation to its component root before and after transform. If relative movement is under about 1px and size/rotation are effectively unchanged, geometry touching is suppressed.
- This logic is why pure translations of component copies can avoid marking every descendant as geometry-touched, while resizes/rotations still propagate touched state.
## WASM bridge details
- WASM modifier updates set plugin/local props with parsed geometry/structure modifiers rather than directly mutating file data.
- The position-data recomputation watcher ignores commits tagged `:position-data`; keep that tag when adding derived position-data commits.
- Rotation has separate WASM and non-WASM event paths. Check both when changing rotation modifier semantics.
+30
View File
@@ -0,0 +1,30 @@
# Library Architecture and Workflow
`library/`: builds `@penpot/library`; JS-facing in-memory Penpot file builder and `.penpot` ZIP exporter. Separate from main app runtime.
## Layout and commands
- Source: `library/src/`; tests: `library/test/`; experimentation/docs: `playground/`, `docs/`; config: `shadow-cljs.edn`, `deps.edn`, `package.json`.
- From `library/`: build `pnpm run build`; bundle helper `pnpm run build:bundle` or `./scripts/build`; tests `pnpm run test`; watch `pnpm run watch` / `pnpm run watch:test`; lint `pnpm run lint`; format check/fix `pnpm run check-fmt` / `pnpm run fmt`.
- When changing file-format construction or export behavior in `common/`, consider whether `@penpot/library` should be tested because it constructs Penpot files outside the app UI.
## JS API and builder state
- The JS build context wraps an atom and implements `IDeref`; `getInternalState` exposes the CLJ state converted to JS.
- Public methods decode JS objects through the JSON transformer before calling common builder functions. Exceptions become JS `BuilderError` objects with enumerable `cause` and an `explain` getter for Malli explain data.
- `create-build-context` can store an optional `referer`, later written into the export manifest.
- The builder is stateful: call `addFile` before `addPage`. `addPage` resets the frame/group stack to the root and clears page-local naming state when the page closes.
- `addBoard` and `addGroup` push onto the parent stack; matching close calls pop it. `closeGroup` requires at least one child and recalculates group geometry. Masked groups use the first child as mask and copy its geometry.
- `commit-shape` emits `:add-obj` with `:ignore-touched true`, using the current parent, frame, and page from the stack.
- Layer names are uniqued per current page; duplicate names get generated suffixes.
- `addBool` converts an existing group into a bool shape and updates style/content/geometry via `:mod-obj` operations rather than adding a new object.
- Media blobs are stored separately from file-media metadata; `add-file-media` requires a `BlobWrapper`.
## Export package
- `.penpot` ZIPs include `manifest.json`, file/page/shape JSON, components/colors/typographies/tokens, media metadata, and media object blobs.
- Path/bool shape `:content` is converted to vectors before JSON encoding.
- File export intentionally includes only selected top-level attrs plus data options; color export removes `:file-id` and drops empty paths.
- Manifest type is `penpot/export-files`, version 1, generated by `penpot-library/%version%`, with optional referer and file relations.
- Export generation is sequential and lazy: delayed JSON/blob work is computed only as each zip entry is written, and the progress callback receives `{total,item,path}` after each entry.
- The library has compatibility defaults for features/migrations in the common builder; do not assume it always exports with the newest app-default migrations/features.
+95
View File
@@ -0,0 +1,95 @@
# Penpot MCP
This subproject provides an MCP server for Penpot integration.
The MCP server communicates with a Penpot plugin via WebSockets, allowing
the MCP server to send tasks to the plugin and receive results,
enabling advanced AI-driven features in Penpot.
## Tech Stack
- Language: TypeScript
- Runtime: Node.js
- Framework: MCP SDK (@modelcontextprotocol/sdk)
- Build Tool: TypeScript Compiler (tsc) + esbuild
- Package Manager: pnpm
## General Principles
IMPORTANT: Use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
## Project Structure (Excerpt)
```
mcp/
├── packages/common/ # Shared type definitions
│ ├── src/
│ │ ├── index.ts # exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package
├── packages/server/ # MCP server subproject
│ ├── src/
│ │ ├── index.ts # entry point
│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.)
│ │ ├── Tool.ts # base class for tools
│ │ ├── PluginTask.ts # base class for plugin tasks
│ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations
| ├── data/ # contains resources, such as API info and prompts
│ └── package.json
├── packages/plugin/ # Penpot plugin subproject
│ ├── src/
│ │ ├── main.ts # handles communication
│ │ └── plugin.ts # plugin implementation
│ └── package.json # Includes @penpot-mcp/common dependency
└── prepare-api-docs # Python project for the generation of API docs
```
## Key Development Tasks
### Adjusting the Prompts
The system prompt file (aka Penpot High-Level Overview) is located in
`packages/server/data/initial_instructions.md`.
### Adding a new Tool
1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`.
Tools can be associated with a `PluginTask` that is executed in the plugin.
Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution.
### Adding a new PluginTask
1. Implement the input data interface for the task in `packages/common/src/types.ts`.
2. Implement the `PluginTask` class in `packages/server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally!
4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list.
## Dev Tooling
From the `mcp/` directory, run
* `pnpm run build` to test the build of all packages
* `pnpm run fmt` to apply the auto-formatter
## Devenv plugin/server wiring
In the normal Penpot devenv MCP path, the browser plugin does not discover or route through Postgres. The frontend provides the plugin extension API with `mcp.getServerUrl()`, currently derived from `frontend/src/app/config.cljs` as `penpotMcpServerURI` if set, otherwise `<public-uri>/mcp/ws`. The MCP plugin opens a direct WebSocket to that URL and appends the current MCP access token as a query parameter.
The live plugin connection registry is in-memory inside each MCP server process (`PluginBridge.connectedClients` / `clientsByToken`). The database only stores MCP access tokens and profile props such as `mcp-enabled`; it does not manage which plugin is connected to which MCP server.
For parallel devenvs, prefer same-origin MCP routing: each Penpot instance should expose `/mcp/ws` through its own nginx/Caddy path to the MCP server running inside the same main container. Keep container-internal ports fixed (MCP defaults `4401/4402/4403`, backend/exporter/frontend defaults, etc.) and only offset host-side published ports per instance. If internal ports are offset, hardcoded local proxy config such as `docker/devenv/files/nginx.conf` will misroute unless templated too.
+33
View File
@@ -0,0 +1,33 @@
# Memory Maintenance
## Discovery Model
- Core principle: progressive discovery through references, building a graph of memories.
- Initially, agents are provided with the list of all memories (names only).
- Agents should read `mem:critical-info` as the top-level entry point (graph root).
This memory should contain references to other memories covering major project domains.
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
The depth of the graph shall depend on the project complexity.
- Use topics/folders to group related memories in order to make the content structure explicit.
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
The surrounding text should clearly indicate when to read the memory/which content to expect.
The text should provide more precise guidance than the memory name alone,
i.e. avoid a reference like "frontend debugging and error handling: `mem:frontend/handling-errors-and-debugging` and instead make clear which concrete aspects are covered in the memory.
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
## Style
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
Keep guidance durable and generalizable, not task-local.
## Add/update threshold
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
## Maintenance Actions
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.
+37
View File
@@ -0,0 +1,37 @@
# Plugins Architecture and Workflow
`plugins/`: standalone TypeScript/pnpm workspace for Plugin API packages and sample plugins. Related to, distinct from, frontend CLJS Plugin API runtime.
## Layout
- `libs/plugin-types`: TypeScript declarations for the public Penpot Plugin API. Type-only package; runtime behavior is implemented elsewhere.
- `libs/plugins-runtime`: runtime that loads plugins and exposes/generated API behavior to plugin code.
- `libs/plugins-styles`: reusable styling package for plugins.
- `apps/*-plugin`: sample/development plugins. `apps/e2e`: plugin e2e tests.
## Dev Workflow
- From `plugins/`: install `pnpm -r install`; runtime dev server `pnpm run start` or `pnpm run start:app:runtime`; sample plugin `pnpm run start:plugin:<name>`; build runtime `pnpm run build:runtime`; build plugins `pnpm run build:plugins`; lint `pnpm run lint`; format `pnpm run format:check` / `pnpm run format`; tests `pnpm run test`; e2e `pnpm run test:e2e`.
- If a change affects public Plugin API types or runtime, update `plugins/CHANGELOG.md`. Prefix type/signature entries with `**plugin-types:**`; runtime behavior entries with `**plugin-runtime:**`.
- JS Plugin API behavior inside Penpot app: `mem:frontend/plugin-api-to-cljs-binding`; TS declarations are not runtime code; many API objects are CLJS proxies in `frontend/src/app/plugins/*.cljs`.
## Sandbox and global cleanup
- The runtime uses SES compartments. Public API return values are passed through `ses.safeReturn` before crossing back to plugin code.
- Plugin `fetch` is sanitized: credentials are omitted and Authorization is blanked. The exposed response only includes ok/status/statusText/url/text/json.
- Timer callbacks are wrapped to mark plugin-originated errors, and timeout/interval IDs are tracked so plugin close can clear them.
- Plugin-originated errors are tracked in a WeakMap instead of mutating error objects, because SES can freeze errors.
- Closing a plugin removes public API keys from the compartment globalThis.
## Lifecycle
- Loading a plugin closes existing non-background plugins and resets the runtime registry. Be careful around `allowBackground` semantics when changing load/close behavior.
- If sandbox evaluation fails, the runtime marks the error as plugin-originated, closes the plugin, and rethrows.
- `plugin-manager` removes event listeners, timers, intervals, and modal state on close, and marks the plugin destroyed. Listener callbacks check that flag because Penpot events can fire after close.
## Modal/UI behavior
- Modal URL preparation differs by manifest version: v1 uses query string parameters, v2 puts parameters in the URL hash.
- `openModal` is idempotent for the same iframe source and avoids reopening when the target URL is already displayed.
- Modal permissions are derived from manifest permissions (`allow:downloads`, `clipboard:read`, `clipboard:write`).
- `resizeModal` clamps to at least 200x200 and at most the window minus margins, adjusting transform so the modal remains in the viewport.
+33
View File
@@ -0,0 +1,33 @@
# Production infrastructure (services Penpot depends on)
Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose providers.
## Services
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
## Task queue and worker model
Async tasks are enqueued via `wrk/submit!` (`app.worker`), which inserts a row into the shared Postgres `task` table tagged with `queue = "<tenant>:<queue-name>"`. Submission is **fire-and-forget** — RPC handlers never poll, never wait, and workers never publish to msgbus. The only completion signal is the `task` row's `status` / `completed_at` columns, which nothing in `rpc/` reads. Soft-delete RPCs return immediately after marking the top-level row, leaving the cascade and reaping to workers.
Workers run on backends with `enable-backend-worker` in `PENPOT_FLAGS`. Each worker-enabled backend has a `dispatcher` (polls `task` with `FOR UPDATE SKIP LOCKED`, marks status='scheduled', RPUSHes claimed task IDs into **its own** Redis list) and one or more `runner`s per queue (BLPOP from that same local list, execute, update the Postgres row). The Redis hand-off list is purely intra-backend — cross-backend coordination happens at the Postgres row level.
## Cross-backend safety
Postgres row locking is the only correctness primitive: `task` claims via `FOR UPDATE SKIP LOCKED`, cron firing via `FOR UPDATE SKIP LOCKED` on the `scheduled_task` row, plus task-handler-internal locks (e.g. `file_gc_scheduler` locks candidate file rows). This makes the work-claim path safe across any number of worker-enabled backends.
Two known race patterns survive multi-backend operation:
- **Cron dedup is best-effort.** The lock on `scheduled_task` is released when the task body finishes. If two backends' cron timers fire for the same scheduled instant with a gap larger than the task body's runtime, both execute it. Penpot's cron entries are idempotent (`session-gc`, `objects-gc`, `storage-gc-*`, `tasks-gc`, `upload-session-gc`, `file-gc-scheduler`); the exceptions are `:telemetry` (would double-report) and `:audit-log-archive` (depends on archive target idempotency).
- **`wrk/submit! ::dedupe true`** does a non-atomic `DELETE` then `INSERT`. Concurrent cross-backend submits can both bypass the `DELETE` (each sees the other's uncommitted insert as absent) and end up with duplicate `'new'` rows. Each row claims and runs once independently, so the underlying work is fine; the "at most one pending" guarantee weakens.
Penpot in production lives with both: horizontal-scale deployments accept "exactly-once" as "essentially-once for idempotent operations." Devenv parallel instances handle it by running workers only on ws0 (see `mem:devenv/core`).
## See also
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
+32
View File
@@ -0,0 +1,32 @@
# render-wasm Architecture and Workflow
`render-wasm/`: Rust crate compiled to WebAssembly via Emscripten/Skia; frontend loads generated JS/WASM renderer. FFI/memory/tile behavior: `mem:render-wasm/ffi-rendering-subtleties`.
## Stable Architecture
- Exported functions live around `src/main.rs` / `src/wapi.rs` and are called from ClojureScript bridge namespaces under `frontend/src/app/render_wasm*`.
- Updates are two-phase: ClojureScript calls exported setters to push shape data, then `render_frame()` performs Skia drawing.
- Rendering is tile-based and shape data is stored separately from hierarchy.
## Source Areas
- `src/state*`: renderer state structures.
- `src/render/` and `src/render.rs`: tile/surface render pipeline.
- `src/shapes/` and `src/shapes.rs`: shape data and Skia drawing.
- `src/wasm/`, `src/wasm.rs`, `src/mem.rs`: JS/WASM memory and interop helpers.
- `src/math/` and `src/view.rs`: geometry and viewport helpers.
## Build Environment
`./build` sources `_build_env`, which sets the Emscripten paths and `EMCC_CFLAGS`. The WASM heap starts at 256 MB and uses geometric growth.
## Commands
From `render-wasm/`:
- Build/copy frontend artifacts: `./build`.
- Watch rebuild: `./watch`.
- Rust tests: `./test` or `cargo test <name>`.
- Lint: `./lint`.
- Format check: `cargo fmt --check`.
Do not change exported WASM function signatures without updating the corresponding frontend bridge and verifying the frontend renderer path.
@@ -0,0 +1,25 @@
# render-wasm FFI and Rendering Subtleties
## FFI state and errors
- The renderer uses one unsafe global `STATE`; the `with_state*` macros currently panic on invalid state pointer. Treat state pointer validity as critical, not recoverable.
- `#[wasm_error]` clears the error code on entry. Recoverable errors set code `0x01`, critical errors/panics set `0x02`, free the byte buffer, then panic so the CLJS bridge can catch and inspect `_read_error_code`.
- The frontend bridge maps `0x01` to `:non-blocking` and `0x02` to `:panic` in ex-data (`:type :wasm-error`). Check actual bridge code if changing names; older comments/docs may use different labels.
- WASM byte transfer is a single global slot. A caller that receives a pointer result must read and free it before another byte payload is written; errors free the slot via `#[wasm_error]`.
## Shape pool and loading
- Shapes are UUID-indexed, and hierarchy/structure is tracked separately. `ShapesPool::get` may return a cached modified clone when modifiers, structure, scale-content, or bool handling apply; `get_raw` bypasses those derived values.
- Bulk loading uses a `loading` flag. `touch_current` / `touch_shape` avoid tile invalidation while loading; text layouts and final view setup must happen after loading ends.
- Many setters mutate only the current shape selected by `use_shape` / current-shape APIs. If no current shape is selected, some mutation blocks are skipped silently.
- `set_parent_for_current_shape` only sets parent metadata and invalidates parent geometry; children must be updated separately to avoid duplicate children.
- Child deletion marks descendants deleted and removes them from all indexed tiles, preserving undo/redo while avoiding stale pixels after panning.
## Tile/render behavior
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
+29
View File
@@ -0,0 +1,29 @@
# Paren-Repair
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
Clojure/ClojureScript files, then reformats them with cljfmt.
## When to use
- After LLM edits introduce broken delimiters — proactively run it on files
you just touched.
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
errors mentioning mismatched/unclosed delimiters, reader errors, or
unexpected EOF.
- Before running lint/format checks — delimiter errors make linter output
misleading. Fix them first, then lint.
## How to use
```bash
# File mode (in-place fix + format)
bb tools/paren-repair.bb path/to/file.clj
# Pipe mode (stdin → fixed code to stdout)
echo '(def x 1' | bb tools/paren-repair.bb
# Help
bb tools/paren-repair.bb --help
```
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.
@@ -0,0 +1,23 @@
# Creating Commits
Commit only on explicit request. Before commit: `git status`; exclude unrelated user changes.
Do not guess or hallucinate git author information (Name or Email). Never include the
`--author` flag in git commands unless specifically instructed by the user for a unique
case; assume the local environment is already configured. Allow git commit to
automatically pull the identity from the local git config `user.name` and `user.email`.
## Message Format
```
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
Body explaining what changed and why.
Co-authored-by: model-name <model-name@penpot.app>
```
## Commit Type Emojis
`:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight
@@ -0,0 +1,160 @@
# Creating Issues
Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`.
## Title Derivation
Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale.
### Bug titles (descriptive present tense)
Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`.
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix.
### Feature / Enhancement titles (imperative mood)
Command what should be built. Format: `[Imperative verb] [what] in/on [where]`.
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
### Universal rules
- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality
- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and"
## Metadata
| Field | Rule |
|-------|------|
| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) |
| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. |
| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. |
| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. |
## Issue Body Template
Write the body to a temp file to avoid shell quoting issues:
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
## Creating the Issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Derived title>" \
--label "<label>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
## Setting the Issue Type
`gh issue create` can't set Issue Type directly. Use GraphQL after creation.
**Issue Type IDs for penpot/penpot:**
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
**Map:**
- `bug` label → Bug
- `enhancement` label → Enhancement
- Feature/epic → Feature
- Docs → Docs
- None of the above → Task
**Set it:**
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
## Verification
```bash
gh issue view <NUMBER> --repo penpot/penpot \
--json title,labels,milestone,projectItems \
--jq '{title, milestone: .milestone.title, labels: [.labels[].name], projects: [.projectItems[].title]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
## Cleanup
```bash
rm -f /tmp/issue-body.md
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`
+62
View File
@@ -0,0 +1,62 @@
# Creating Pull Requests
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Title Format
PR titles follow commit title conventions:
```
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
```
See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR title as the final commit subject, so title format matters.
## Description Body
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
PR descriptions follow this structure:
```markdown
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
## What
<one paragraph: the problem or feature, user-facing impact>
## Why
<root cause or motivation, why this change was necessary>
## How
<high-level approach, key technical decisions>
```
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
## Writing Principles
- **Write for humans.** The diff shows what changed. The description explains why.
- **Be concise.** Focus on reasoning: What was the problem? Why did it happen? How did you solve it?
- **Skip the obvious.** Don't explain what `git diff` already shows.
### What NOT to Include
- ❌ List of files changed (visible in diff)
- ❌ Testing steps (CI handles this)
- ❌ Screenshots unless UI-visible
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
## Before Opening
- Follow `mem:workflow/creating-commits` for commits
- Run the focused tests/lints appropriate to touched modules.
- Do not force-push during review unless the maintainer workflow explicitly asks for it.
+19
View File
@@ -0,0 +1,19 @@
# Docs Workflow
`docs/`: Penpot documentation site; Eleventy.
## Layout and Tooling
- `docs/package.json`: Eleventy commands and documentation-site dependencies.
- `docs/README.md`: local setup and tooling notes.
- Diagrams may use PlantUML, svgbob, mermaid, and C4/arc42 conventions depending on the existing page.
## Commands
From `docs/`:
- Install deps if needed: `pnpm install`.
- Local server: `pnpm start` or `pnpm run serve` (serves on `http://localhost:8080`).
- Build: `pnpm run build`.
- Watch: `pnpm run watch`.
Documentation changes should follow the existing page structure and rendered Help Center conventions rather than inventing a new style locally.
+142
View File
@@ -0,0 +1,142 @@
# the name by which the project can be referenced within Serena
project_name: "penpot"
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- clojure
- typescript
- rust
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: |
CRITICAL: Read the memory `critical-info` IMMEDIATELY. It is absolutely critical for your work on this project - as is
the Serena Instructions Manual. Do NOT wait for further instructions, read these critical items NOW!
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
+63 -88
View File
@@ -1,104 +1,79 @@
# AI Agent Guide
# AI AGENT GUIDE
This document provides the core context and operating guidelines for AI agents
working in this repository.
## CRITICAL: Read module memories BEFORE writing any code
## Before You Start
Do this **before planning, before coding, before touching any file**:
Before responding to any user request, you must:
1. Read `critical-info` (use `serena_read_memory critical-info` or read `.serena/memories/critical-info.md`).
It describes the project structure and tells you which modules exist.
2. From `critical-info`, identify which modules your task affects.
3. Read each affected module's **core memory** — the name is `<module>/core`
(e.g. `frontend/core`, `backend/core`, `common/core`).
4. If the core memory references deeper `mem:` memories relevant to your task, read those too.
1. Read this file completely.
2. Identify which modules are affected by the task.
3. Load the `AGENTS.md` file **only** for each affected module (see the
architecture table below). Not all modules have an `AGENTS.md` — verify the
file exists before attempting to read it.
4. Do **not** load `AGENTS.md` files for unrelated modules.
**STOP: Do not proceed until you have read the core memory of every affected module.**
Skipping this step is the #1 cause of incorrect or incomplete work.
## Role: Senior Software Engineer
---
# Memory system
Memories are the **primary project guidance** — not docs or readme files.
They are dense, agent-oriented notes: terse bullets, invariants, no prose.
## Entry point
Start at `critical-info` (the graph root). It describes the project structure,
module dependency graph, and references section-level core memories.
## Progressive discovery model
Memories form a **reference graph**, not a flat list:
```
critical-info ← read first (graph root)
└─ <section>/core ← top-level memory per section (e.g. frontend/core, backend/core)
└─ <topic> ← focused memories (e.g. frontend/handling-errors-and-debugging)
└─ ... ← deeper memories as needed
```
When working on a task:
1. Read `critical-info` to identify which sections are affected.
2. Read the affected section's `core` memory for an overview.
3. Follow `mem:` references in the core memory to focused memories relevant to your task.
4. Continue following references deeper as needed.
## Accessing memories
- **If `serena_read_memory` / `serena_list_memories` tools are available**: use them.
`serena_read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`).
- **If tools are NOT available**: read the filesystem directly.
Memory name `mem:foo/bar` maps to file `.serena/memories/foo/bar.md`.
## Cross-reference convention
Memories reference other memories with `mem:<section>/<name>` inside backticks.
Example: `mem:common/changes-architecture`.
When you encounter a `mem:` reference relevant to your task, read that memory next.
## Topic/folder organization
Memories are grouped into folders that mirror project modules or topics:
`backend/`, `common/`, `frontend/`, `render-wasm/`, `exporter/`, `workflow/`, etc.
Each folder's top-level memory is `<folder>/core`.
---
# Role: Senior Software Engineer
You are a high-autonomy Senior Full-Stack Software Engineer. You have full
permission to navigate the codebase, modify files, and execute commands to
fulfill your tasks. Your goal is to solve complex technical tasks with high
precision while maintaining a strong focus on maintainability and performance.
### Operational Guidelines
## Operational Guidelines
1. Before writing code, describe your plan. If the task is complex, break it
down into atomic steps.
2. Be concise and autonomous.
3. Do **not** touch unrelated modules unless the task explicitly requires it.
4. Commit only when explicitly asked. Follow the commit format rules in
`CONTRIBUTING.md`.
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
## Changelogs
The project has two changelogs:
- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp).
- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only.
When making changes, add a changelog entry to the appropriate file under the
`## <version> (Unreleased)` section in the correct category
(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`).
## GitHub Operations
To obtain the list of repository members/collaborators:
```bash
gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login'
```
To obtain the list of open PRs authored by members:
```bash
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
($members | split("|")) as $m |
.[] | select(.author.login as $a | $m | index($a)) |
"\(.number)\t\(.author.login)\t\(.title)"
'
```
To obtain the list of open PRs from external contributors (non-members):
```bash
MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//')
gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" '
($members | split("|")) as $m |
.[] | select(.author.login as $a | $m | index($a) | not) |
"\(.number)\t\(.author.login)\t\(.title)"
'
```
## Architecture Overview
Penpot is an open-source design tool composed of several modules:
| Directory | Language | Purpose | Has `AGENTS.md` |
|-----------|----------|---------|:----------------:|
| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes |
| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes |
| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes |
| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes |
| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No |
| `mcp/` | TypeScript | Model Context Protocol integration | No |
| `plugins/` | TypeScript | Plugin runtime and example plugins | No |
Some submodules use `pnpm` workspaces. The root `package.json` and
`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`.
### Module Dependency Graph
```
frontend ──> common
backend ──> common
exporter ──> common
frontend ──> render-wasm (loads compiled WASM)
```
`common` is referenced as a local dependency (`{:local/root "../common"}`) by
both `frontend` and `backend`. Changes to `common` can therefore affect multiple
modules — test across consumers when modifying shared code.
+378 -276
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -237,9 +237,8 @@ setting this up.
## Changelog
When your change is user-facing or otherwise notable, add an entry to
[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference
the relevant GitHub issue or Taiga user story.
The changelog is updated automatically as part of the release process. Contributors
should **not** modify `CHANGES.md` manually in their pull requests.
## Code of Conduct
+1 -1
View File
@@ -160,6 +160,6 @@ This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC
Copyright (c) KALEIDOS INC Sucursal en España SL
```
Penpot is a Kaleidos [open source project](https://kaleidos.net/)
-262
View File
@@ -1,262 +0,0 @@
# Penpot Backend Agent Instructions
Clojure backend (RPC) service running on the JVM.
Uses Integrant for dependency injection, PostgreSQL for storage, and
Redis for messaging/caching.
## General Guidelines
To ensure consistency across the Penpot JVM stack, all contributions must adhere
to these criteria.
IMPORTANT: all CLI commands should be executed under backend/
subdirectory for make them work correctly.
### 1. Testing & Validation
* **Coverage:** If code is added or modified in `src/`, corresponding
tests in `test/backend_tests/` must be added or updated.
* **Execution:**
* **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace.
* **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas.
### 2. Code Quality & Formatting
* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root)
* **Formatting:** All the code must pass the formatting check (run `pnpm run
check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty"
diffs caused by unrelated whitespace changes.
* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in
performance-critical paths to avoid reflection overhead.
## Code Conventions
### Namespace Overview
The source is located under `src` directory and this is a general overview of
namespaces structure:
- `app.rpc.commands.*` RPC command implementations (`auth`, `files`, `teams`, etc.)
- `app.http.*` HTTP routes and middleware
- `app.db.*` Database layer
- `app.tasks.*` Background job tasks
- `app.main` Integrant system setup and entrypoint
- `app.loggers` Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`)
### RPC
The RPC methods are implemented using a multimethod-like structure via the
`app.util.services` namespace. The main RPC methods are collected under
`app.rpc.commands` namespace and exposed under `/api/rpc/command/<cmd-name>`.
The RPC method accepts POST and GET requests indistinctly and uses the `Accept`
header to negotiate the response encoding (which can be Transit — the default —
or plain JSON). It also accepts Transit (default) or JSON as input, which should
be indicated using the `Content-Type` header.
The main convention is: use `get-` prefix on RPC name when we want READ
operation.
Example of RPC method definition:
```clojure
(sv/defmethod ::my-command
{::rpc/auth true ;; requires auth
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map or throw
{:id (uuid/next)})
```
Look under `src/app/rpc/commands/*.clj` to see more examples.
### Tests
Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`.
### Integrant System
The `src/app/main.clj` declares the system map. Each key is a component; values
are config maps with `::ig/ref` for dependencies. Components implement
`ig/init-key` / `ig/halt-key!`.
### Connecting to the Database
Two PostgreSQL databases are used in this environment:
| Database | Purpose | Connection string |
|---------------|--------------------|----------------------------------------------------|
| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` |
| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` |
**Interactive psql session:**
```bash
# development DB
psql "postgresql://penpot:penpot@postgres/penpot"
# test DB
psql "postgresql://penpot:penpot@postgres/penpot_test"
```
**One-shot query (non-interactive):**
```bash
psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;"
```
**Useful psql meta-commands:**
```
\dt -- list all tables
\d <table> -- describe a table (columns, types, constraints)
\di -- list indexes
\q -- quit
```
> **Migrations table:** Applied migrations are tracked in the `migrations` table
> with columns `module`, `step`, and `created_at`. When renaming a migration
> logical name, update this table in both databases to match the new name;
> otherwise the runner will attempt to re-apply the migration on next startup.
```bash
# Example: fix a renamed migration entry in the test DB
psql "postgresql://penpot:penpot@postgres/penpot_test" \
-c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';"
```
### Database Access (Clojure)
`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
```clojure
;; Query helpers
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
(db/delete! cfg-or-pool :table {:id id}) ; delete
;; Run multiple statements/queries on single connection
(db/run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row1)
(db/insert! conn :table row2))
;; Transactions
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row)))
```
Almost all methods in the `app.db` namespace accept `pool`, `conn`, or
`cfg` as params.
Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup.
### Error Handling
The exception helpers are defined on Common module, and are available under
`app.common.exceptions` namespace.
Example of raising an exception:
```clojure
(ex/raise :type :not-found
:code :object-not-found
:hint "File does not exist"
:file-id id)
```
Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`.
### Performance Macros (`app.common.data.macros`)
Always prefer these macros over their `clojure.core` equivalents — they provide
optimized implementations:
```clojure
(dm/select-keys m [:a :b]) ;; faster than core/select-keys
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
(dm/str "a" "b" "c") ;; string concatenation
```
### Configuration
`src/app/config.clj` reads `PENPOT_*` environment variables, validated with
Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags
:enable-smtp)`.
### Background Tasks
Background tasks live in `src/app/tasks/`. Each task is an Integrant component
that exposes a `::handler` key and follows this three-method pattern:
```clojure
(defmethod ig/assert-key ::handler ;; validate config at startup
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/expand-key ::handler ;; inject defaults before init
[k v]
{k (assoc v ::my-option default-value)})
(defmethod ig/init-key ::handler ;; return the task fn
[_ cfg]
(fn [_task] ;; receives the task row from the worker
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
;; … do work …
))))
```
**Wiring a new task** requires two changes in `src/app/main.clj`:
1. **Handler config** add an entry in `system-config` with the dependencies:
```clojure
:app.tasks.my-task/handler
{::db/pool (ig/ref ::db/pool)}
```
2. **Registry + cron** register the handler name and schedule it:
```clojure
;; in ::wrk/registry ::wrk/tasks map:
:my-task (ig/ref :app.tasks.my-task/handler)
;; in worker-config ::wrk/cron ::wrk/entries vector:
{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight
:task :my-task}
```
**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow):
| Expression | Meaning |
|------------------------------|--------------------|
| `"0 0 0 * * ?"` | Daily at midnight |
| `"0 0 */6 * * ?"` | Every 6 hours |
| `"0 */5 * * * ?"` | Every 5 minutes |
**Time helpers** (`app.common.time`):
```clojure
(ct/now) ;; current instant
(ct/duration {:hours 1}) ;; java.time.Duration
(ct/minus (ct/now) some-duration) ;; subtract duration from instant
```
`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL
interval object suitable for use in SQL queries:
```clojure
(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds"
```
+25 -23
View File
@@ -3,10 +3,10 @@
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
org.clojure/clojure {:mvn/version "1.12.5"}
org.clojure/tools.namespace {:mvn/version "1.5.1"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
com.github.luben/zstd-jni {:mvn/version "1.5.7-10"}
io.prometheus/simpleclient {:mvn/version "0.16.0"}
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
@@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"}
;; Minimal dependencies required by lettuce, we need to include them
;; explicitly because clojure dependency management does not support
;; yet the BOM format.
@@ -25,22 +25,22 @@
io.micrometer/micrometer-observation {:mvn/version "1.14.2"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
com.google.guava/guava {:mvn/version "33.4.8-jre"}
com.google.guava/guava {:mvn/version "33.6.0-jre"}
funcool/yetti
{:git/tag "v11.9"
:git/sha "5fad7a9"
{:git/tag "v11.10"
:git/sha "88701f4"
:git/url "https://github.com/funcool/yetti.git"
:exclusions [org.slf4j/slf4j-api]}
com.github.seancorfield/next.jdbc
{:mvn/version "1.3.1070"}
{:mvn/version "1.3.1108"}
metosin/reitit-core {:mvn/version "0.9.1"}
nrepl/nrepl {:mvn/version "1.4.0"}
metosin/reitit-core {:mvn/version "0.10.1"}
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.9"}
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
org.postgresql/postgresql {:mvn/version "42.7.11"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.0"}
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
@@ -49,39 +49,41 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
org.jsoup/jsoup {:mvn/version "1.21.2"}
org.jsoup/jsoup {:mvn/version "1.22.2"}
org.im4java/im4java
{:git/tag "1.4.0-penpot-2"
:git/sha "e2b3e16"
:git/url "https://github.com/penpot/im4java"}
org.lz4/lz4-java {:mvn/version "1.8.0"}
at.yawk.lz4/lz4-java
{:mvn/version "1.11.0"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
dawran6/emoji {:mvn/version "0.2.0"}
markdown-clj/markdown-clj {:mvn/version "1.12.4"}
markdown-clj/markdown-clj {:mvn/version "1.12.8"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
software.amazon.awssdk/s3 {:mvn/version "2.46.7"}
software.amazon.awssdk/sts {:mvn/version "2.46.7"}}
:paths ["src" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps
{com.bhauman/rebel-readline {:mvn/version "RELEASE"}
{com.bhauman/rebel-readline {:mvn/version "0.1.7"}
clojure-humanize/clojure-humanize {:mvn/version "0.2.2"}
org.clojure/data.csv {:mvn/version "RELEASE"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"}
mockery/mockery {:mvn/version "RELEASE"}}
org.clojure/data.csv {:mvn/version "1.1.1"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
mockery/mockery {:mvn/version "0.1.4"}}
:extra-paths ["test" "dev"]}
:build
{:extra-deps
{io.github.clojure/tools.build {:mvn/version "0.10.10"}}
{io.github.clojure/tools.build {:mvn/version "0.10.14"}}
:ns-default build}
:test
@@ -92,7 +94,7 @@
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}}
:outdated
{:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}}
{:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}}
:main-opts ["-m" "antq.core"]}
:jmx-remote
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; This is an example on how it can be executed:
;; clojure -Scp $(cat classpath) -M dev/script-fix-sobjects.clj
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns user
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "backend",
"version": "1.0.0",
"license": "MPL-2.0",
"author": "Kaleidos INC",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"repository": {
+14 -2
View File
@@ -19,7 +19,7 @@
{:permits 40}
:root/by-profile
{:permits 10}
{:permits 10 :queue 30 :timeout 30000}
:file-thumbnail-ops/global
{:permits 20}
@@ -27,4 +27,16 @@
{:permits 2}
:submit-audit-events/by-profile
{:permits 1 :queue 3}}
{:permits 1 :queue 3}
:restore-file-snapshot/global
{:permits 3}
:restore-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:create-file-snapshot/global
{:permits 3}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}}
+21 -17
View File
@@ -8,8 +8,18 @@ export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449
# Runtime config that varies per devenv instance (PENPOT_HOST, PENPOT_PUBLIC_URI,
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
# docker/devenv/defaults.env and injected via the main service's env block.
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
# mem:devenv/core for the rationale.
__worker_flag=""
if [[ "${PENPOT_BACKEND_WORKER:-true}" == "true" ]]; then
__worker_flag="enable-backend-worker"
fi
export PENPOT_FLAGS="\
$PENPOT_FLAGS \
@@ -20,7 +30,7 @@ export PENPOT_FLAGS="\
disable-login-with-github \
disable-login-with-gitlab \
disable-telemetry \
enable-backend-worker \
$__worker_flag \
enable-backend-asserts \
disable-feature-fdata-pointer-map \
enable-feature-fdata-objects-map \
@@ -60,13 +70,7 @@ export PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE=314572800
export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
export AWS_ACCESS_KEY_ID=penpot-devenv
export AWS_SECRET_ACCESS_KEY=penpot-devenv
export PENPOT_OBJECTS_STORAGE_BACKEND=s3
export PENPOT_OBJECTS_STORAGE_S3_ENDPOINT=http://minio:9000
export PENPOT_OBJECTS_STORAGE_S3_BUCKET=penpot
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/control-center
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
@@ -84,14 +88,14 @@ export JAVA_OPTS="\
--enable-native-access=ALL-UNNAMED";
function setup_minio() {
# Initialize MINIO config
mc alias set penpot-s3/ http://minio:9000 minioadmin minioadmin -q
mc admin user add penpot-s3 penpot-devenv penpot-devenv -q
mc admin user info penpot-s3 penpot-devenv |grep -F -q "readwrite"
if [ "$?" = "1" ]; then
mc admin policy attach penpot-s3 readwrite --user=penpot-devenv -q
if [ "${PENPOT_OBJECTS_STORAGE_BACKEND}" != "s3" ]; then
return 0
fi
mc mb penpot-s3/penpot -p -q
# Shared MinIO user/policy provisioning is handled by docker-compose.infra.yml.
# Per process startup only ensures that the configured bucket exists.
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
}
+1 -1
View File
@@ -4,7 +4,7 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# Copyright (c) KALEIDOS INC
# Copyright (c) KALEIDOS INC Sucursal en España SL
import argparse
import json
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth.ldap
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.auth.oidc
"OIDC client implementation."
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.cleaner
"A collection of helpers for perform cleaning of artifacts; mainly
+57 -31
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.common
"A binfile related file processing common code, used for different
@@ -440,11 +440,28 @@
(db/run! cfg (fn [{:keys [::db/conn]}]
(let [ids (db/create-array conn "uuid" ids)
sql (str "SELECT flr.* FROM file_library_rel AS flr "
" JOIN file AS l ON (flr.library_file_id = l.id) "
" WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")]
sql (str "SELECT flr.*,"
" fls.synced_at"
" FROM file_library_rel AS flr"
" JOIN file AS l"
" ON flr.library_file_id = l.id"
" LEFT JOIN file_library_sync AS fls"
" ON fls.file_id = flr.file_id"
" AND fls.library_file_id = flr.library_file_id"
" WHERE flr.file_id = ANY(?)"
" AND l.deleted_at IS NULL;")]
(db/exec! conn [sql ids])))))
(def ^:private sql:upsert-file-library-sync
"INSERT INTO file_library_sync (file_id, library_file_id, synced_at)
VALUES (?::uuid, ?::uuid, ?::timestamptz)
ON CONFLICT (file_id, library_file_id)
DO UPDATE SET synced_at = EXCLUDED.synced_at;")
(defn upsert-file-library-sync!
[conn {:keys [file-id library-file-id synced-at]}]
(db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at]))
(def ^:private sql:get-libraries
"WITH RECURSIVE libs AS (
SELECT fl.id
@@ -799,32 +816,41 @@
(def ^:private sql:get-file-libraries
"WITH RECURSIVE libs AS (
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*, flr.synced_at
FROM file AS fl
JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id)
JOIN libs AS l ON (flr.file_id = l.id)
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.synced_at,
l.is_shared,
l.version
FROM libs AS l
INNER JOIN project AS p ON (p.id = l.project_id)
WHERE l.deleted_at IS NULL;")
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
WHERE flr.file_id = ?::uuid
UNION
SELECT fl.*
FROM file AS fl
JOIN file_library_rel AS flr
ON flr.library_file_id = fl.id
JOIN libs AS l
ON flr.file_id = l.id
)
SELECT l.id,
l.features,
l.project_id,
p.team_id,
l.created_at,
l.modified_at,
l.deleted_at,
l.name,
l.revn,
l.vern,
l.is_shared,
l.version,
fls.synced_at
FROM libs AS l
JOIN project AS p
ON p.id = l.project_id
LEFT JOIN file_library_sync AS fls
ON fls.file_id = ?::uuid
AND fls.library_file_id = l.id
WHERE l.deleted_at IS NULL;")
(defn get-file-libraries
[conn file-id]
@@ -834,7 +860,7 @@
;; completly useless
(map #(assoc % :is-indirect false))
(map decode-row-features))
(db/exec! conn [sql:get-file-libraries file-id])))
(db/exec! conn [sql:get-file-libraries file-id file-id])))
(defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.migrations
"A binfile related migrations handling"
+7 -3
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.v1
"A custom, perfromance and efficiency focused binfile format impl"
@@ -573,7 +573,6 @@
;; Insert all file relations
(doseq [{:keys [library-file-id] :as rel} rels]
(let [rel (-> rel
(assoc :synced-at timestamp)
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index))]
@@ -583,7 +582,12 @@
:file-id (:file-id rel)
:lib-id (:library-file-id rel)
::l/sync? true)
(db/insert! conn :file-library-rel rel))
(let [rel-params (dissoc rel :synced-at)]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params)
:library-file-id (:library-file-id rel-params)
:synced-at (or (:synced-at rel)
timestamp)})))
(l/warn :hint "ignoring file library link"
:file-id (:file-id rel)
+4 -4
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.v2
"A sqlite3 based binary file exportation with support for exportation
@@ -314,10 +314,10 @@
(doseq [rel (read-obj cfg :file-rels file-id)]
(let [rel (-> rel
(update :file-id bfc/lookup-index)
(update :library-file-id bfc/lookup-index)
(assoc :synced-at timestamp))]
(update :library-file-id bfc/lookup-index))]
(db/insert! conn :file-library-rel rel
::db/return-keys false)))
::db/return-keys false)
(bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp))))
(doseq [media (read-seq cfg :file-media-object file-id)]
(let [media (-> media
+5 -5
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.binfile.v3
"A ZIP based binary file exportation"
@@ -824,10 +824,10 @@
:file-id (str file-id)
:lib-id (str libr-id)
::l/sync? true)
(db/insert! conn :file-library-rel
{:synced-at timestamp
:file-id file-id
:library-file-id libr-id})))))
(let [rel-params {:file-id file-id
:library-file-id libr-id}]
(db/insert! conn :file-library-rel rel-params)
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))
(defn- import-storage-objects
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
+3 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.config
(:refer-clojure :exclude [get])
@@ -72,6 +72,7 @@
:telemetry-uri "https://telemetry.penpot.app/"
:media-max-file-size (* 1024 1024 30) ; 30MiB
:font-max-file-size (* 1024 1024 30) ; 30MiB
:ldap-user-query "(|(uid=:username)(mail=:username))"
:ldap-attrs-username "uid"
@@ -120,6 +121,7 @@
[:auto-file-snapshot-timeout {:optional true} ::ct/duration]
[:media-max-file-size {:optional true} ::sm/int]
[:font-max-file-size {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
+26 -8
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.db
(:refer-clojure :exclude [get run!])
@@ -27,7 +27,9 @@
[next.jdbc.transaction])
(:import
com.zaxxer.hikari.HikariConfig
com.zaxxer.hikari.HikariConfigMXBean
com.zaxxer.hikari.HikariDataSource
com.zaxxer.hikari.HikariPoolMXBean
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
io.whitfin.siphash.SipHasher
io.whitfin.siphash.SipHasherContainer
@@ -67,9 +69,8 @@
(def defaults
{::name :main
::min-size 0
::max-size 60
::connection-timeout 10000
::connection-timeout 30000
::validation-timeout 10000
::idle-timeout 120000 ; 2min
::max-lifetime 1800000 ; 30m
@@ -82,7 +83,7 @@
(defmethod ig/init-key ::pool
[_ cfg]
(let [{:keys [::uri ::read-only] :as cfg}
(merge defaults cfg)]
(merge defaults (d/without-nils cfg))]
(when uri
(l/info :hint "initialize connection pool"
:name (d/name (::name cfg))
@@ -90,7 +91,8 @@
:read-only read-only
:credentials (and (contains? cfg ::username)
(contains? cfg ::password))
:min-size (::min-size cfg)
:min-size (or (::min-size cfg)
(::max-size cfg))
:max-size (::max-size cfg))
(create-pool cfg))))
@@ -111,7 +113,9 @@
[{:keys [::uri] :as cfg}]
;; (app.common.pprint/pprint cfg)
(let [config (HikariConfig.)]
(let [config (HikariConfig.)
max-size (::max-size cfg)
min-size (or (::min-size cfg) max-size)]
(doto config
(.setJdbcUrl (str "jdbc:" uri))
(.setPoolName (d/name (::name cfg)))
@@ -121,8 +125,8 @@
(.setValidationTimeout (::validation-timeout cfg))
(.setIdleTimeout (::idle-timeout cfg))
(.setMaxLifetime (::max-lifetime cfg))
(.setMinimumIdle (::min-size cfg))
(.setMaximumPoolSize (::max-size cfg))
(.setMinimumIdle min-size)
(.setMaximumPoolSize max-size)
(.setConnectionInitSql initsql)
(.setInitializationFailTimeout -1))
@@ -180,6 +184,20 @@
:code :invalid-connection
:hint "invalid connection provided")))
(defn pool-stats
"Given a HikariDataSource instance, returns a map with current pool
statistics: active/idle connections, threads awaiting connection,
total connections, maximum pool size, and minimum idle connections."
[^HikariDataSource pool]
(let [^HikariPoolMXBean pool-mxbean (.getHikariPoolMXBean pool)
^HikariConfigMXBean cfg-mxbean (.getHikariConfigMXBean pool)]
{:active-connections (.getActiveConnections pool-mxbean)
:idle-connections (.getIdleConnections pool-mxbean)
:threads-awaiting-connection (.getThreadsAwaitingConnection pool-mxbean)
:total-connections (.getTotalConnections pool-mxbean)
:maximum-pool-size (.getMaximumPoolSize cfg-mxbean)
:minimum-idle (.getMinimumIdle cfg-mxbean)}))
(defn create-pool
[cfg]
(let [dsc (create-datasource-config cfg)]
Loaded 100 of 1772 files, more files were not shown because too many files have changed in this diff. Show more