Persist binfile manifest metadata in file_data on import so file
statistics are available at open-workspace time. Emit a new
open-workspace-file audit event enriched with file statistics:
page count, shape count, component count, linked libraries,
design tokens, and whether the file is a shared library.
Closes#11106
AI-assisted-by: mimo-v2.5-pro
Pan/zoom via render_from_cache while the tile atlas is still empty
left a blank workspace under the page-transition blur. Ignore
set-view-box / view-interaction-start until tiles-complete, block
pointer events on the viewport SVG, and flush any deferred local
viewport sync when the overlay ends.
Rename skill directory and update all references to follow the
same naming pattern as plan-review.
Simplify review.md command from 138 to 25 lines — remove redundant
content that duplicated what the skills already define. The command
now acts as a thin router; the skills own the methodology.
AI-assisted-by: mimo-v2.5-pro
Logout only cleared the auth-token cookie but never deleted the
server-side row because delete-fn read ::id which wrap-authz no longer
sets since 363b4e3778. Make delete-fn delete via ::session/:id attached
by wrap-authz so replayed tokens are rejected (CWE-613, GHSA-mj9f-5cwq-7p3q).
Add regression tests covering invalidation, idempotency and isolation
of other sessions. Fix verified with Red→Green TDD and full backend
suite (677 tests).
Closes#11316
AI-assisted-by: muse-spark-1.2-contributor
* 🚑 Remove internal error details from HTTP error responses
PostgreSQL exceptions, I/O exceptions, and unhandled errors were
leaking raw database messages (table names, constraint names,
SQLSTATE codes), filesystem paths, and internal exception details
to API clients via :hint, :state, and :path response fields.
Remove these fields from server-error responses while keeping
full error context in server-side logs for operators.
Closes#11287
AI-assisted-by: mimo-v2.5-pro
* 🚑 Strip internal fields and map PG errors to safe messages
Complete the security fix for GHSA-r8wx-23q6-w3gf by addressing
the incomplete redaction found in code review.
Add strip-internal-fields helper to dissoc :hint, :state, :path,
and :context from error response data in three handlers that
previously passed raw ex-data through to clients:
- handle-error :internal
- handle-exception :default (else branch)
- handle-error :assertion (else branch)
Add pgsql-state->message to map PostgreSQL SQLSTATE codes to safe,
client-facing messages (e.g. 23505 → "A conflicting entry already
exists") instead of returning raw PG error text. Include :message
in all PSQLException response branches.
Add regression tests asserting :hint, :state, :path, :context are
absent from responses for :internal and unhandled ex-info errors.
Closes#11287
AI-assisted-by: mimo-v2.5-pro
* 🚑 Keep :hint in error protocol, fix unsafe sources
Refine the security fix based on code review feedback.
Keep :hint as part of the error protocol — it is essential for
controlled error communication. Remove it from strip-internal-fields
(which now only strips :state, :path, :context).
Fix the actual sources of unsafe :hint values:
- http/middleware.clj: replace (ex-message cause) with safe static
strings for IllegalArgumentException, RequestTooBigException, and
EOFException. These :validation errors return ex-data verbatim
to clients, so raw exception messages were leaking internals.
- PSQLException handler: use :hint instead of :message for the
SQLSTATE-mapped messages, staying consistent with the error
protocol.
Update tests to assert :hint is present (with safe static values)
in :internal and unhandled ex-info responses, and absent only from
bare RuntimeException and IOException responses.
Closes#11287
AI-assisted-by: mimo-v2.5-pro
During view gestures, fast mode renders tiles without shadows or
blur. Writing those tiles into the doc/tile atlas left shadowless
patches when render_from_cache overlayed them on the scaled
preview. Keep the last HQ atlas tiles until the post-gesture
full-quality render completes.
HQ tiles are 512px and the atlas stays at 4096² (64 full-size
slots). Browser zoom plus a forced ?dpr= can need more visible
tiles than that, and a framebuffer larger than the GPU allows.
Pack interest tiles into smaller atlas cells, blit at 512 then
scale, and inset Linear samples so seams do not bleed. Clamp the
canvas backing store and DPR together, wrap Skia at the real
drawingBuffer size, and wait one frame after DPR changes so CSS
client size and overlays stay aligned.
Add a direct container-geometry path for eligible frames: inline blur
when the kernel fits the tile margin, otherwise a cached filter-surface
pass reused across tiles via DropShadowFilterCache on both the direct
and slow render_shape paths.
Move frame shadow logic into shadows.rs. Fix nested/clipped frame
shadows by deferring parent clip to composite time, apply negative
spread via inset, and allow rotated/transformed frames on the direct
path. Skip descendant extrect walks for clipped frames when only
nested drop shadows matter, and skip child silhouettes when the
container fill already covers shadow descendants.
* 🐛 Add ownership check to share-link deletion
The delete-share-link RPC command only verified file-level edit
permission but did not check if the caller owned the share-link.
This allowed any file editor to delete share-links created by
other users, disrupting collaborative workflows.
The fix adds an ownership check that allows deletion only by:
- The share-link creator (owner-id matches profile-id)
- File admins (is-admin permission)
- File owners (is-owner permission)
Implemented using TDD:
- RED: Test demonstrates IDOR vulnerability (editor can delete)
- GREEN: Ownership check prevents unauthorized deletion
- All existing tests continue to pass
Closes#11289
AI-assisted-by: qwen3.7-plus
* 🐛 Add test coverage for share-link deletion escape hatches
Address code review feedback for PR #11290:
- Add test for editor deleting their own share-link
- Add test for admin deleting editor's share-link
- Add test for owner deleting editor's share-link
- Remove redundant :is-owner check (already included in :is-admin)
- Add clarifying comment about :is-admin including :is-owner
Closes#11289
AI-assisted-by: qwen3.7-plus
The notification pill component now properly respects the `is-html`
flag when rendering the detail section, matching the behavior of the
children section. Token import error messages now escape HTML
characters in user-provided values like token names and type names
before displaying them in notifications.
AI-assisted-by: qwen3.7-plus
* 🐛 Use gradient type instead of export type in SVG renderer
data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.
Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])
Closes#5972
* 🐛 Add SVG gradient export regression test
Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.
AI-assisted-by: gpt-5.6-luna
* ✨ Standardize exporter testing workflow
Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.
AI-assisted-by: gpt-5.6-luna
* ✨ Add focused exporter test execution
Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.
AI-assisted-by: gpt-5.6-luna
* 🐛 Replace shell exec with execFile in exporter
Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.
This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.
Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission
All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)
AI-assisted-by: qwen3.7-plus
* 🐛 Use existing hex-color-string? and fix test path mismatch
Address code review feedback:
- Replace duplicated hex-color-rx and valid-hex-color? with existing
hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned
AI-assisted-by: qwen3.7-plus
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
* 🐛 Use gradient type instead of export type in SVG renderer
data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.
Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])
Closes#5972
* 🐛 Add SVG gradient export regression test
Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.
AI-assisted-by: gpt-5.6-luna
* ✨ Standardize exporter testing workflow
Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.
AI-assisted-by: gpt-5.6-luna
* ✨ Add focused exporter test execution
Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.
AI-assisted-by: gpt-5.6-luna
* 🐛 Replace shell exec with execFile in exporter
Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.
This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.
Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission
All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)
AI-assisted-by: qwen3.7-plus
* 🐛 Use existing hex-color-string? and fix test path mismatch
Address code review feedback:
- Replace duplicated hex-color-rx and valid-hex-color? with existing
hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned
AI-assisted-by: qwen3.7-plus
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
* 🐛 Use gradient type instead of export type in SVG renderer
data->gradient-def was comparing the render `type` parameter (:svg,
:png, :pdf) against "linear" to decide between linearGradient and
radialGradient elements. Since the export type is never "linear",
the comparison always fell through to radialGradient, causing all
linear gradients to be exported as radial in SVG output.
Read the gradient type from the data map instead:
(get-in data ["gradient" "type"])
Closes#5972
* 🐛 Add SVG gradient export regression test
Extract SVG gradient definition generation from the renderer so it can
be tested directly. Add exporter test build wiring and cover both
linear and radial gradient output.
AI-assisted-by: gpt-5.6-luna
* ✨ Standardize exporter testing workflow
Align exporter scripts with the frontend testing pattern. Add a
dedicated GitHub Actions workflow and document the canonical exporter
commands in Serena memories.
AI-assisted-by: gpt-5.6-luna
* ✨ Add focused exporter test execution
Mirror frontend test-runner behavior for focused namespaces and test
vars. Support --focus, --log-level, and --help, and document the
commands.
AI-assisted-by: gpt-5.6-luna
* 🐛 Replace shell exec with execFile in exporter
Replace child_process.exec with execFile to eliminate shell
interpretation. Add hex color validation in exporter and frontend
to reject malformed input before command construction.
This fixes GHSA-4f36-m4hj-cv86 (CVSS 9.9 Critical), an authenticated
OS command injection vulnerability where malicious fill-color values
could execute arbitrary commands in the exporter container.
Defense in depth:
- Layer 1: execFile passes arguments directly without shell parsing
- Layer 2: Exporter validates colors with strict hex regex
- Layer 3: Frontend filters invalid colors before DOM emission
All three independent reporters' attack vectors are addressed:
- Quote breakout (lyhtheori)
- Command substitution (B1gN0Se)
- Path traversal (KimiSecurityTeam)
AI-assisted-by: qwen3.7-plus
* 🐛 Use existing hex-color-string? and fix test path mismatch
Address code review feedback:
- Replace duplicated hex-color-rx and valid-hex-color? with existing
hex-color-string? from app.common.types.color
- Fix RCE test to use marker path in payload instead of hardcoded /tmp/pwned
AI-assisted-by: qwen3.7-plus
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
* ⚡ Memoize shape-attr->token-attrs and hoist per-type attrs in get-attrs*
* ⚡ Skip redundant token merges for token-less shapes in get-attrs*
* ⚡ Freeze group descendant attrs in design panel during transforms
Add escape-markdown to common/data.cljc that escapes Markdown
special characters (*, _, ~, `, [, ], >, #, @, etc.) by prefixing
them with backslash. Apply it to user-controlled fields (:hint,
:href) in the Mattermost error reporter before constructing the
notification message.
This is an internal-only feature not accessible to end users.
AI-assisted-by: mimo-v2.5-pro
Restrict version parameter to supported values (1 or 3) via schema
validation instead of accepting any integer. Add content-based format
detection when version is not provided, using bfc/parse-file-format
to inspect file magic bytes.
Closes#11105
AI-assisted-by: qwen3.7-plus
The create-upload-session RPC method accepted total-chunks values of 0
or negative numbers without validation, creating inconsistent session
state. Add {:min 1} constraint to the schema to reject invalid values
at input validation.
Closes#11103
AI-assisted-by: qwen3.7-plus
The clone-file-media-object RPC command only checked edit permissions
on the destination file. The source media object was fetched directly
by UUID without verifying the caller had access to the file that owns
it.
This fix adds a read permission check on the source file before
cloning. If the caller lacks read access to the source file, the
operation fails with :not-found to avoid leaking information about
the existence of files/media the caller cannot access.
Closes#11087
AI-assisted-by: qwen3.7-plus
Prevent BOLA in chunked upload assembly by verifying session
ownership. The assemble-chunks function now requires a profile-id
parameter and scopes the upload_session lookup accordingly, matching
the pattern already used by upload-chunk.
All three callers (assemble-file-media-object, create-font-variant,
import-binfile) updated to pass the authenticated profile-id.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Fix comment bubbles rendering above workspace dropdowns (#10283)
Comment bubbles (workspace-comments-container) had z-index: 1000, which placed
them above dropdown menus (--z-index-dropdown: 400). Replace the hardcoded 1000
with $z-index-300 from the design-system z-index scale so comments sit above the
canvas/guides but below menus and dropdowns.
* Refactor workspace comments container styles
Modernize CSS properties for workspace comments container.
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
---------
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
29dbf9ab1 marks non public buckets as attachments, which works on the fs
backend because nginx applies those headers to the internally redirected
response. On the s3 backend the handler answers 307 and the client then
fetches the bytes from the object store, so the header set on the redirect
does not reach the response that carries the object.
Sign the disposition into the presigned url as well, so the object store
returns it. It is only signed when the bucket is not public, so urls for
inline served objects are unchanged.
Also cover the disposition in the handler tests, for the non public buckets
and for the public ones that stay inline.
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Omit nil optional profile fields before frontend schema validation and RPC persistence. Preserve omitted language and theme values in backend updates, and add regression coverage for partial profile saves.
AI-assisted-by: gpt-5.6-luna
When copying an access token over plain HTTP (non-secure context), the
browser does not expose navigator.clipboard, causing to-clipboard to
return a rejected Promise. The caller was ignoring the Promise entirely,
so the rejection became an unhandled exception that crashed the UI.
Fix: chain .then/.catch on the returned Promise so that a successful
copy shows the existing success toast and a failure (including
insecure-origin) shows an error toast using the existing
errors.clipboard-api-unavailable translation key.
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The grid-item-metadata* component always used :will-be-deleted-at (falling
back to :modified-at) and always showed the "Will be deleted %s" tooltip,
even for files in the Recent tab that have no deletion date.
Now the component branches on the presence of :will-be-deleted-at:
- Deleted files: show the deletion timeago with the existing
"Will be deleted %s" tooltip.
- Regular files: show :modified-at timeago with a new
"Last modified %s" tooltip key (dashboard.grid.last-modified-at).
Closes#10873
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The grid-item-metadata* component always used :will-be-deleted-at (falling
back to :modified-at) and always showed the "Will be deleted %s" tooltip,
even for files in the Recent tab that have no deletion date.
Now the component branches on the presence of :will-be-deleted-at:
- Deleted files: show the deletion timeago with the existing
"Will be deleted %s" tooltip.
- Regular files: show :modified-at timeago with a new
"Last modified %s" tooltip key (dashboard.grid.last-modified-at).
Closes#10873
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
On self hosted installs /js/config.js is regenerated from PENPOT_FLAGS
on every container start, but nginx served it with the same
`public, max-age=604800` used for build assets, and index.html versions
it only by the build. A flags only change therefore leaves the URL
untouched, so a browser that had already loaded the app kept using its
cached copy for up to a week: enabling a flag such as
enable-login-with-google had no visible effect for returning users
until the cache expired or they cleared their site data.
Serve that one file with the same no-store headers already used for
index.html, which is the other file whose contents change without its
URL changing. Every other static asset keeps the long lived cache.
Fixes#10556.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Corrected a typo in the configuration documentation regarding the auto-file-snapshot timeout setting.
Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
Round bucket reset intervals up to whole milliseconds before adding them to an instant. This prevents Clojure ratios from reaching duration conversion and disabling rate limiting for the request.
Add a regression test for a refill rate that produces fractional milliseconds.
Closes#11253
AI-assisted-by: gpt-5.6-luna
Pasting text could throw "Unknown node type" and lose the paste. The
insertion paths assume the caret sits on a text node or a <br>, but the
browser can report it on a container element (the offset being a child
index, common in Firefox) or, for an empty text shape that was just
focused, on nothing at all: selectAll() returned early without ever
setting a selection.
Add resolveTextNodePosition(), which walks a (node, offset) pair down to
the addressed text node or line break and returns null instead of
throwing when it cannot. The selection controller normalizes the caret
with it before inserting text or a pasted fragment, and selectAll() now
collapses on the line break of an empty editor so the caret is always
usable.
Closes#11149
AI-assisted-by: longcat-2.0-free
Add media type validation to upload-tempfile and upload-org-logo
management endpoints. Both stored user-supplied mtype without
checking against an allowlist. Only image types and PDF are
permitted. Non-public bucket assets now also carry
Content-Disposition: attachment to prevent inline rendering.
AI-assisted-by: mimo-v2.5-pro
Make sd-token-uuid nil-safe when accessing .original.id to prevent
crashes when StyleDictionary emits group nodes alongside real tokens.
Group nodes have an original object but no id property, causing
undefined is not an object errors during interactive token resolution
in the edit modal.
Closes#11143
AI-assisted-by: qwen3.7-plus
The sidebar measures panel numeric inputs (X, Y, width, height,
rotation) emitted one full apply-modifiers commit per DOM event with
no throttle: every arrow key-repeat, wheel tick and scrub pointermove
became update-positions / update-dimensions / increase-rotation. A
sustained gesture starved the React renderer and crashed the
workspace with error #185 (Maximum update depth exceeded).
Coalesce those bursts at the data layer (potok), following the
update-position-data debounce pattern in texts.cljs:
- update-positions is now burst-coalesced in place (its only caller
is the measures panel); new update-dimensions-coalesced and
increase-rotation-coalesced variants are used by the measures
panel, while the immediate events keep serving plugins, variants
and token application (including the delta? rotation path).
- The first event of a burst commits immediately (leading edge, so
single edits stay synchronous); further ticks commit at most once
per 50 ms (throttle); a trailing debounced flush guarantees the
exact final value lands. All payloads are absolute values, so
keeping the latest queued value per shape/attribute is lossless.
- Pending payloads are drained atomically and stale shape ids
(deleted mid-burst) are skipped. The drain stream lives until the
workspace is finalized, so bursts reuse a single subscription.
- Fewer commits per burst also means fewer undo entries; scrub drags
still produce a single entry via the input's outer transaction.
Tests: new frontend-tests.logic.sidebar-transform-coalescing-test (8
tests, legacy SVG and WASM renderer branches) guards the invariant
that a 20-event burst commits the exact final value in a handful of
commits. The previously unregistered update-position-test is wired
into the runner with WASM mock fixtures (it fails in full-suite
context without them due to a pre-existing global mock-state issue).
AI-assisted-by: kimi-k3
Add role-ceiling check to create-team-invitations and
update-team-invitation-role methods. These RPC methods allowed
team admins to grant or elevate invitations to :owner role,
bypassing the protection that exists in update-team-member-role.
The fix replicates the existing check from update-team-member-role:
reject promotion to :owner when the caller is not an owner.
Closes#11098
AI-assisted-by: qwen3.7-plus
The validate-url-allows-public-{https,http} tests relied on real DNS
resolution of example.com, which fails in containers without public
DNS access. Mock resolve-host to return a known public IP, consistent
with the pattern used by other tests in the same file.
AI-assisted-by: mimo-v2.5-pro
Apply climit with 4 global permits and 1 per-profile permit (queue 2)
to prevent connection pool exhaustion from concurrent imports. Each
import holds a DB connection for its entire duration with idle
transaction timeout disabled, so unbounded concurrency could exhaust
the pool (default 60 connections).
AI-assisted-by: mimo-v2.5-pro
The MCP workflow was named "MCP CI" while every other tests-*.yml
workflow uses the "CI: <Component>" pattern. Rename it to "CI: MCP"
for consistency in the GitHub Actions listing.
The MCP workflow was named "MCP CI" while every other tests-*.yml
workflow uses the "CI: <Component>" pattern. Rename it to "CI: MCP"
for consistency in the GitHub Actions listing.
Avoid an offscreen buffer per Fill::Image during tile walks: only use
save_layer when a shape image filter is present; axis-aligned rects and
frames without corner radii also skip the redundant container clip.
The global `proxy_set_header Host $http_host;` forwarded the client-facing
Host to internal proxy_pass calls (backend/exporter), breaking mTLS routing
in service-mesh setups (e.g. Istio STRICT mode), which match outbound
requests to a cluster based on Host/:authority.
Explicitly set `Host $proxy_host` on /api, /assets, /api/export, /readyz
and /ws/notifications so these calls always target the correct internal
service host, independent of the client's original Host header.
Fixes#10835
Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
Co-authored-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
* 🐛 Fix editor v3 quitting when changing typography options
* 🎉 Apply text styles to collapsed caret
* 🐛 Fix not persisting the new selrect
* 🐛 Fix selrect not being recomputed on caret style changes
* 🐛 Fix quitting the editor when changing typography on empty texts
Split the integration suite into four shards running two Playwright
workers each. Median wall time for the job drops from ~40 min to an
expected ~15 min; the build job is unchanged at ~4 min.
Shard reports are merged into a single HTML report, and the merged
run is summarised in the job step summary: totals, failed specs and
flaky specs ranked by retry count.
Chromium is installed into a shared volume so shards do not
re-download it. `workflow_dispatch` allows running the suite manually
against an arbitrary ref, with configurable shard layout and workers.
PRs targeting `staging` keep running serially while the current
release stabilizes. The exception is marked TEMPORARY and removed in
a follow-up.
Enable Playwright's JSON reporter alongside `list` and publish a
summary of flaky tests to the job step summary. The JSON report is
kept as an artifact for 30 days so flakiness rates can be aggregated
over time.
CI already runs with `retries: 2`, so unstable tests have been passing
silently on retry. This only surfaces what the suite already absorbs;
no test behaviour changes.
The reporter in `frontend/scripts/test-e2e` becomes overridable via
`PLAYWRIGHT_REPORTER` so the local developer default stays untouched.
Enable Playwright's JSON reporter alongside `list` and publish a
summary of flaky tests to the job step summary. The JSON report is
kept as an artifact for 30 days so flakiness rates can be aggregated
over time.
CI already runs with `retries: 2`, so unstable tests have been passing
silently on retry. This only surfaces what the suite already absorbs;
no test behaviour changes.
The reporter in `frontend/scripts/test-e2e` becomes overridable via
`PLAYWRIGHT_REPORTER` so the local developer default stays untouched.
The comments layer lives in the viewport overlays, which are absolutely
positioned above the canvas, and the container itself carries a high
z-index. A comment bubble panned into the ruler bars therefore painted
on top of them, covering the ticks and numbers.
Clip the comments container to the area outside the ruler bars while
the rulers are visible, the same thing the `clip-handlers` clip path
already does so the selection handlers stay off the rulers. Clipping
only the comments container leaves the text editing overlay, which
shares the viewport overlays, untouched.
Fixes#11163.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The generated /etc/nginx/overrides/server.d/mcp-locations.conf used a
plain proxy_pass target (e.g. `proxy_pass http://penpot-mcp:4402;`)
where $PENPOT_MCP_URI/$PENPOT_MCP_URI_WS are shell variables substituted
once by envsubst in nginx-entrypoint.sh at container startup, not nginx
variables. nginx resolves a literal proxy_pass hostname once when the
config loads and never re-checks it, so the existing
`resolver 127.0.0.11 valid=10s;` directive in
overrides/http.d/resolvers.conf has no effect on these three locations
- it only applies to nginx variables evaluated per-request.
In multi-container deployments where the penpot-mcp container restarts
or is recreated independently of penpot-frontend (image update, OOM,
orchestrator reschedule), it gets a new IP from Docker's/the
orchestrator's DNS, and the frontend's nginx keeps forwarding to the
old, now-dead address until penpot-frontend itself is restarted. This
surfaces to users as `wss://<host>/mcp/ws` failing to connect from the
browser after enabling the MCP plugin, with
`connect() failed (111: Connection refused)` in the frontend's nginx
logs.
Route each location through a `set $var ...; proxy_pass $var;` pair so
proxy_pass evaluates a real nginx variable, letting the pre-existing
resolver directive re-resolve penpot-mcp within its 10s TTL instead of
caching the address for the container's lifetime.
For /mcp/stream and /mcp/sse, the set value also appends
$is_args$args explicitly: when proxy_pass targets a variable AND that
variable's value includes a URI/path component, nginx does not
automatically forward the original request's query string the way it
does for a static proxy_pass target - it must be appended by hand, or
the userToken query parameter used for multi-user authentication is
silently dropped before reaching the MCP server. /mcp/ws has no path
component in its target so it isn't affected by this and needed no
such change.
Verified locally: force-recreated the penpot-mcp container onto a
different IP while leaving penpot-frontend untouched; the /mcp/ws
WebSocket upgrade kept returning 101 Switching Protocols throughout,
both immediately and after the resolver's TTL window. Separately
verified /mcp/stream: a POST with ?userToken=... now shows up
server-side as userTokenFp=<redacted first 8 chars> instead of <none>,
and an actual MCP client (Claude Code) using this proxy can now call
authenticated tools like execute_code successfully.
Signed-off-by: Jules LaPrairie <jules@lucidbox.ca>
The design sidebar named the same "mixed values" concept with two
different translation keys. Most sections use settings.multiple, while
the blur options and the design system numeric input used
labels.mixed-values.
Both read "Mixed" in English, so the split is invisible in the default
locale, but labels.mixed-values has no translation at all in 16 locales
and a different wording in 8 more. Where it is missing the string falls
back to the default language, so those controls rendered the English
word next to sections showing the localized one; where both exist, a
single sidebar named the same concept two ways (fr "Divers" against
"Melange", ru "Smeshanyy" against "Smeshat").
Point the two outliers at settings.multiple, the key the rest of the
sidebar already uses and the one translated in every locale that ships
a translation for it.
Fixes#11148.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Add `project-id` to the guard condition in `use-plugin-register`'s layout effect so the plugin "Try out" flow waits until projects have loaded.
Previously, only `plugin-url` was checked, which allowed the fetch to fire
before projects were available, sending a nil `project-id` and causing a 400
validation error from the backend.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Declare the shape attributes stored files carry
`schema:shape-attrs` is the shape model as *declared*, and it has fallen
behind the `Shape` record. Three record fields are absent from it:
`rotation`, `flip-x` and `flip-y` are therefore present on every shape
that exists and declared nowhere. `rotation` is already named twice in
this namespace, in `allowed-shape-attrs`, and once in
`app.common.types.shape.attrs/editable-attrs`, so the schema is
demonstrably the odd one out rather than the data being unusual.
Nothing complains, because the maps are open: an undeclared key
validates fine. What breaks is everything that reads the model *from the
schema* rather than from a live value, such as the generative tests'
shape generator, the generated OpenAPI surface, and any consumer
reflecting over `schema:shape-attrs`.
Whether an entry is optional, nilable, or both is decided by the record
rather than by taste. `app.common.record/defrecord` cannot remove a base
field: its `without` assocs nil and its `containsKey` answers true
whatever the field holds, on both platforms. So a `Shape` base field is
always present, and nil is how that field says "unset". Every other key
lives in the `$extmap`, disappears on dissoc, and is dropped by
`setup-shape` when a caller passes nil. Base fields are therefore
nilable, and the rest are optional.
Declared here, measured over a 305-shape corpus:
- `rotation`, `flip-x` and `flip-y`, record fields present on every
shape, nilable for the reason above: `make-minimal-shape` gives the
two flip fields no default, so they are nil on all 305. Optional as
well, unlike the geometry below, because `schema:shape-generic-attrs`
has a second job: `check-shape-generic-attrs` validates partial update
payloads with it, such as the `{:blocked true}` that
`app.main.data.workspace/update-shape` passes, and a required key here
would reject every such payload.
- `hide-in-viewer`, moved out of `schema:frame-attrs`, because circles,
rects and texts carry it too, 197 shapes.
- `svg-attrs`, `svg-defs`, `svg-transform` and `svg-viewbox`, the SVG
provenance an import leaves behind, 101 shapes and 63 for the
transform. Typed `:map` rather than more precisely on purpose: legacy
files hold `svg-transform` as a plain `{:a … :f}` map rather than a
`::gmt/matrix` record, and `svg-viewbox` as either a `::grc/rect`
record or a plain map, so a tighter schema would reject files that are
otherwise valid.
- `use-for-thumbnail` on frames. The model has long had it:
`app.common.files.migrations` renames `:use-for-thumbnail?` to it and
`app.common.logic.libraries` reads it. This schema had not declared
it.
- `rx` and `ry` on rects and circles, the legacy radii SVG import parses
off the element and migration 0003 assocs as `0`. Superseded by `r1`
to `r4`, but stored files carry them.
- `content` on svg-raw. `shapes-builder/create-raw-svg` sets it and
`allowed-svg-attrs` names it. Typed `[:or :map :string]`, because a
bare text node arrives as the string itself: `<text>hi</text>` becomes
one svg-raw for the element and another for `"hi"`, and
`shapes-builder/parse-svg-element` carries a FIXME about exactly that.
`schema:nilable-geom-attrs` is new, for bool and path. Those two are the
only shape types whose geometry can be nil: `make-minimal-shape` gives
`x`, `y`, `width` and `height` a default for every other type and skips
those two, whose extent their content and `selrect` imply instead. The
four keys stay required, as they already are in the other seven
branches, and only the nil is new.
**Do not make the analogous change to `ctf/schema:file`.** That map
carries `:backend`, `:comment-thread-seqn` and `:ignore-sync-until`,
none of which the schema declares, and declaring them breaks saving:
`app.binfile.common/update-file!` derives its UPDATE column list from a
file map's keys, and the `file` table has no `backend` column, it being
synthesized on read. Measured at 185 failures, mostly `rpc-file-test`.
Whether a schema serving as both read description and write contract is
itself a defect is a real design question, and a separate one. The
`check-shape-generic-attrs` case above is a second instance of it.
Adding entries changes what `shape-generator` produces, so generative
tests begin exercising code paths with these attributes present. That is
where a problem would surface. With this applied the common suite is
1142 tests and 24702 assertions on the Clojure side, 992 tests and 24017
assertions on the ClojureScript side, no failures on either.
AI-assisted-by: mixed models
* ✨ Align shape generator with declared schema and add key-presence test
shape-generator now selects geometry attrs per-type: nilable-geom-attrs
for bool/path, shape-geom-attrs for everything else, and always merges
them. This removes the dead attrs2 generation for bool/path and the
implicit dependency on create-shape adding nil defaults for missing
base record fields.
The new shape-generator-key-presence test asserts that generated shapes
carry the required keys: rotation, flip-x, flip-y on all shapes and x,
y, width, height on bool/path, even when nilable.
AI-assisted-by: longcat-2.0-free
* 🐛 Sample 200 shapes in the key-presence test, not 10
`sg/sample` hands its options to `malli.generator/sample`, which reads
`:size`. `:num` is test.check's option. It is correct for the
`smt/check!` call directly above, where it came from, but `sg/sample`
ignores it and falls back to its default of 10.
Ten samples leave the bool and path assertions vacuous about one run in
fourteen. Simulated over 200 draws of 10, 14 contained no bool and no
path at all, and the median draw held 2. Those four assertions defend
exactly the keys this branch made required, so a run that skips them
silently is the one case worth not missing.
The assertion count shows the arithmetic. The test contributed 42 with
`:num`, which is 10 shapes times 3 keys plus 3 bool-or-path shapes times
4 keys, and contributes 756 with `:size`. The common suite goes from
1143 tests and 24744 assertions to 1143 tests and 25458 assertions, no
failures either way.
AI-assisted-by: mixed models
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
When a profile is deleted, only the current session was being
invalidated. Other active sessions on different devices remained
functional until the background cleanup task completed.
Add session/invalidate-all helper that deletes all sessions for
a profile by profile_id, and call it from delete-profile before
the response transform. This ensures immediate access revocation
across all devices when an account is deleted.
Closes#11114
AI-assisted-by: qwen3.7-plus
The fetch-manifest function previously had no timeout, causing the
plugin installation flow to hang indefinitely if the server accepted
the connection but never completed the response.
Added a 15-second timeout using rx/timeout to abort the request
automatically.
Closes#11119
AI-assisted-by: qwen3.7-plus
Replace the placeholder rlimit.edn with a real per-endpoint
configuration covering auth, SSRF, search, email, media and project
operations. The previous file only had a commented-out example, so
all limits fell back to the 200k/h default window.
Also propagate the evaluated `now` timestamp into both bucket and
window result maps, so consumers (e.g. soft-mode reports) can know
exactly when the limit was checked.
AI-assisted-by: minimax-m3
Share link IDs function as capability secrets — anyone possessing
the ID can read a file without authentication. The previous UUIDv8
scheme is predictable (56 bits fixed per process + 48-bit timestamp).
Changed to uuid/random (UUIDv4) for genuine unpredictability.
Closes#11116
AI-assisted-by: qwen3.7-plus
Replace standard '=' operator with MessageDigest/isEqual to prevent
timing attacks on shared key authentication middleware.
Closes#11121
AI-assisted-by: qwen3.7-plus
* 🐛 Add permission checks to WebSocket subscription handlers
Check file and team read permissions before allowing WebSocket
subscriptions to prevent resource enumeration via presence
notifications.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Fix random backend test failure
Release packs far more cheap Current draws (e.g. fills_none paths)
into one Partial than debug; a single end-of-Partial
flush_and_submit then stalls the browser. Soft-flush every N walker
nodes (and on Partial yield) keeps ops buffers bounded while Full
still submits via present_frame.
`create-font-variant-rejects-foreign-font-id` sends `:data`, which
`schema:create-font-variant` no longer accepts: the same commit that
added the test documents that param as removed in 2.18 in favour of
`:uploads`. Both of the test's requests are therefore rejected by params
validation before they reach `check-font-team-ownership!`, which is the
thing the test exists to check. It asserted nothing about ownership and
failed three assertions.
Upload the font through `upload-font-chunked!`, the helper the other
tests in this namespace already use, and pass the session id in
`:uploads`.
`backend-tests.rpc-font-test` is 16 tests, 172 assertions, 0 failures
with this applied.
AI-assisted-by: mixed models
`create-font-variant` destructures `uploads` and never reads it: the
handler passes the whole `params` map to `prepare-font-data-from-uploads`.
`clj-kondo` reports it as an unused binding and exits 2, which fails the
Lint step of the Backend workflow, and the Lint step runs before the
tests, so no branch based on `develop` can run the backend suite at all.
AI-assisted-by: mixed models
The info-service-uri-not-configured test used config-get-mock with an
empty map, which falls back to cf/config for missing keys. In a REPL
with real config, media-processing-service-uri is set, causing the code
to attempt an HTTP call instead of raising the expected error.
Use (constantly nil) to ensure cf/get always returns nil, matching the
test intent of simulating an unconfigured service URI.
AI-assisted-by: mimo-v2.5
Replace the inline organization map in schema:create-organization-invitation with cto/schema:organization-with-avatar, eliminating schema duplication and fixing mismatched validation rules for :logo and :sso-active fields.
AI-assisted-by: mimo-v2.5
Prevent cross-team font injection by checking that when a font-id
already has variants, they belong to the same team. This closes a
BOLA gap where a user with team edit permissions could create a
font variant referencing a font-id from another team.
AI-assisted-by: mimo-v2.5-pro
* ✨ Add headless wasm render backend to the exporter
* ♻️ Move render-wasm bridge to common and split wasm builds
* 🔧 Upload builtin font variants in the wasm exporter
* ♻️ Move shared font and resources utils out of render_wasm
* ⚡ Fetch only the exported roots in the wasm exporter
* ⚡ Bound save_layer rects in the vector export path
* ⚡ Skip drop shadows that are imperceptible at current scale
Filter drop shadows by on-screen footprint (stricter for recursive
shapes) so overview HQ avoids expensive blur passes that barely show.
* ⚡ Simplify Path and Bool strokes at low scale
At overview zooms, Inner/Outer strokes fall back to Center and
dash/dotted styles become solid when the pattern is subpixel.
Strokes are never skipped so stroke-only icons stay visible.
* ⚡ Drain GPU work on partial render frames
Partial frames only flushed the Backbuffer, so tile GPU commands
queued until present_frame's flush_and_submit and stalled the
browser on large files. Submit the context each partial frame
without presenting Target or re-composing the tile atlas.
* ⚡ Prefer direct painting when effects are imperceptible
Skip the Fills/Strokes layered path when drop/inner shadows would
not paint at the current scale, and allow stroke-only shapes
(fills_none) on the direct path. Apply the same footprint LOD to
inner-shadow painting.
The section pointed at `docker/devenv/docker-compose.yaml`, which #9906
deleted when it split the devenv compose into `docker-compose.infra.yml`
and `docker-compose.main.yml`. The same page names both replacements in
its architecture section, so only this one was missed.
Setting PENPOT_FLAGS in the container environment would not have worked
anyway: `backend/scripts/_env` expands the inherited value before its own
list, so its flags win. Document the mechanism that does work, the
gitignored `backend/scripts/_env.local` that `start-dev` sources right
after `_env`, and the left-to-right last-wins rule that lets an override
switch off a flag `_env` enables.
* ♻️ Extract apply_clip_stack_to_surfaces helper
Share the layered-path clip loop so the Current-surface direct
path can reuse the same hard-clip stack without duplication.
* ⚡ Expand direct shape painting onto Current
Allow clip stacks, frames, non-identity transforms, and SrcOver
opacity on the Current-surface fast path; skip empty non-masked
groups. Avoids Fills/Strokes blits for common shapes.
* ⚡ Skip empty drop-shadow blits; warm DropShadows once
Early-out drop-shadow composite when a shape has no visible
shadows, and touch DropShadows→Current once per tile instead
of per shape to keep flush_and_submit cheap.
* 🐛 Add backend password validation with complexity rules and dictionary check
Enforce minimum 8-character password length, require at least 1 lowercase
letter, 1 uppercase letter, 1 digit, and 1 special character, and reject
common passwords using Passay library with a 10k-entry wordlist from
SecLists during registration and password change flows.
AI-assisted-by: mimo-v2.5-pro
* ✨ Improve user feedback
When the password is invalid, the user now gets extra indications to make it stronger, so it can be valid.
* 🐛 Fix remove unneeded common password check
The dictionary check is only relevant for passwords that meet all other requirements, but all 10,000 common passwords would fail the character requirements, so this check is not needed
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Marking intermediate surfaces dirty after clearing them on tile
context switch made the first stack composite blit empty
Fills/Strokes/shadows into Current. Dirty means content to
composite, so clear the flags after the clear instead.
Pass performance.now from finalize/debounce and re-anchor the WASM
budget if the stamp is 0 or already past max_blocking_time, so HQ
tiles are not yielded after a few nodes with almost no real work.
Remove :skip-ssrf-check? true from prepare-organization-sso-provider so
SSRF protection is active when validating organization SSO configs.
The endpoint is already protected by shared-key authentication
(admin-console), but enabling SSRF protection prevents potential misuse
of internal network resources if the shared key were ever compromised
(defense-in-depth).
Add test prepare-organization-sso-provider-does-not-skip-ssrf-check to
verify the SSRF check is not skipped.
AI-assisted-by: qwen3.7-plus
Add sanitize-svg function that removes dangerous elements and attributes:
- script tags
- foreignObject elements
- Event handler attributes (onload, onmouseover, etc.)
- javascript: URLs from href/xlink:href attributes
Apply sanitization in process-main-image before storing SVG files.
AI-assisted-by: mimo-v2.5-pro
Add normalize-string helper in app.common.data that trims whitespace
and returns empty string for nil input. Apply to profile, team, and
project string fields (fullname, lang, theme, name) before storage.
AI-assisted-by: qwen3.7-plus
Capture unique constraint violation in insert-file! and return
generic :not-found error instead of propagating raw PostgreSQL
exception, preventing file existence oracle.
AI-assisted-by: mimo-v2.5-pro
Add authorization check to generic-handler in assets.clj so that
/assets/by-file-media-id/:id and its /thumbnail variant verify the
requesting profile has read access to the parent file. Return 404
(not 403) when access is denied to avoid confirming existence.
Also switch get-file-media-object from db/get to db/get* so that
non-existent media objects return nil instead of raising.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Restrict webhook edit/delete to team members only
Remove the creator-id fallback from get-webhooks-permissions.
Previously, the webhook creator could always edit/delete their
webhook even after being removed from the team. Now can-edit
comes from team role only — removed users get :not-found.
Webhooks are NOT deleted on member removal; the team owns them
and team admins/owners manage them.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Restrict webhook creation to team editors
Use team role check (check-edition-permissions!) for create-webhook
instead of the custom check that allowed any team member to create
webhooks via creator-id self-match override.
AI-assisted-by: mimo-v2.5-pro
Bound read depth at 128 levels to prevent StackOverflowError from
crafted deeply-nested payloads. All recursive read handlers go
through read-object!, so a single depth check covers all paths.
AI-assisted-by: mimo-v2.5-pro
Prevent unbounded memory allocation when a crafted binfile specifies
an excessively large object size. Apply the same 100 MiB limit that
read-stream! already enforces.
AI-assisted-by: mimo-v2.5
Add check-library-team-ownership! helper that verifies both the file
and library share the same team before creating or modifying library
relations. This prevents cross-team library injection where a user
with edit permissions on files in different teams could link them
across team boundaries.
Applied to link-file-to-library, unlink-file-from-library, and
update-file-library-sync-status handlers.
AI-assisted-by: mimo-v2.5
Add :closed true to schema:import-binfile to reject unknown keys.
Remove file-id from handler destructuring, config binding, and audit
props to prevent specifying a target file on import.
AI-assisted-by: mimo-v2.5-pro
Prevent email bombing attacks on the send-user-feedback endpoint by
limiting the error-report field to 1MiB and adding climit rate limits:
by-profile (1 permit, queue 3) and global (4 permits), configured in
climit.edn. Make the schema public so it can be exercised by tests,
and add schema validation tests covering the new size limit.
AI-assisted-by: qwen3.7-plus
Copy Current into DocAtlas and the tile atlas with Surface::draw
instead of image_snapshot_with_bounds, matching the interactive
path and removing a GPU sync stall on every completed tile.
Add max-export-dimension constant (100000 units) and validate in
calculate-dimensions. Reject exports when bounding box width, height,
or position exceeds the limit to prevent resource exhaustion in the
Chromium export pool.
AI-assisted-by: mimo-v2.5-pro
Add end-to-end HTTP tests under backend/test/e2e/ using Node.js built-in
test runner (node:test) and native fetch. Tests run through the devenv
nginx proxy on port 3450.
Test suites (19 tests total):
- auth-flow: demo profile creation, login, session cookies, access tokens
- export-binfile: file creation, export to asset URL via SSE
- asset-download: download with cookie/token auth, 401 without auth,
S3 redirect behavior, full export-to-download flow
Key findings documented in tests:
- nginx @handle_redirect intercepts backend 307 and proxies to S3 directly,
stripping the client Authorization header (bug does not reproduce in devenv)
- SSE end event uses ~#uri tagged format for URLs
- Unauthenticated RPC returns uuid/zero profile (not null)
AI-assisted-by: mimo-v2.5-pro
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add media-processor service for image and font processing
Externalizes ImageMagick and FontForge subprocess invocations into a
separate Node.js HTTP service (media-processor/). Backend dispatches
via feature flag :use-remote-media-processing.
Key changes:
- media-processor module (TypeScript, Express 5, Sharp, FontForge/woff)
- POST /api/image/info, /api/image/thumbnail, /api/font/generate
- Resource limits: 128MP rejection, prlimit (512MB + 30s CPU)
- Streaming multipart via SequenceInputStream
- app.media split into validation (leaf), local (shell impls), remote (HTTP)
- Schema enforcement: :upload and :input schemas in validation namespace
- Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT)
- 78 tests across 4 files (image, font, middleware, config)
- FontForge path escaping for command injection prevention
- Parallel font variant conversions with Promise.all
AI-assisted-by: mimo-v2.5-pro
* 🐳 Revert docker-compose changes from media-processor commit
Remove docker-compose.yaml modifications that were part of the media-processor
service commit. The media-processor service definition, flags, and environment
variables are reverted to their previous state.
AI-assisted-by: qwen3.7-plus
* ⬆️ Update dependencies
* 🐛 Fix PR review issues in media-processor
- Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir
when input is a file path, ensuring output lands in expected location
- Error preservation: execCommand preserves killed/signal/code properties
from child process errors for OOM detection
- Content-Length: service-multipart-request calculates and includes
Content-Length header for streaming multipart requests
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor
- Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY
in devenv to match backend config key
- Fix timeout middleware to destroy request AFTER response finishes,
preventing truncated 504 responses
- Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85)
- Replace require('fs') with proper ES module import in upload-storage.ts
- Refactor font conversion temp-dir boilerplate into withTempInput helper
- Document FontForge escaping limitations (single quotes only)
- Fix misleading comment in image.ts about sharp metadata decoding
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 2)
- Fix queue middleware to skip next() when response already ended,
preventing orphaned work after timeout
- Fix hybrid storage to use disk when Content-Length is absent (chunked
transfer), preventing unbounded memory allocation
- Add source image format validation in generateThumbnail to reject
unsupported formats (TIFF, BMP, etc.) with 400 instead of 500
- Remove dead code in convertFont for unreachable woff→woff path
- Remove unused isEnabled() method from LokiLogTransport
- Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type
- Extract queue middleware to separate file for testability
- Add comprehensive tests for queue middleware and upload storage
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 3)
- Fix disk-backed upload cleanup after successful requests by adding
cleanup middleware that removes temp files on response finish/close
- Wrap sharp metadata/decoding errors as 400 validation errors instead
of 500 internal errors
- Only apply flatten() for JPEG output to preserve alpha channel in
PNG and WebP outputs
AI-assisted-by: qwen3.7-plus
* ✨ Add comprehensive tests for media-processor
Phase 1 - Cleanup verification:
- Add cleanup middleware unit tests (6 tests)
- Add HTTP upload cleanup integration tests (5 tests)
Phase 2 - Error handling & alpha preservation:
- Add sharp error wrapping tests (4 tests)
- Add HTTP malformed image tests (2 tests)
- Add alpha preservation tests (3 tests)
Phase 3 - Edge cases:
- Add upload storage edge case tests (3 tests)
- Add queue middleware edge case tests (4 tests)
Phase 4 - Backend mock verification:
- Fix backend mocks to include :mtype field in image info responses
- Verify all error codes match actual service behavior
Total: 27 new tests added (160 tests passing)
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 4)
- Add Zod validation constraints for config values (int, positive, min)
- Fix auth middleware to compare Buffer byte lengths instead of string lengths
- Validate requested output dimensions in generateThumbnail (crop mode)
- Change queue middleware to release slot via callback in finally block
- Add comprehensive tests for all fixes
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response streams in backend media remote
- Wrap stream consumption in try/finally with .close() calls
- Add tests to verify stream closure for info, font-convert, and thumbnail
AI-assisted-by: qwen3.7-plus
* 🐛 Fix queue slot leak on upload failures
Make releaseQueue idempotent and attach fallback listener to release
slot when response finishes. This covers Multer errors that bypass
the route handler's finally block, preventing permanent queue stall.
AI-assisted-by: qwen3.7-plus
* 🐛 Cancel processing on timeout
Create AbortController in timeout middleware and abort signal when
timeout fires. Pass signal to Sharp and FontForge to cancel ongoing
processing and release resources when request is cancelled.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 6)
- Error handler: check headersSent before writing response to prevent
ERR_HTTP_HEADERS_SENT when timeout already sent 504
- Timeout config: increase default requestTimeout from 60s to 180s to
match font processing timeout (120s) and backend request timeout
- Image processing: check abort signal before starting Sharp operations
to cancel processing when timeout fires
- Queue lifecycle: remove res.on('close', release) fallback to hold
queue slot until processing completes, preventing concurrency limit
violation when client disconnects
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream in download-image
Wrap response body in with-open to ensure stream is closed after
writing to temp file, preventing HTTP connection leaks on repeated
URL imports.
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream on validation errors in download-image
Move with-open to wrap the entire validation and processing block,
ensuring the response body stream is closed even when validation fails
(non-2xx status, missing size, invalid media type). This prevents
HTTP connection leaks on repeated failed downloads.
Add test to verify stream closure on validation errors.
AI-assisted-by: qwen3.7-plus
* 🐛 Pass abort signal to Sharp toBuffer for timeout cancellation
Wrap Sharp's toBuffer() with Promise.race to check abort signal during
processing. This ensures large thumbnails stop processing when the
request times out, preventing wasted CPU/memory and queue capacity.
Add test to verify abort during toBuffer operation.
AI-assisted-by: qwen3.7-plus
* 🐛 Hold queue slot until Sharp completes and handle client disconnect
- Remove Promise.race from generateThumbnail — Sharp processing now
completes fully before queue slot is released, preventing concurrency
limit violations under timeout conditions
- Remove res.on("finish", release) fallback from queue middleware —
error handler now explicitly calls releaseQueue in all error paths
- Add res.on("close") handler in timeout middleware to abort signal
when client disconnects, ensuring processing stops early
- Add tests for client disconnect handling and queue slot lifecycle
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 9 review findings
- Document Sharp 0.35.3 cancellation limitation in image.ts
- Add integration test for timeout cleanup with large images
- Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw
ProcessingError on resource limit kills instead of returning null
- Validate font signatures for same-format conversions to prevent
arbitrary files from being persisted as valid fonts
- Fix concurrent mkdtemp race in upload-storage by using shared
initialization promise
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 10 review findings
- Add tmpdir assertion in font.ts to prevent path injection
- Preserve original error in queue middleware catch handler
- Change auth middleware response type from "internal" to "authorization"
- Add cleanup flag to prevent double cleanup in cleanup middleware
- Move quality clamping into parseQuality function for consistency
- Add integration tests for quality parameter clamping at route level
- Update existing tests to match new auth response type
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 11 review findings
- Extract releaseSlot helper in error-handler to reduce duplication
- Remove redundant try/catch in font.ts withTempDir cleanup
- Improve font path validation error message for clarity
- Move path validation before try/catch to prevent swallowing
- Add debug logging for cleanup failures in cleanup middleware
- Inline TransportTargetSpec type alias in logger.ts
- Extract logging middleware to separate file for consistency
- Remove duplicate MIME validation in image thumbnail route
- Add test for font path validation (outside tmpdir rejection)
- Add tests for error handler queue release across all branches
AI-assisted-by: qwen3.7-plus
* 🐛 Remove Content-Length header from multipart requests
The JDK's HttpClient rejects Content-Length as a restricted header,
causing IllegalArgumentException when sending multipart requests to the
media-processor. Remove the explicit Content-Length header and let the
JDK use chunked transfer encoding. The media-processor will use disk
storage for all multipart requests (safe default behavior).
Remove unused size computations (file-size, header-bytes, footer-bytes,
total-size) that were only used for Content-Length.
Update test to verify Content-Length is not present in request headers.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix pino ESM bundling for media-processor
Mark pino and its transports (pino-pretty, pino-loki) as external to
avoid bundling issues with worker thread modules that reference
__dirname (not available in ES modules).
AI-assisted-by: qwen3.7-plus
Accept an optional :max-size keyword argument in blob/decode and
blob/decode-str. When provided, the uncompressed size declared in the
blob header is validated before allocating memory, raising an error if
it exceeds the limit. Callers that do not pass :max-size are unaffected.
AI-assisted-by: deepseek-v4-pro
The `SyncFromLibrary` op dispatches a real `sync-file` event that
schedules `rx/timer 3000` + an RPC call to
`update-file-library-sync-status`. In the headless test runner
(no backend), this produces a network error that leaks into test
output.
Wrap the `check` function in `mock/with-mocks` to mock `rp/cmd!`
(returning success) and `rx/timer` (firing instantly). This
eliminates the 3200ms grace period in `op-grace-ms` and prevents
the network error from appearing in test output.
AI-assisted-by: mimo-v2.5
Importing a .penpot file left every svg-raw subtree broken: the parent's
:shapes vector came back holding plain strings instead of uuids, so the
child ids no longer resolved against the page objects map. The next
persisted change touching that page then failed referential integrity
validation with :child-not-found, surfaced to the client as an HTTP 400
:referential-integrity error, which in practice bricks the file.
An svg-raw shape can be a container: importing an SVG builds a tree of
svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw
with children as group-like. But schema:svg-raw-attrs was an empty map.
Frame, group and bool all declare :shapes as a vector of uuid; svg-raw
did not, so the JSON decoder used by binfile had no type information for
those ids and left them as strings.
Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw
shape has no children, so the child ids decode back to uuids.
Closes#10496.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Importing a .penpot file left every svg-raw subtree broken: the parent's
:shapes vector came back holding plain strings instead of uuids, so the
child ids no longer resolved against the page objects map. The next
persisted change touching that page then failed referential integrity
validation with :child-not-found, surfaced to the client as an HTTP 400
:referential-integrity error, which in practice bricks the file.
An svg-raw shape can be a container: importing an SVG builds a tree of
svg-raw shapes, and cfh/group-like-shape? explicitly treats an svg-raw
with children as group-like. But schema:svg-raw-attrs was an empty map.
Frame, group and bool all declare :shapes as a vector of uuid; svg-raw
did not, so the JSON decoder used by binfile had no type information for
those ids and left them as strings.
Declare :shapes on schema:svg-raw-attrs, optional because a leaf svg-raw
shape has no children, so the child ids decode back to uuids.
Closes#10496.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
calc-overlay-position measured the destination overlay frame with its full
object bounds (get-object-bounds) while measuring the relative-to frame with
its selrect. Object bounds include padding for shadows, blur, outer strokes
and overflowing children, so centered/right/bottom overlays were shifted by
half that extra padding when the overlay frame had such effects (the overlay
appeared offset, e.g. a bit to the left).
Use the destination frame selrect (the visible frame box) instead, which
matches the sibling helper calc-overlay-pos-initial and the viewer, which
reserves the bounds size and re-aligns the selrect separately. The now unused
geom.shapes.bounds require is removed.
Adds a regression test asserting calc-overlay-position returns the same
position with and without a bounds-inflating drop shadow on the destination
frame.
Fixes#9048
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Replace the commiter subagent with a create-commit skill,
consistent with the create-pr and create-issue skill patterns.
- Remove .opencode/agents/commiter.md
- Add .opencode/skills/create-commit/SKILL.md
- Update implement-plan.md to use the skill instead of
subagent delegation
- Document commit body line wrapping at 72 chars in
creating-commits memory and skill
AI-assisted-by: mimo-v2.5
After `Modifier.removeRange` modified the block map, the `targetRange` from
the drop event still referenced stale block keys from the pre-removal DOM
state, causing `TypeError: Cannot read properties of undefined`.
- Added a guarded `moveText` export in `frontend/packages/draft-js/index.js`
that validates block keys exist before and after removal. Falls back
gracefully when target range references stale keys.
- Added a `handle-drop` callback in
`frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` that returns
"handled" for internal drag operations, preventing Draft.js from calling its
default (crash-prone) handler.
AI-assisted-by: mimo-v2.5
Close the profile props schema to reject undocumented keys and add a
denylist for system-managed props like :subscription that should not be
user-writable via RPC.
Changes:
- Add system-managed-props denylist (#{:subscription})
- Close schema:props with :closed true
- Add tests for subscription rejection and valid key acceptance
AI-assisted-by: qwen3.7-plus
* ♻️ Rename nitrate config to admin-console
Rename user-facing configuration from 'nitrate' to 'admin-console':
- Feature flags: :nitrate -> :admin-console, :nitrate-bulk-create-profiles -> :admin-console-bulk-create-profiles
- Config keys: :nitrate-shared-key -> :admin-console-shared-key, :nitrate-backend-uri -> :admin-console-uri
- Shared-keys map entry: :nitrate -> :admin-console (setup.clj + main.clj)
- Env vars: PENPOT_NITRATE_SHARED_KEY -> PENPOT_ADMIN_CONSOLE_SHARED_KEY, PENPOT_NITRATE_BACKEND_URI removed (consolidated into PENPOT_ADMIN_CONSOLE_URI)
- Docker/nginx: PENPOT_NITRATE_URI -> PENPOT_ADMIN_CONSOLE_URI
Code namespaces, file paths, CSS classes, and i18n keys stay as-is.
AI-assisted-by: mimo-v2.5-pro
* ♻️ Rename initialize-user-in-nitrate-organization to initialize-user-in-organization
Part of the nitrate -> admin-console rename series. The function and all 9 references across 6 files have been renamed.
* ♻️ Rename :nitrate-bulk-create-profiles-not-allowed to :bulk-create-profiles-not-allowed
* ♻️ Inline nitrate-permissions into app.common.types.organization
- Delete app.common.types.nitrate-permissions and its test
- Move permission rules (allowed?, can-send-invitations?, etc.) into organization.cljc
- Harmonize all consumers to use alias cto for app.common.types.organization
- Update test runner and create organization_test.cljc
Remove the guard in stop-devenv that refused to stop ws0 while any
ws1+ instance was running. Each workspace is now fully independent
and can be started/stopped in any order. Shared infra shuts down
only when no instances remain running.
Updated docs (devenv.md, agentic-devenv.md) and devenv memory to
reflect the new behavior.
AI-assisted-by: mimo-v2.5-pro
Never pipe test output directly to filters (head, tail, grep).
Always redirect to a file first to prevent hiding test failures.
AI-assisted-by: mimo-v2.5
Move Dockerfile.frontend, .backend, .exporter, .mcp and .storybook
under docker/images/ from ubuntu:26.04 / nginx-unprivileged / a
manual Node tarball install to Docker Hardened Images (Debian 13,
or Alpine for storybook). storybook and mcp get a true non-dev
runtime; frontend, backend and exporter keep the -dev tag as their
final image, since each needs a shell and/or package manager at
container runtime (nginx templating, fontforge/python3, and a
headless-browser stack, respectively).
Move docker/imagemagick/Dockerfile and docker/devenv/Dockerfile from
ubuntu:26.04 to Docker Hardened Images (Debian 13 / trixie).
imagemagick gets a true non-dev runtime with its shared libraries
vendored via ldd; devenv keeps the -dev tag as its final image since
it's an interactive development container, not a production
artifact.
MCP tokens now use a separate JWT issuer claim (`urn:penpot:mcp-token`) instead of `access-token`, preventing them from being validated as API access tokens.
Fixes#10960
AI-assisted-by: qwen3.7-plus
Production crash where @(get bounds id) threw
"No protocol method IDeref.-deref defined for type null"
when a shape ID had no corresponding entry in the bounds map
during layout calculations.
Added defensive nil guards (when-let / when) to all unprotected
bounds dereference sites:
- flex_layout/bounds.cljc: layout-content-points (parent + child)
and layout-content-bounds
- grid_layout/bounds.cljc: layout-content-points and
layout-content-bounds
- min_size_layout.cljc: child-min-width grid branch (3 sites) and
child-min-height grid branch
Added 7 new tests in geom_bounds_layout_nil_test.cljc covering all
nil-bounds edge cases for flex, grid, and min-size layout paths.
Registered in runner.cljc.
Closes#10843
AI-assisted-by: qwen3.7-plus
* 🐛 Fix main menu is covered by the toolbar
* ♻️ Refactor SCSS
* 🐛 Fix adjust z-index of workspace context menu
* ♻️ Refactor SCSS
* 🐛 Fix adjust z-index of tokens context menu
* ♻️ Refactor SCSS
* 🐛 Fix adjust z-index of old context menu
* 📎 PR improvements
* 📎 Update serena documentation about creating-prs workflow
* 🐛 Handle unrecognized JSON escape sequences as malformed-json
When clojure.data.json's read-escaped-char encounters an unrecognized
escape sequence (e.g. a backslash followed by '}', or other case
fall-throughs in the parser) in a JSON request body, it throws a bare
IllegalArgumentException. Previously this fell through to the generic
RuntimeException branch in wrap-parse-request's handle-error, which
unwrapped and recurred without matching, eventually reaching the
internal-error handler and producing HTTP 500 + an error report — even
though the root cause was malformed client input, not a server bug.
The fix converts any IllegalArgumentException raised in the JSON parse
path into a `:validation`/`:malformed-json` error by raising a new
ex-info (which is caught by the top-level error handler in
`app.http/router-handler`). The result is an HTTP 400 response with a
descriptive hint, and no error report is generated. This addresses
~10% of all error reports received.
The new IAE branch is placed before the RuntimeException branch in
the cond (since IllegalArgumentException IS-A RuntimeException) and
uses the throw-style (ex/raise) to match the existing
RequestTooBigException / EOFException branches. A comment above the
handle-error cond documents why raising is intentional and is caught
by the top-level app.http error handler, not by the per-route
wrap-errors middleware.
Test suite changes:
- Extend the existing `DummyRequest` defrecord in
`http_middleware_test.clj` from 2 fields to 12 fields, implementing
every IRequest method, and add a private `make-dummy-request`
constructor that accepts an options map with every key optional and
sensible `:or` defaults. Future fields added to DummyRequest won't
break existing call sites as long as the `:or` defaults are kept in
sync.
- Remove the now-redundant `JsonRequest` defrecord and migrate all 11
`->DummyRequest` call sites to `make-dummy-request`.
- Add 6 new deftest cases:
- parse-request-illegal-argument-exception: malformed JSON body
(containing `\}`) is converted to `:malformed-json`.
- parse-request-request-too-big-exception: RequestTooBigException
is converted to `:request-body-too-large`.
- parse-request-eof-exception: java.io.EOFException is converted
to `:malformed-json`.
- parse-request-runtime-exception-with-cause: a wrapped
RuntimeException recurses on ex-cause and dispatches to the
matching specific branch.
- parse-request-runtime-exception-without-cause: a bare
RuntimeException falls through to errors/handle, returning 500
with :type :server-error :code :unexpected.
- parse-request-non-runtime-throwable: java.io.IOException (a
non-RuntimeException Throwable) is handled by the dedicated
handle-exception method, returning 500 with :code :io-exception.
Together, the new tests cover all 6 branches of wrap-parse-request's
handle-error cond.
Refs #10804.
AI-assisted-by: minimax-m3
When the user types a search term that filters out the currently-active
font, the picker showed no selection at all and Enter would close without
applying any font. Fix:
- Compute effective-selected (render-only, no state mutation) as the
first filtered font when the current font is absent from the results.
The row renderer and recent-fonts list use this for the tick mark, so
the top match is visually pre-selected while the user types.
- Enter key applies first-result (via on-select then on-close) when the
current selection is not in the filtered list; otherwise closes as
before. No font is live-applied on every keystroke.
- on-key-down deps extended to on-select/on-close; effect deps include
on-key-down so the global listener is always current.
Avoids the live-apply side-effect that caused the revert of #9512.
Fixes#3204.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🎉 Improve reset to default flow
* 🎉 Add diff modal
* 🐛 Fix measurement shortcuts
* 🎉 Separate open-sections for each tab
* 🐛 Align button link icon
* 🎉 Reset only works after press save
* 🐛 Fix little things
* 🎉 Make the import export row to be fixed
* ♻️ Fix CI
* 🐛 Cancel shortcut is not appearing on disabled tab
* 🐛 Fix tests
* 🐛 Fix loop on personalized
* 🐛 Fix show measurements shortcut
The timeout for tool calls (which is trictly relevant for plugin tasks only)
is now configurable via env. var PENPOT_MCP_TOOL_TIMEOUT_S.
The default was raised from 30 to 120, because 30 seconds was not enough for
some calls, especially in larger Penpot files. #10953
In multi-user mode, rejecting a second plugin WebSocket connection for an
already-registered user token performed the full removeConnection cleanup
for the newcomer. Since the token-keyed cleanup is keyed by token rather
than by socket, this deleted the clientsByToken entry and the Redis
request-channel subscription of the established, healthy connection. That
connection then remained open and heartbeating but was unroutable, so every
subsequent MCP tool call for the user failed although a valid plugin
connection existed.
removeConnection now performs the token-keyed cleanup only if the removed
connection actually owns the token registration, so rejecting a duplicate
releases only the resources the newcomer itself registered.
AI-assisted-by: claude-fable-5
In multi-user mode, plugin task requests are published to a Redis channel
keyed by user token. When no MCP server instance held a plugin connection
for that token (e.g. after the user navigated away from the workspace),
the publish reached zero subscribers and the request was silently dropped,
so every tool call stalled until the 30-second task timeout instead of
failing with a meaningful error.
RedisBridge.sendTaskRequest now returns the PUBLISH receiver count and
releases its response-channel subscription when the request reached no
receiver (or publishing failed), since no response can arrive. PluginBridge
uses the count to reject the pending task immediately with the multi-user
connection error message; publish failures likewise reject the task instead
of surfacing as an unhandled rejection followed by a timeout. The pending-
task settlement logic shared with the timeout handler is extracted into a
rejectPendingTask helper.
AI-assisted-by: claude-fable-5
* 🐛 Add regression test: main-side component edits break copy swap slots
Reproduces the :missing-slot referential-integrity failure ("Shape has been
swapped, should have swap slot") that crashes files with component copies.
Root cause: reordering or deleting a nested sub-head IN THE MAIN of a component,
while copies exist, does not propagate swap slots to the copies. find-near-match
matches a copy's sub-heads to the main's children by POSITION, so once the main's
order changes the copies' shape-refs no longer match their position and, lacking a
swap slot, fail referential-integrity validation.
- Copy-side edits are handled correctly (characterization tests, pass today).
- The two main-side tests fail today with :missing-slot and go green once the
sync assigns swap slots to copies on a main reorder/delete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🐛 Fix integrity crashes from copy/main child-order divergence
Copy sub-heads were matched to their main's children purely by position
(find-near-match), while several code paths reorder or mutilate one side
only. Any of them made file validation fail with :missing-slot ("Shape
has been swapped, should have swap slot"), crashing the workspace on the
next validated commit, or persisting a corrupt file whose later edits
crash. Reproduced live: deleting a sub-head inside a copy (which only
hides it) and then reflowing the copy's grid moved the hidden (cell-less)
child to the front of :shapes, knocking every sibling out of its
positional slot.
Four fixes, one per divergence path:
- validate/check-required-swap-slot: a sub-head whose shape-ref is still
a child of the near main parent is a REORDER (the component sync
realigns it), not a swap; a swap slot is required only when the ref
points outside the near main parent (a real swap). This matches how
the sync engine itself pairs children (by shape-ref, not by position).
comp-processors/fix-missing-swap-slots (migration 0019) is aligned:
adding slots to merely-reordered sub-heads would freeze them out of
normal synchronization.
- changes/:reorder-children now refuses to alter the child structure of
component copies unless allow-altering-copies is set, mirroring the
is-valid-move? rule of :mov-objects; that structure is owned by the
component sync engine. Grid reflows emitted this change type with no
guard. pcb/reorder-grid-children also skips copy grids producer-side.
- layout/reorder-grid-children keeps children that participate in no
cell (hidden or absolute positioned) at their original index instead
of lumping them at the front: moving them gratuitously changed their
z-order and, in copies, broke the positional matching. Note :shapes
stays reversed relative to the sorted cell order for in-cell children.
- logic/generate-delete-shapes: deleting shapes from inside a main
(without deleting the main root, whose copies keep working against the
deleted component) now also deletes the copy shapes that reference
them, transitively (copies of copies) and across all pages of the
file, so no dangling shape-refs remain. Skipped for
allow-altering-copies flows (component swap replaces the shape and the
sync reconciles copies via swap slots). Cross-page removals build
redo/undo changes against that page's objects directly, since the
changes-builder mounts only the current page; their undo mov-objects
carry allow-altering-copies so restoring inside a copy is not rejected
by the new guard.
The regression tests assert the fixed semantics: main-side reorders and
deletes keep copies valid, the previously crashing full chain (unvalidated
main reorder + later copy edit) stays healthy, :reorder-children cannot
scramble copies, and reorder-grid-children keeps cell-less children in
place. The namespace is now also registered in the JS test runner.
AI-assisted-by: Claude Opus 4.8 (1M context)
* ✨ Extend composable slot cases to the grid-reflow crash sweep
Case D (CopySubheadDeletePreservesSlots) now sweeps which copy sub-head
is deleted (first or last) and whether the copy root is resized
afterwards, forcing a grid reflow: the reflow used to move the hidden
(cell-less) child to the front of the copy's children, shifting every
sibling out of its positional slot. Deleting the FIRST sub-head masked
the bug (moving it to the front is a no-op), which is why the case
passed before this sweep. The foundation layout is grid accordingly.
Case E (MainReorderKeepsCopySlots) no longer hangs the app now that a
main-side reorder leaves a valid file, so its warning docstring is
replaced: it runs as a routine test (verified headless, passing) and is
safe in a "run all".
SlotIntegrity's doc is updated to the new validator semantics (a slot is
required only for real swaps, not reorders); the positional alignment it
asserts remains the correct, stronger steady-state invariant for these
cases. The lockfile change materializes the playwright devDependency
already declared in package.json.
AI-assisted-by: Claude Opus 4.8 (1M context)
* 📚 Record copy/main order-divergence invariants in memories
Swap-slot semantics (membership, not positional; slots only for real
swaps), the copy-structure guards on :mov-objects/:reorder-children, the
grid reorder stability for cell-less children, and the main-side delete
propagation in generate-delete-shapes.
AI-assisted-by: Claude Opus 4.8 (1M context)
* 🐛 Add adjustements to the code
---------
Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 🎉 Install react-aria-components
* 🎉 Create modal component in TS using react-aria-component
* 🎉 Create modal ds component
* 🎉 Separate header content and footer components
* 🐛 Remove mf/html macros when not needed
* 🎉 Solve little problems
* ♻️ Format files
* ♻️ Remove ModalCloseBtn
* 🐛 Fix CI
* ♻️ Remove unused files
* 🎉 Make close button not dependant on the modal header
* 🎉 Add footer with two slots
* 🎉 Improvements on modal
* 🐛 Fix package imports and remove login example code
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Combined fixes from PR #10656 (numeric-input redesign) and PR #10696
(global finite? guard) for issue #10638.
- Add (number? v) guard to cljs mth/finite? so strings are rejected
- Redesign numeric-input last-value* to store number, not formatted string
- Invalid-input fallback restores display without emitting on-change
- Esc now fully discards typed text (resets raw-value* + dirty flag)
- Token dedup by name instead of resolved value
- Defense-in-depth: d/parse-double at 4 padding/gap handlers
- Math tests (common), unit tests, Storybook play tests, Playwright E2E
AI-assisted-by: deepseek-v4-flash
Co-authored-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Ulises Millán <ulises.millanguerrero@gmail.com>
* 🐛 Add nil guards on viewport-node in pixel overlay component
Add nil checks for viewport-node in process-pointer-move, viewport->canvas-coords, process-pointer-move-wasm, pick-color-at-wasm, and handle-draw-picker-canvas.
Fixes a crash ("can't access property 'getBoundingClientRect', ... is null")
when the viewport DOM node is unmounted while the color picker eyedropper
is active and pointer move events are still firing.
Fixes#10811
AI-assisted-by: mimo-v2.5
* 🐛 Remove unused app.common.pprint require from errors.cljs
Fixes clj-kondo warning: namespace app.common.pprint is required but never used.
AI-assisted-by: mimo-v2.5
Use cuerdas blank-name handling directly when normalizing frontend export
payloads. Replace nil or blank export names with the object-id string in
request-simple-export, request-multiple-export, clipboard export, and plugin
direct export payloads so that the backend always receives a valid name. Add
focused frontend tests for nil/blank name normalization and normalized request
params.
AI-assisted-by: nex-n2-pro
* 🐛 Clamp gradient stop offsets to valid [0, 1] range
Fixed a bug where gradient stop offsets outside the valid [0, 1] range were being sent to the server, causing schema validation errors ('invalid shape found').
Changes:
- Viewport gradient handler: clamp offset in points-on-pointer-down before creating new stops
- Colorpicker gradient preview: clamp offset in handle-preview-down before adding stops
- Data layer: clamp offset parameter in update-colorpicker-add-stop and all stop offsets in update-colorpicker-stops; added app.common.math require
All clamping follows the existing pattern used in handle-marker-pointer-move.
AI-assisted-by: deepseek-v4-flash
* 💄 Fix formatting in gradient handlers
Fix cljfmt formatting issues in gradient handler functions.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix setting a font-related properties to nil
* ✨ Repair already-corrupted text nodes
* 🐛 Fix deleted fonts appearing in Recents and as the auto-selected font for new text shapes
The audit event validation was failing when processing error reports that
contain string profile-id values. The error report storage converts
profile-id to string format, but the audit schema expects a UUID.
Changes:
- Modified prepare-rpc-event to convert string profile-id to UUID using
uuid/parse* (exception-safe parsing)
- Updated access token middleware to set ::id and ::type on request so
audit context includes token identification
- Added tests for profile-id conversion and token context population
Closes#10897
AI-assisted-by: qwen3.7-plus
Every pointerup unconditionally fires finish-panning and finish-zooming,
which call finalize-view-interaction!. This triggered internal-render
(and reset_canvas) on plain clicks — causing a visible white flash on
large viewports or weak GPUs.
Add a guard so finalize-view-interaction! only runs when a view
interaction (pan/zoom) is actually active.
Fixes#10915
Co-authored-by: Cursor <cursoragent@cursor.com>
* 🐛 Prevent WASM panics during WebGL context restore
Delay context-restored until reload finishes and no-op app
mutations while reloading so resize/modifiers cannot panic
mid-teardown.
* 🐛 Fall back to CLJS bool content when WASM is not ready
Returning nil from calculate-bool would persist empty path content
during context loss/reload; use path/calc-bool-content instead.
Local-only builds by default for build-devenv and
build-imagemagick-docker-image; --push is now required to build
multi-platform and push to the registry. All release-image build
commands (frontend, backend, exporter, mcp, storybook) now accept
--tag to override the image tag. Adds a shared DEVENV_TAG variable
threaded through pull-devenv, the production build function and
docker-compose.main.yml so a custom devenv tag can be used
end-to-end.
* 🔧 Support background blur on PDF render for strokes and text shapes
* ✨ Add LRU eviction to the wasm image store
* ✨ Add RasterFormat to encode png, jpeg and webp from wasm
* 🐛 Fix client-side wasm export encoding jpeg and webp as png
Penpot itself and plugins used different default theme selection
strategies when no theme is explicitly selected in Penpot by the user:
- Penpot itself defaults to dark.
- Plugins defaulted to whatever is configured in the user's
system/browser, which could be light.
So with no explicit theme chosen in the profile — the common state —
every plugin could be told the theme was light while Penpot itself
showed dark, both on open (penpot.theme) and on themechange. For every
explicit choice (light, dark, system, and the legacy default) the two
resolutions already agreed; only the unset case diverged.
Extract the app's resolution into app.util.theme/resolve-theme (system
follows the system theme; default and unset mean dark) as the single
source of truth, and use it in the app's own use-initialize as well as
in the plugin runtime's getTheme and themechange handling. The
themechange handler's now-dead default-to-dark remapping is removed.
Fixes#10676
AI-assisted-by: claude-fable-5
* 🐛 Fix leaked deferred DOM ops on dashboard navigation and template clone
The React reconciliation "removeChild" error surfaced during rapid
dashboard navigation because several effects scheduled deferred DOM
operations (focus, CSS positioning) without returning a cleanup that
cancelled them. When the component unmounted before the callback
fired, it ran against stale DOM and desynchronized React fiber tree
from the actual DOM.
- context_menu_a11y.cljs: replace tm/schedule-on-idle (30s idle
window) with tm/schedule (setTimeout 0) and return a rx/dispose!
cleanup.
- dropdown.cljs: capture the tm/schedule handle and dispose it in
the effect cleanup.
- tooltip.cljs: capture the ts/raf handle and cancel it on cleanup.
AI-assisted-by: opencode-go/mimo-v2.5-pro
* 🐛 Fix leaked focus timers in dashboard sidebar navigation
Six sidebar navigation handlers scheduled setTimeout callbacks to mutate
tabindex/focus on React-managed title elements without cancelling prior
pending callbacks. During rapid keyboard navigation (Projects→Fonts→Libs→Drafts)
the stale callbacks fired against unmounted DOM, desyncing React fiber tree
and triggering "removeChild" NotFoundError.
- sidebar-project*: cancel prior timer in on-key-down
- sidebar-search*: cancel prior timer in on-key-press
- sidebar-content*: cancel prior timer in go-projects-with-key,
go-fonts-with-key, go-drafts-with-key, go-libs-with-key
Each handler now stores the timer handle in a component-level ref and
disposes any pending handle before scheduling a new one.
AI-assisted-by: opencode-go/mimo-v2.5-pro
* ♻️ Refactor sidebar focus timer handling into helpers
Extract the repeated dispose-before-schedule focus idiom into
schedule-focus-by-id! (sidebar.cljs) and focus-and-untabbable!
(app.util.dom). Replaces the six duplicated blocks and adds
mf/use-effect unmount cleanup to dispose any pending timer in the
three sidebar components, closing the remaining leak noted in the
original fix.
AI-assisted-by: opencode/hy3-free
* ♻️ Extract use-focus-timer-ref hook for sidebar components
Replace the duplicated mf/use-ref + mf/use-effect cleanup pairs in
sidebar-project*, sidebar-search*, and sidebar-content* with a shared
use-focus-timer-ref hook (app.main.ui.hooks). The hook creates the ref
and disposes any pending timer on unmount via mf/with-effect, reading the
ref with mf/ref-val instead of deref. mf/use-effect is now a body-level
hook call rather than a let binding.
AI-assisted-by: opencode/hy3-free
* 📎 Add pr feedback fix
Repair text shapes with empty/broken content at all three levels
(root, paragraph-set, paragraph) in migration 0025 to prevent
workspace update failures. Handle all corner cases: nil/empty/non-
vector children, non-map items, wrong types. Remove geometry-only
validation skip in changes.cljc so all shapes are validated.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix several issues in RPC command handlers
- Reject circular library references in link-file-to-library
- Add explicit team permission check in search-files
- Constrain search-term max length to 250 chars
- Include :deleted-at in file ETag for COND caching
- Move storage I/O outside DB transaction in create-file-thumbnail
AI-assisted-by: deepseek-v4-pro
* 📎 Check perms before circular link checks
* 🐛 Handle circular library reference error
Catch :circular-library-reference error from backend when linking
files to libraries. Show user-friendly toast notification instead of
propagating unhandled error. Add English and Spanish translations.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix area selection aborted by select-shapes interrupt
Only emit :interrupt from select-shapes when edition mode is active.
Unconditional :interrupt (from #10798) made drag-stopper cancel the
marquee mid-drag.
* 🔧 Fix text editor v2 fill e2e test on develop
An organization owner keeps read-only access to the teams of their
organization even when they are not a member, so removing them from a
team was navigating them away from content they are still allowed to
see, and they could walk right back in through the URL.
Publish a :team-role-change to :viewer for them instead of a
:team-membership-change, which reuses the existing real-time role
transition on both the dashboard and the workspace. Any other member is
notified as before.
Signed-off-by: Juanfran <juanfran.ag@gmail.com>
The third column (event name) in error report "last events" now starts at
a consistent position regardless of the delta value, by right-padding the
delta string to 10 characters. The first event always shows (+0ms).
Adds tests for empty, single, multi-event, and column alignment cases.
AI-assisted-by: deepseek-v4-flash
Replace `globals/document` and `globals/window` with
`js/document` and `js/window` in workspace.cljs, removing
the unused `app.util.globals` import. This avoids "can't
access dead object" errors in Firefox when navigating between
pages/files, matching the existing pattern used in
viewport/hooks.cljs.
Fix a leaked MOUSELEAVE listener in viewport_ref.cljs — the
ref callback added a new listener on every mount but never
unregistered the previous one. Now uses standard
.addEventListener/.removeEventListener with a React ref to
track the handler for proper cleanup.
Fix ResizeObserver cleanup in viewport_ref.cljs —
`init-observer` is now a private function that only creates
an observer when a node is provided, and cleanup is handled
via the ref callback on unmount.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Fix nil getData crash dropping ZIP without manifest.json
Add nil-guard in read-as-text to raise typed :invalid-entry error instead of calling (.getData nil writer) which produced a raw TypeError.
Made read-zip-manifest public (was defn-) with explicit detection of missing manifest.json, raising typed :invalid-penpot-file validation error. The existing catch path surfaces this hint as a friendly user error instead of the raw TypeError text.
Add regression tests for both paths. 374 users were affected, 704 occurrences across 2.17.0-RC2/RC3/RC4.
Fixes#10709.
AI-assisted-by: minimax-m3
* 🐛 Harden dnd/get-data against missing dataTransfer
When the sortable hook or any caller passes a synthetic event
without a dataTransfer property (e.g. a dragend fired after a drop
that has already cleared the transfer), the previous implementation
called .getData directly on the nil/undefined result and threw
"Cannot read properties of undefined (reading 'getData')".
Wrap the body in when-let so get-data returns nil cleanly when
dataTransfer is missing. All three current callers
(hooks.cljs:164, viewport/actions.cljs:531 and :562) already treat
the return value as optional via when-let / when, so no caller
breaks.
Add a regression test covering both the missing-dataTransfer case
and a real dataTransfer roundtrip.
AI-assisted-by: minimax-m3
* 🐛 Harden paste handler against missing clipboardData in forms
When a paste event arrives without a clipboardData property (e.g.
a programmatically dispatched ClipboardEvent in some browsers, or
edge cases like dragging a file with no text content), the previous
implementation called .getData directly on the nil/undefined
clipboardData and threw "Cannot read properties of undefined
(reading 'getData')".
Wrap the body in when-let so the paste logic is skipped entirely
when clipboardData is missing. The existing (string? paste-data)
guard in the inner when already tolerates nil; no other caller
behavior changes.
AI-assisted-by: minimax-m3
* 🐛 Harden paste handler against missing clipboardData in components/forms
Same defensive pattern as the main/ui/forms.cljs paste handler: wrap
the body in when-let so the .getData call is skipped when the
clipboardData property is missing on the paste event. Prevents the
raw "Cannot read properties of undefined (reading 'getData')"
TypeError for programmatic / edge-case paste events.
AI-assisted-by: minimax-m3
* 🐛 Harden v3 text editor paste and styles-fn against undefined receivers
Two related fixes for the "Cannot read properties of undefined
(reading 'getData')" family of bugs in the workspace text editor:
- v3_editor.cljs: wrap the paste body in when-let on clipboardData
so .getData("text/plain") is never called on a nil receiver. The
existing (when (and text (seq text))) guard already tolerates nil
text; only the outer .getData call was unprotected.
- editor.cljs: add (and content ...) to the if branch in styles-fn
so .getText and .getData are never called on a nil content. The
else branch (legacy.txt/styles-to-attrs) is already the correct
fallback for missing content.
Add a regression test that mirrors the fixed patterns and verifies
they no longer throw on synthetic events with no clipboardData or
nil content.
AI-assisted-by: minimax-m3
* 🐛 Harden get-editor-block-data and get-editor-block-type against nil block
getCurrentBlock from Draft.js can return undefined for an empty
selection (e.g. before any block is created). The previous
implementations called .getData / .getType directly on the result
and threw "Cannot read properties of undefined (reading
'getData')" / "...reading 'getType')".
Wrap both functions in (when (some? block) ...) so they return nil
cleanly. Callers in editor.cljs and text_editor.cljs already handle
nil results (render-block short-circuits via the case on type; the
text-data caller in text_editor.cljs lets nil flow up), so no
upstream change is required.
Add a regression test covering both functions with nil and
js/undefined input.
AI-assisted-by: minimax-m3
* 🐛 Harden draft-js block-data helpers against nil block
Three related fixes in the vendored draft-js package:
- mergeBlockData: early-return undefined when block is falsy.
Without this, the first line (block.getData()) throws for callers
that pass a nil block.
- splitBlockPreservingData: guard the blockMap.get(...) lookup. If
the start key is stale (e.g. after a Modifier.splitBlock that
doesn't actually produce the expected key), .get() returns
undefined and the subsequent .getData() throws. Fall back to an
empty Immutable Map for the block data.
- updateBlockData: short-circuit (return state unchanged) when
mergeBlockData returns undefined. Without this, the chain
newBlock.getData() would throw on the same nil-block case that
mergeBlockData now guards.
These match the defensive nil-handling pattern used elsewhere in
the frontend (.getData callers) and protect against stale
selection keys in the Draft.js content state.
AI-assisted-by: minimax-m3
The handle transform composed rotation incorrectly, so handles blew out in size when an ellipse was rotated.
Fixes#10069
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Copy/paste of properties resolved tokens to their values, dropping the reference. Carry the token with the value it resolves, at sub-attribute granularity for map-valued attrs.
Fixes#9582
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add plugin with composable test framework and component tests
The plugin provides a framework for writing composable tests against
the Plugin API, and applies it to systematic end-to-end testing of
component semantics.
The framework's core ideas: a test is written once as a composition of
operations over a starting configuration; choice points among the
operations (optional steps, alternatives) expand the composition into
a full sweep of test variants, so a single case definition yields
broad combinatorial coverage; and the operations drive the real Plugin
API with real change propagation, testing the full production
implementation.
The initial application is a suite of component test cases covering
synchronization, overrides, swap slots and variants — the
TypeScript/e2e continuation of the ClojureScript composable test suite
(frontend_tests.composable_tests). Several cases originate from
reproducing real defects (e.g. #10109 and the swap-slot corruptions).
Tests run from an interactive panel in Penpot: cases are listed with
plain-language descriptions, tests can be run selectively, results
stream in live, and every checkbox carries a stable DOM id — so the
panel can equally be driven programmatically (the basis for running
the suite in CI), as documented in the plugin's README.
Lives at plugins/apps/composable-test-suite as a regular member of the
plugins workspace (init script, start:plugin:composable-test-suite,
shared dev port 4202, covered by build:plugins via the new
./apps/*-test-suite filter).
Related to #10584.
AI-assisted-by: claude-fable-5
* ✨ Run the composable test suite headlessly in CI
Adds a headless run mode for the composable test suite, following the
plugin-api-test-suite's CI architecture, and a workflow that runs it as
a per-PR gate.
An in-sandbox entry (src/ci/headless.ts) runs the suite without the
panel UI — the framework's runner was UI-free by construction, so no
refactoring was needed — and streams each result through console
markers, addressed by the same composite identifiers the panel uses
(e.g. MainEditSyncs-2), with durations and, on failure, the error and
the applied-steps transcript. It is built as a single self-executing
bundle and evaluated directly inside a real Penpot plugin sandbox by
the driver (ci/run-ci.ts), so no plugin dev server or port is involved.
The driver needs no backend and no login: it serves the prebuilt
frontend bundle via the frontend e2e static server and intercepts every
backend RPC with Playwright fixtures. The mocked backend is not a
limitation for this suite — everything it asserts is frontend store
logic executed in memory — which the full run confirms: all 48 tests
behave identically to the interactive panel, including variants and
swap slots, with the single (currently expected) failure of
MainEditSyncs-2 reproducing bug #10109 under the mock.
TEST_FILTER selects tests by identifier substring; CI_TIMEOUT_MS bounds
the run. The mock harness mirrors the frontend e2e harness (see the
provenance note in the driver).
Related to #10584.
AI-assisted-by: claude-fable-5
* 📚 Restructure the composable-tests memory around both suites
Present the composable component tests top-down: the shared framework
principles upfront, then the two implementations — the ClojureScript
suite in the frontend test tree and the TypeScript suite in the plugin,
which tests fully end-to-end with a slightly more elaborate set of
abstractions — and the plugin's headless CI run, pointing to the
plugin's README for operational details. Also records this session's
additions (geometry operations, case N, the CI harness).
AI-assisted-by: claude-fable-5
* 📎 Refine the PR-description conventions in the creating-prs memory
Encourage digestible descriptions: bullet items over prose (grouped by
area with bold lead-ins for larger PRs) and no manual line wraps, since
the rendered markdown adapts to the viewport. Also drop the outdated
'MCP' from the standard Note line.
AI-assisted-by: claude-fable-5
* 🔧 Set Prettier endOfLine to auto in plugins workspace
Prettier defaults to endOfLine "lf", which is incompatible with
checkouts on Windows that use core.autocrlf=true
* 🐛 Fix problems with suite
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Server changes:
- Switch list ordering from DESC to ASC (oldest first)
- Flip cursor direction to > for forward pagination
- Add 'until' param for server-side upper-bound filtering
CLI changes:
- Add --from/--to flags mapping to server's since/until
- Streaming output for --all and --format ndjson
- Add --format ndjson option (one JSON object per line)
- Add --normalize-hints flag to strip dynamic values
- Add --output flag to write list results to file
- Add 'stats' subcommand with aggregations (signature, host,
tenant, version, source, kind, hour) reading from API, file, stdin
- stats input supports JSON, JSON array, and NDJSON formats
Test changes:
- Fix pagination assertions for ASC ordering
AI-assisted-by: mimo-v2.5-pro
Use Penpot's shared image type list for image fill uploads and add an integration test covering SVG file selection.
AI-assisted-by: gpt-5.6-sol
Signed-off-by: Lucas Ozdemir <lulu.58@outlook.fr>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The change-password form validated the existing password against the 8-character policy, locking out accounts whose current password predates it.
Closes#10626
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The Color tokens picker showed token sets in their raw definition
order (ascending precedence), so the lowest-precedence set appeared
first and the highest-precedence (last-defined, winning) set
appeared last. This is the opposite of what's useful: users care
most about which set is currently winning, so that one should be at
the top.
get-sets returns sets in definition order and the picker's
grouped-tokens-by-set pipeline (add-tokens-to-sets ->
filter-active-sets -> filter-non-empty-sets -> group-sets ->
combine-groups-with-resolved) preserves that order at every step, so
the picker just rendered get-sets' raw order. Reverse the set seq
once, before it enters the pipeline, so the highest-precedence set
renders first.
group-sets groups sets by parent path via group-by, which risked
restoring definition order within a subgroup independent of the
reversed input order. Added tests covering a flat set list, a
reversed subgroup (to confirm group-by does not silently re-sort
subgroup members), and a mixed flat/subgrouped list.
Closes#10552
Signed-off-by: Andrew Cunliffe <cunliffeandrewc@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix clipboard crash when copying as SVG
clipboard.write with an image/svg+xml payload throws an unhandled DOMException on browsers that do not support the type. Fall back to writeText for that specific failure.
Fixes#10596
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
* 📎 Update Kaleidos Copyright
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
---------
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Each event entry in `last-events` is now wrapped as
`{:name <event-type> :t (app.common.time/now)}` so every event carries a
wall-clock timestamp. A new helper `format-last-events` renders the
buffer as a multi-line string with ISO time and delta-since-previous-
event in ms, replacing the previous pprint dump in error reports.
This lets support/devs tell whether the events leading up to a crash
were spaced out (user action) or jammed together (runaway loop).
AI-assisted-by: minimax-m3
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Non-export zip files were tagged as :legacy-zip with the body attached,
causing downstream parsing to crash on unrecognized zip content. Now
they are marked :unknown, matching how other unrecognized formats are
handled, so the import fails gracefully.
AI-assisted-by: deepseek-v4-flash
Holding an arrow key on a selection with a fast OS key-repeat rate
crashed the workspace with React error #185 (Maximum update depth
exceeded): each OS key-repeat event was converted 1:1 into a
`set-modifiers`/`set-wasm-modifiers` store write inside
`nudge-selected-shapes` with no throttle, starving the renderer.
The sibling mouse-driven resize/rotate/move paths got an `rx/sample`
throttle in PR #10560; the keyboard-nudge path was the only transform
stream left un-throttled. This change applies the same `rx/sample`
throttle to the nudge stream, mirroring the drag-path structure, and
adds a regression test guarding the final committed position
invariant under a burst of 20 `move-selected` events for both the WASM
and legacy (non-WASM) branches.
Closes#10726
AI-assisted-by: glm-5.2
The bulk profile creation endpoint creates already active profiles that
skip email verification and onboarding, so it should not be reachable on
production deployments. Add the `nitrate-bulk-create-profiles` flag,
disabled by default, and reject the call when it is not enabled.
Signed-off-by: Juanfran <juanfran.ag@gmail.com>
The dashboard route can be reached without a `:team-id` query parameter
(e.g. `/#/dashboard/recent`). When that happened, `team-container*` was
emitting `dtm/initialize-team` with a `nil` team-id, which set
`:current-team-id` to `nil` in the application state. The dashboard
and workspace initialize events then built `df/fetch-fonts` with a
`nil` team-id, producing a `:get-font-variants` RPC with empty params
`{}` that the backend rejected with HTTP 400.
Guard `team-container*` so it does not emit `initialize-team` /
`finalize-team` and does not render the children when `team-id` is
not a uuid. The `with-effect` body and the render are guarded
independently; the cleanup closure captures the same `team-id` as the
setup, so the finalize still fires correctly when transitioning between
valid teams.
AI-assisted-by: minimax-m3
* 🐛 Skip identity transforms in layout reflow propagation
Layout reflow emitted identity transforms for unchanged children, which
fanned out through the whole subtree on every drag frame and froze large
files in the WASM renderer.
* 🐛 Fix exclude boolean rendering in render WASM
Implement RPC methods for querying server error reports with pagination
and filtering. Add CLI tool (tools/error-reports.mjs) for convenient
access with table and JSON output formats. Extract profile-id from audit
events and logging context for better error categorization. Build
improved HREF using request path when available.
AI-assisted-by: qwen3.7-plus
When nginx follows a backend 307 redirect to a presigned S3 URL, it was
forwarding the client's Authorization header to S3. Production S3 rejects
this because it sees two auth mechanisms (presigned URL signature +
Authorization header). MinIO in devenv is more lenient and ignores the
extra header.
Fix: add proxy_set_header Authorization "" in the @handle_redirect block.
Fixes#10776
AI-assisted-by: mimo-v2.5-pro
Schema validation reported a generic "Invalid data" message. Report the expected schema and the received value, with a bounded cycle-safe renderer so the error path cannot itself crash.
Fixes#10072
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Move all development tools from tools/ to scripts/ for consistency.
Rename lint/fmt/check-fmt to lint-clj/fmt-clj/check-fmt-clj to clarify
they target Clojure specifically. Remove unused scripts (attach-opencode,
start-opencode, start-opencode-server) and the backport-commit skill.
Update all internal references across .serena/, AGENTS.md, and
CONTRIBUTING.md to point to the new script locations. Simplify
CONTRIBUTING.md by delegating module-specific fmt/lint instructions
to the respective serena memories.
AI-assisted-by: deepseek-v4-flash
The workspace route can be reached without a `:file-id` query parameter
(e.g. `/#/workspace?team-id=...`). When that happened, `workspace*` was
emitting `dw/initialize-workspace` with a nil file-id, which stored nil
in `:current-file-id`. The `fetch-profiles` event then read nil from
state and called `:get-profiles-for-file-comments` with `{:file-id
nil}`, producing a 400 response.
Move the `use-equal-memo` calls for `file-id` and `page-id` from
`workspace*` up to `workspace-page*`, and guard the render with
`(when (uuid? file-id) ...)` so `workspace*` only mounts when `file-id`
is a valid uuid. Since `workspace*` never mounts with a nil `file-id`,
`initialize-workspace` is never emitted with nil, and the 400 is
prevented at the source.
AI-assisted-by: minimax-m3
* 🎉 Add page multi-selection in the workspace sitemap
* ♻️ Simplify page selection state updates with single assoc
* 🐛 Fix SCSS issue
* ♻️ Update some components to new syntax
* ♻️ Adapt SCSS to the new guidelines
* ♻️ SCSS cleanup
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
* ✨ Add list view toggle for dashboard files
* ✨ Add drop files visual feedback
* ♻️ Use radio buttons component from DS
* ♻️ Use hook to keep layout status
* ♻️ Refactor code and SCSS
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
* 🐛 Fix geometry sync between mains and rotated component copies
Rotating a copy instance as a whole marked every shape inside it as
touched for geometry, so later geometric changes in the main (e.g. a
resize) were no longer propagated to that copy, while non-geometric
ones (e.g. fills) still were. And on paths where geometry did get
written to a rotated copy (e.g. resetting overrides), the sync engine
compensated only the roots' position delta, so the written values wiped
the copy's rotation back to 0.
Model the instance root's transformation as inherited, overridable
content, asymmetric to position (which remains free per-instance
placement):
- An untouched copy follows the main's transformation verbatim,
including rotation and flips (preserving the BUG #13267 semantics
that rotating a main propagates to its copies).
- Transforming a copy as a whole overrides only its ROOT: check-delta
compares the root's rotation/flips absolutely, but the descendants
relative to their root, so they merely follow and stay untouched.
- When a copy root's geometry is overridden, update-attrs expresses the
main's geometry in the copy's own frame: reposition-shape applies the
roots' relative transformation (rotation/flips) around the dest root
center in addition to the position delta. Geometric changes from the
main then keep propagating to the rotated copy, landing correctly in
its rotated frame instead of destroying its placement.
Covered by the new composable test case
case-n-geometry-sync-with-rotated-instances: an 8-variant sweep over
optional copy rotation, optional main rotation, and one of a fills or
height edit on the main child, asserting the whole model through the
real workspace events (the new rotate operation dispatches
dwt/increase-rotation, whose apply-modifiers step runs the check-delta
classification under test; change-height dispatches
dwt/update-dimensions and implements IPropertyCheck so one-of sweeps
can mix property and geometry edits). Verified by temporarily reverting
the fix: the case then fails with 6 assertion failures and passes again
with the fix restored.
Fixes#10109
AI-assisted-by: claude-fable-5
* 🐛 Fix synchronization problems
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
Relative operators were accepted in operand position, so "10+*3" silently evaluated to 310 instead of being rejected. Make negation a first-class operand so legitimate negative operands ("10 + -3") keep working.
Fixes#9581
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 📚 Add comprehensive documentation for .penpot file format (v3)
Create user-facing documentation for the .penpot binfile format to help
developers and power users understand and inspect the ZIP+JSON structure.
- Add technical specification with complete schema reference for all JSON
files (manifest, file metadata, pages, shapes, library assets, storage
objects, plugin data)
- Add user-friendly overview explaining the format structure, inspection
methods, and version history
- Update export-import-files.njk to clarify current format is not deprecated
and note that v2 was never released
- Add cross-links between documentation pages
- Include source code references to authoritative malli schemas
AI-assisted-by: qwen3.7-plus
* 📎 Add playwright dependency to the root package.json
* 📚 Add browser based .penpot file inspector to docs
Add a client-side, no-build inspector for .penpot (v3) files. The tool
runs entirely in the browser: JSZip is loaded lazily from a CDN, the
file is never uploaded. It provides a collapsible file tree, syntax-
highlighted JSON viewer with search, image previews, a summary panel
with file/shape/storage counts, shape summary cards with color swatches,
and clickable UUID cross-references with backlinks.
The inspector is added at
docs/technical-guide/developer/data-model/penpot-file-inspector.njk
and linked from the format spec and the user-facing format page.
AI-assisted-by: minimax-m3
* 📚 Use full page width and 2-column layout for inspector
The inspector previously sat inside the docs site's 42rem content
column, which forced a stacked layout and wasted the wide viewport.
- Hide the docs page-navigation sidebar on this page via :has()
- Use a 2-column CSS grid: file tree (280px, sticky) on the left,
content (flex: 1) on the right
- Restore the directory tree as a permanent left-rail navigation
(it was removed when the picker was first introduced, then added
back as a collapsible panel; a sticky rail is a better fit now
that horizontal space is plentiful)
- Remove the tree toggle button — the tree is always visible
- Collapses to 1 column on viewports below 900px
AI-assisted-by: minimax-m3
* 💄 Indent nested JSON values in inspector
The JSON tree view in the inspector was rendering all properties at
the same indent level, making it hard to see which values were
nested inside objects or arrays. Root-level properties and deeply
nested ones looked identical.
Add padding-left and a subtle vertical guide line to .jchildren so
each nesting level is visually distinct. Bump the toggle column to
1.2em and add a touch of vertical padding to .jrow for breathing
room.
AI-assisted-by: minimax-m3
Position-data entries for text shapes could omit the :fills key when the
element had no explicit fills, causing backend malli validation failures.
- Use get-default-text-attrs as base in WASM calculate-position-data
- Default fills when CSS --fills property is absent in DOM calc path
AI-assisted-by: deepseek-v4-pro
Add a `client-setup` command to the published `@penpot/mcp` package.
When invoked as `penpot-mcp client-setup`, the bin delegates to the `add-mcp`
CLI against the local MCP server URL (from `PENPOT_MCP_SERVER_PORT`, default
4401), run via npx.
Update docs on MCP client configuration.
Recommend calling `add-mcp` directly for now, since the `client-setup` command
only becomes available once a new `@penpot/mcp` release is published to npm.
AI-assisted-by: Claude
Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
* 📎 Add postgresql client tool wrapper for devenv
* ♻️ Replace uuid-ossp defaults with gen_random_uuid() and add missing :id on insert
- Switch all DEFAULT uuid_generate_v4() to gen_random_uuid()
(built-in PG 13+, no extension required)
- Add explicit :id (uuid/next) to 4 db/insert! calls that were
relying on the DB default (team-profile-rel, project-profile-rel,
team-project-profile-rel)
- Drop uuid-ossp extension (no longer needed)
- Add missing uuid require to projects.clj and srepl/binfile.clj
AI-assisted-by: deepseek-v4-flash
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
When ImageMagick fails to process an uploaded image (e.g., corrupted PNG
with invalid IHDR data), the backend was raising :type :internal with
:code :imagemagick-error, which mapped to HTTP 500. The frontend treated
this as a server error and displayed the full error page.
Changed exec-magick! to raise :type :validation with :code :invalid-image
instead. This flows through the existing :invalid-image handler in
errors.clj which returns HTTP 400. The frontend's handle-media-error and
process-error now catch this code and show a notification banner.
AI-assisted-by: qwen3.7-plus
* 🐛 Demote unable-to-retrieve-user-info OIDC error to warning level
401 responses from the OIDC userinfo endpoint (e.g. expired/revoked GitHub
token) are normal auth failures, not server errors. Logging at :error level
triggers the database and Mattermost error reporters unnecessarily.
AI-assisted-by: deepseek-v4-flash
* ✨ Add pure function tests for OIDC auth module
Add tests for: int-in-range?, valid-info?, qualify-prop-key, qualify-props,
provider-has-email-verified?, profile-has-provider-props?, redirect-response,
redirect-with-error, redirect-to-verify-token, and build-redirect-uri.
AI-assisted-by: deepseek-v4-flash
* ✨ Add HTTP-mock tests for fetch-user-info and fetch-access-token
Replace with-redefs with binding (cf/config is ^:dynamic).
Add tests for: fetch-user-info (success, 401, 500, request structure),
fetch-access-token (success, 400 error).
AI-assisted-by: deepseek-v4-flash
* ✨ Add get-info integration tests with partial mocking
Test all branches: token/userinfo/auto info sources, incomplete info,
role checks (satisfied and insufficient), state props merge,
sso-session-id from claims, and sso-provider-id for uuid providers.
AI-assisted-by: deepseek-v4-flash
* ✨ Add callback-handler integration tests with real tokens and session
Tests all main branches: error param, no profile (registration disabled),
profile blocked, provider mismatch, inactive profile, success flow,
and graceful handling of unable-to-retrieve-user-info exception.
Uses real tokens/generate, tokens/verify, and session/inmemory-manager.
AI-assisted-by: deepseek-v4-flash
pnpm occasionally detects an incompatible node_modules directory (e.g.
after a store location or pnpm major version change) and interactively
asks whether to remove and recreate it, blocking the MCP bootstrap in
the devenv tmux pane. Set confirmModulesPurge: false in
mcp/pnpm-workspace.yaml so the purge is auto-confirmed; this file is
included in the npm pack tarball (unlike .npmrc) and applies to all
install invocations from a single place.
AI-assisted-by: claude-fable-5
- Remove conditional build from test scripts (frontend, common)
- Remove test:jvm from common package.json (JVM tests via clojure directly)
- Remove test from backend package.json (JVM tests via clojure directly)
- Unify common/scripts/test-quiet.js with frontend's BUILD_STEPS pattern
- Add execution discipline section to mem:testing (no piping, tee to file)
- Add READ mem:testing FIRST directives to module testing docs
AI-assisted-by: deepseek-v4-flash
The notify steps referenced mattermost/action-mattermost-notify@master, a
mutable branch that runs in CI with access to the MATTERMOST_WEBHOOK_URL
secret. Pinning to the immutable commit of the latest release (v2.1.0,
ae31bb6) keeps the exact reviewed code from executing, per GitHub third-party
action hardening guidance, while staying easy to bump.
The webhook creation form used a hardcoded AWS instance metadata
endpoint (http://169.254.169.254/...) as the default :uri value for
new webhooks. This leaked an internal cloud credential endpoint into
the UI defaults and could expose it to users.
Remove the default :uri so new webhooks start with an empty URI
instead of a sensitive hardcoded value.
Event targets can be DOM text nodes (nodeType 3) which lack the
.closest() method. Add a get-element helper that normalizes text
nodes to their parent Element before calling .closest(), matching
the existing pattern in dom/get-parent-with-data.
Fixes#10640
AI-assisted-by: mimo-v2.5-pro
* ✨ Add systematic component tests via a composable test model
Introduce a framework for systematically testing Penpot component behaviour
(synchronisation/propagation, swaps, variant switches, nesting), plus a first
suite of cases built on it.
A test is expressed as a COMPOSITION OF OPERATIONS over a "situation" (an
in-memory file value plus named role bindings). Operations are reified as data
and composed by two combinators — `in-sequence` (threads the situation) and
`one-of`/`optional` (alternatives, enumerated into concrete variants). So one
written case stands for a whole matrix of variants, and coverage grows by
composition rather than by copying tests. Operations drive the REAL production
change pipeline, and event-operations dispatch the REAL workspace events and
await settlement, so the production watcher's automatic propagation is what is
exercised — the tests reflect genuine app behaviour, not a reimplementation.
Structure (frontend/test/frontend_tests/composable_tests/):
- core — the domain-agnostic engine: situation, the operation and
enumeration protocols, the combinators, and the runners.
- comp/nodes — the component operations (create/instantiate/reset, nesting,
swap, the variant ops, child add/remove/move, change, undo,
library sync).
- comp/setups — component-shaped starting configurations.
- interpreter — runs a case against the real frontend store: sync-ops apply
directly, event-ops dispatch real events and await
settlement (absorbing sync-file's delayed status RPC, which
would otherwise leak an error into subsequent tests).
- comp/sync-test — the cases (B-F, H, I, K, L, M).
This is test-only code with a single consumer — the frontend test suite (the
layer that runs the real app) — so it lives entirely under the frontend test
tree as .cljs, not under app/common.
The framework and its cases are documented in the project memory
frontend/composable-component-tests, added alongside.
Co-authored-by: Claude <noreply@anthropic.com>
* 🐛 Guard WASM mock teardown against an empty snapshot
`teardown-wasm-mocks!` unconditionally restored from the `originals` atom.
When run without a matching setup (double teardown, or `with-wasm-mocks*`
misused around an async test body), the snapshot is empty and every WASM API
function was `set!` to nil — permanently, for the remainder of the test run.
Any later code calling one of them (e.g. a leaked debounced resize-wasm-text
event firing during a subsequent test namespace) then crashed with
"initialized? is not a function".
Make the restore a no-op when there is nothing to restore.
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
The dashboard profile menu submenus (Help & Learning, Community &
Contributions, About Penpot) opened on pointer enter but nothing closed
them when the pointer left the option, leaving a stale submenu visible
until the whole menu closed.
Close the open submenu when the pointer leaves an expandable option or
its submenu, with a 200ms grace period (same approach as the workspace
context menu) so the submenu survives the pointer crossing the gap
between the parent menu and the floating submenu. Keyboard navigation
is unchanged and now covered by tests.
Closes#10549
AI-assisted-by: claude-fable-5
Signed-off-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Add PENPOT_INTERNAL_URI environment variable to the exporter. This allows
separating the URI used for internal communication (headless browser to
frontend) from the public URI used for resource references in exported SVGs.
Previously, PENPOT_PUBLIC_URI served both purposes, which caused exported
SVGs to contain broken font URLs when the internal Docker address was used.
Changes:
- Add :internal-uri to exporter config schema with fallback to :public-uri
- Add get-internal-uri helper function
- Use internal-uri for browser navigation in SVG/PDF/bitmap renderers
- Post-process SVG output to replace internal URI with public URI
- Use internal-uri for backend API calls in resource handler
- Log both URIs on startup
- Update docker-compose.yaml to use both variables
- Document the new variable in configuration.md
Closes#10627
AI-assisted-by: mimo-v2.5-pro
* 🐛 Fix too much recursion when clicking shape in comments mode
Remove `deselect-all` from `handle-interrupt` in comments mode. The
`select-shape` event emits `:interrupt` which the comments stream
watcher routes to `handle-interrupt`. In comments mode, calling
`deselect-all` cleared the selection and emitted a competing
`rt/nav`, creating a synchronous recursion cycle in the potok store
that overflowed the JS call stack.
Fixes#10620
AI-assisted-by: opencode
* 🐛 Add unit tests for comments handle-interrupt
Make `handle-interrupt` public (defn- → defn) and add 4 tests covering
each branch: draft thread, open thread, comments mode, and noop.
AI-assisted-by: opencode
Guard remaining text pipeline locations that accessed :selrect directly
against nil/zero-dimension selrects by using safe-size-rect, which
provides a 4-level fallback chain (selrect -> points -> shape fields ->
empty 0.01x0.01 rect).
Fixes:
- fix-position in viewport_texts_html.cljs: replaced dm/get-prop
:selrect with ctm/safe-size-rect for both old and new shape
- assoc-position-data in modifiers.cljs: replaced (:selrect ...)
with ctm/safe-size-rect for delta computation
- change-orientation-modifiers in modifiers.cljc: replaced raw
:selrect access with safe-size-rect for scale and origin computation
Closes#10617
AI-assisted-by: mimo-v2.5-pro
Use `get` instead of `nth` to avoid index-out-of-bounds when a selected
component copy has fewer variant properties than the first component in
the selection.
Closes#10615
AI-assisted-by: deepseek-v4-pro
Guard the content-to-PathData coercion on whether
stp/convert-to-path actually produced a new value, so
SVG-raw shapes (whose :content is a hiccup map) pass through
unchanged instead of crashing.
Closes#10612
AI-assisted-by: deepseek-v4-pro
Clamp selection offset to node length in setDraftEditorSelection to
prevent DOMException when Draft.js SelectionState offset exceeds the
actual DOM text node length. This can happen during spellcheck, IME
composition, or race conditions during React re-renders.
Updates @penpot/draft-js to commit 09a33e0a which includes the fix
in addPointToSelection and addFocusToSelection.
Fixes#10607
AI-assisted-by: mimo-v2.5-pro
* Fix stale references to `run-devenv-agentic` and `start-devenv`
* Improve clarity in description of workspaces
* Improve section on agentic devenv
Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
For the npm MCP package, adjust the launcher to specifically prepare
the runtime directory: The package contents are copied once per version
to a runtime directory owned by the launcher.
The npm-owned package directory cannot be used. While installing there
worked for older npm/npx version, we have to assume it is read-only.
Fixes#9947
get-teams-detail, get-org-invitations and delete-org-invitations lacked
::rpc/auth false, so wrap-nitrate-sso ran on them whenever params carried
an organization-id. For SSO-active orgs this rejected the org owner's
admin-console reads with a 401, since their Penpot session has no SSO
grant for the org. These are shared-key protected management calls made
on the org owner's behalf and never use profile-id, so they should not
require an end-user SSO session — matching their sibling endpoints.
The InteractionProxy `delay` setter rejected `0` via `(not (pos? value))`,
so an immediate after-delay interaction (`interaction.delay = 0`) was
refused. The model allows it: `set-delay`
(common/.../shape/interactions.cljc) only asserts `check-safe-int`, with
no positivity constraint, and `safe-int` permits 0.
Replace the guard with `(or (not (sm/valid-safe-int? value)) (neg? value))`:
it allows 0 and positive integers, rejects negatives, and rejects
non-integers cleanly (the previous `number?` check let fractional values
through, which then failed the model's `check-safe-int` assertion
downstream).
Adds a regression test (delay round-trips a positive value, then 0).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
The ShapeProxy `borderRadius` setters (`borderRadius` and the four
per-corner variants) validated with `sm/valid-safe-int?`, so a fractional
radius (e.g. `shape.borderRadius = 7.5`) was rejected as invalid. The data
model types `:r1`-`:r4` as `::sm/safe-number` (shape.cljc), the radius
sidebar input only constrains `min 0` (not integer), and `set-radius-*`
store the value verbatim — so the plugin API was stricter than the model.
Same class of defect as the merged #9780.
Switch those 5 guards to `sm/valid-safe-number?`, keeping the existing
non-negative guard on the all-corners setter. Integer-only setters in the
file (`getRange` bounds, `setParentIndex`) keep `valid-safe-int?`.
Adds a regression test asserting fractional radius values are accepted
(with throwValidationErrors enabled).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
The flex and grid layout proxies validated `rowGap`, `columnGap` and the
four padding setters with `sm/valid-safe-int?`, so a fractional value
(e.g. `flex.rowGap = 10.5`) was rejected as invalid. The data model types
`:row-gap`/`:column-gap` and `:p1`-`:p4` as `::sm/safe-number`
(layout.cljc), and the sidebar accepts decimals, so the plugin API was
stricter than the model — the same class of defect as the merged #9780.
Switch those 16 gap/padding guards (8 in flex.cljs, 8 in grid.cljs) to
`sm/valid-safe-number?`, matching the model and the predicate already used
by the flex-element setters in the same file. Integer-only setters
(`zIndex`, grid track indices/counts and cell positions/spans) keep
`valid-safe-int?`. Also fixes a `:righPadding` typo in two grid
rightPadding error branches.
Adds a regression test asserting fractional gap/padding values are
accepted (with throwValidationErrors enabled) for both flex and grid.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Setting or clearing `board.guides` from a plugin threw a malli
`:malli.core/invalid-schema` for every value, including `[]`. Causes:
- `shape.cljs` validated against `[:vector ::ctg/grid]`, but the
`:app.common.types.grid/grid` reference is no longer registered
(direct schemas replaced the namespaced-keyword registrations). Use
the direct var `ctg/schema:grid`, matching every other setter.
- `parser.cljs` `parse-frame-guide` returned the `parse-frame-guide-column`
/ `parse-frame-guide-row` fns instead of calling them with the guide,
so column/row guides parsed to a vector containing a function.
- `parse-frame-guide-square` used `parse-frame-guide-column-params`
instead of the dedicated `parse-frame-guide-square-params`.
Adds a regression test parsing column/square guides and validating the
result (and an empty vector) against `ctg/schema:grid`.
Fixes#9773
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
The plugin proxy `:on-error` handler funneled every throwable through
`handle-error`, which only produced a message for malli ExceptionInfo
carrying `::sm/explain`. Two paths lost the real cause and rendered the
unhelpful "[PENPOT PLUGIN] Value not valid. Code: :error":
- A plain JS error (TypeError, etc.) is not an ExceptionInfo, so
`(ex-data cause)` is nil and the message bound to nil; the real
`.-message` only reached the host-page console, invisible to the
plugin sandbox.
- A malli explain whose errors flatten to nothing made `error-messages`
return "", which rendered as "Value not valid: . Code: :error".
`error-messages` now returns nil (not "") when there is nothing to
render, and `handle-error` falls back to the raw explain, then to the
throwable's `ex-message`/`str`, so a useful message always surfaces.
Working cases (well-formed explain) are unchanged.
Adds regression tests for the plain-JS-error path, the empty-explain
path, and the `error-messages` nil contract.
Fixes#9692
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* 🐛 Fix dropdown shown Mixed Font Families for same family with different variant
* 🐛 Fix variants dropdown appearing blank on mixed variants but same family
* ✨ Add playwright test for mixed font families/variants
* ✨ Adds static dispatch safe stubs in tests
* 🐛 Fix shapesColors metadata key to match ColorShapeInfo
* 🐛 Fix CommentThread.remove rejecting the owner's own threads
* 🐛 Fix page.removeCommentThread throwing on a spurious Promise
* ✨ Implement ShapeBase.swapComponent in the plugin API
* ✨ Expose File.revn in the plugin API
* 🐛 Fix FileVersion.createdAt calling Luxon method on a js/Date
* 🐛 Fix plugin font/typography application to text and ranges
* 🐛 Default plugin overlay interaction position for non-manual types
* 🐛 Fix plugin interaction setters passing an id-only shape
* 🐛 Fix grid addColumnAtIndex rejecting valid track types
* 🐛 Expose libraryId on library color/typography/component proxies
* ✨ Implement LibraryTypography.setFont in the plugin API
* 🐛 Fix typography.applyToTextRange reading unexposed range bounds
* 🐛 Fix utils.geometry.center argument mismatch
* 🐛 Fix localStorage.removeItem calling getItem
* 🐛 Fix shape backgroundBlur proxy key casing
* 🐛 Report boolean shape type as 'boolean' in the plugin API
* 🐛 Return the resulting paths from plugin flatten
* 🐛 Make plugin z-order methods act on the target shape
* 🐛 Make is-variant-container? return a boolean
* ✨ Implement Group.isMask in the plugin API
* 🐛 Return a shape proxy from TextRange.shape
* 🐛 Return the duplicated set from TokenSet.duplicate
* 🐛 Fix theme addSet/removeSet reading set name with a keyword
* 🐛 Accept string fontFamilies token value in the plugin API
* 🐛 Fix combineAsVariants ignoring the passed component ids
* 🐛 Fix board removeRulerGuide ignoring its argument
* 🐛 Fix board guides setter schema and parser
* 🐛 Avoid 0-byte allocation when syncing empty grid tracks
* 🐛 Validate grid track indices in the plugin API
* 🐛 Return null for empty input in group() and centerShapes()
* 🐛 Return TokenTypographyValue[] from a typography token's resolvedValue
* 🐛 Return TokenShadowValue[] from a shadow token's resolvedValue
* 🐛 Return string[] from a fontFamilies token's resolvedValue
* 🐛 Clear mutually-exclusive reps when setting LibraryColor gradient/image
* 🐛 Add readonly tags to types, deprecate Image type
* 📚 Update plugins changelog
* 🐛 Fix playwright wasm test for updating canvas background
* 🐛 Fix playwright wasm test for rendering blurs
* 🐛 Fix invisible emoji texts in render-wasm playwright data
* 🐛 Fix stroke to path extra points
* 🐛 Set evenodd when needed on stroke to path (#10446)
---------
Co-authored-by: Elena Torró <elenatorro@gmail.com>
* ✨ Add dedicated Line and Arrow drawing tools
Introduce a Line/Arrow toolbar option and a click-drag drawing
interaction that matches Figma's workflow: select the tool, press and
drag to define the line in one gesture, with Shift snapping to 15°
increments. Arrowhead style can be toggled on either endpoint via the
existing stroke-cap controls.
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* 💄 Fix formatting error
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* 🐛 Translate line and arrow tooltips in top toolbar
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* 🐛 Add missing namespace
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* 📚 Update copyright notice
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* Add translations (EN) for toolbar elements
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* Add translations (ES) for toolbar elements
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* ♻️ Improve stroke-cap-end update for arrow handling
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* 🐛 Fix shortcuts select tool but do not replace it in the toolbar
Refactor tool selection logic in top_toolbar.cljs
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
* ♻️ Remove unnecessary blank line
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
---------
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Render the organizations selector dropdown in a portal anchored to the
trigger button, so a long list is no longer clipped by the
sidebar-content-wrapper overflow.
The plugin text API rejected negative letter-spacing even though the
product UI allows -200..200 (typography.cljs). Two defects in
frontend/src/app/plugins/text.cljs:
- `letter-spacing-re` (`#"^\d*\.?\d*$"`) had no provision for a leading
minus, so any negative value failed validation.
- The text-range `:letterSpacing` setter inverted its guard: it used
`(or (empty? value) (re-matches ...))` to mean "invalid", which
rejected matching values and let non-numeric input through. The
text-shape setter and the sibling `lineHeight` range setter both
correctly use `(not (re-matches ...))`.
Fix the regex to allow an optional leading minus and add the missing
`not` so the range setter matches the shape setter. Adds regression
coverage for the regex accept/reject contract.
Fixes#9780
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* ✨ Add font processing resource limits via prlimit
Font processing tools (fontforge, sfnt2woff, woff2sfnt, woff2_decompress)
were invoked via clojure.java.shell/sh with no timeouts or resource limits.
This adds process-level resource limits using prlimit(1) and the shell/exec!
infrastructure from the ImageMagick hardening work.
shell/exec! changes:
- Add :prlimit parameter that prepends prlimit(1) to the command
- :prlimit takes {:mem <MiB> :cpu <seconds>} for address space and CPU time
limits, enforced by the kernel's RLIMIT subsystem
- prlimit-cmd builds the prlimit command prefix (private helper)
Font processing changes:
- Replace all clojure.java.shell/sh calls with shell/exec! via exec-font!
- exec-font! applies font-prlimit (512 MiB, 30s CPU, 60s wall-clock)
- All 5 conversion functions (ttf->otf, otf->ttf, ttf-or-otf->woff,
woff->sfnt, woff2->sfnt) use try/finally for explicit temp file cleanup
- Remove clojure.java.shell require from media.clj
Tests:
- Add exec-prlimit-normal, exec-prlimit-cpu, exec-prlimit-memory tests
Closes#10234
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ✨ Make font processing resource limits configurable
Replace hardcoded font-prlimit map and wall-clock timeout with
config-driven values under the PENPOT_FONT_PROCESS_* namespace.
The prlimit implementation detail is not exposed in config keys.
Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>
---------
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
Co-authored-by: deepseek-v4-flash <deepseek-v4-flash@penpot.app>
The tooltip messages for 'not-active' and 'has-errors' conditions
were swapped in both the typography row and color row components.
When a token is deleted (not-active), the tooltip should show the
'deleted-token' message, and when a referenced token has errors
(has-errors), it should show the 'not-active-token' message.
Fixes#10296Fixes#10299
Signed-off-by: 0xRapzz <oxrapzz@rapzzclip.win>
Co-authored-by: 0xRapzz <oxrapzz@proton.me>
* ♻️ Modify social media icons in verification email
* ♻️ Update verify email
* ♻️ Update copies in 'check your email'
* ♻️ Update onboarding images
* ♻️ Refurbish create team slide
* ♻️ Refactor SCSS for in-app onboarding
* 🐛 Fix replace old uxbox with penpot image for all email HTMLs
* 🐛 Fix use of link component
* ⬆️ Updgrade base image for penpot docker images to ubuntu 26.04
* ⬆️ Update playwright
* 🐳 Use dist-upgrade to update all system packages
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐳 Add ImageMagick policy.xml resource limits to backend Docker image
Add a restrictive policy.xml to the backend Docker image that caps
ImageMagick resource usage: 256MiB memory, 512MiB map, 128MP area,
30s time limit, 16KP max dimensions. Blocks PS/EPS/PDF/XPS coders
to prevent Ghostscript attack surface.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ✨ Add timeout support to shell/exec!
Add optional :timeout parameter (in seconds) that uses
Process.waitFor(long, TimeUnit). On timeout, the process is
destroyed forcibly and an :internal/:process-timeout exception
is raised. Stdout/stderr readers handle IOException from closed
streams when the process is killed.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ♻️ Rename ::wrk/netty-executor to ::wrk/executor with cached pool
Replace DefaultEventExecutorGroup (fixed Netty thread pool) with a
cached thread pool (px/cached-executor) for general async task
offloading. The cached pool creates threads on demand and reuses
idle ones, which is more appropriate for blocking I/O workloads
(shell commands, message bus, rate limiting, etc.).
Changes:
- Rename ::wrk/netty-executor to ::wrk/executor in worker/executor.clj
- Switch implementation from DefaultEventExecutorGroup to px/cached-executor
- Update all ig/ref wiring in main.clj (msgbus, tmp cleaner, climit, rlimit, rpc)
- Remove ::wrk/netty-executor from redis.clj (let lettuce create its own
eventExecutorGroup instead of sharing a Netty executor)
- Assert executor is present in shell/exec! to prevent silent nil usage
- Remove executor-threads config (no longer needed for cached pool)
The ::wrk/netty-io-executor (NioEventLoopGroup) remains unchanged as it
handles actual non-blocking network I/O for Redis and S3.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove im4java dependency and replace with direct ImageMagick CLI calls
- Replace im4java Java library with direct 'magick' CLI calls via shell/exec!
- Add PENPOT_IMAGEMAGICK_* config env vars for resource limits (thread, memory, map, area, disk, time, width, height)
- Use configurable ImageMagick environment with sensible defaults matching policy.xml
- Remove -Dim4java.useV7=true JVM flag from startup scripts
- Remove org.im4java/im4java from deps.edn
- All ImageMagick commands now use shell/exec! with 60s timeout and resource limits
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 💄 Rename imagemagick env functions and optimize config reads
- Rename imagemagick-defaults -> imagemagick-default-env
- Rename imagemagick-env -> get-imagemagick-env
- Optimize to avoid double cf/get calls per config key
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ✨ Add tests for shell/exec! timeout and media processing
- Add shell_test.clj: tests for exec! timeout, env vars, stdin, stderr
- Add media_test.clj: tests for info, generic-thumbnail, profile-thumbnail
- Fix generic-process to prefer explicit format over input mtype
- Fix shell/exec! to use cached executor when system has no executor
- Fix reduce-kv accumulator in set-env (must return penv)
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ♻️ Refactor media/process to take system as first argument
- Change (defmulti process :cmd) -> (defmulti process (fn [_system params] (:cmd params)))
- Change (run params) -> (run system params)
- All process methods now receive [system params]
- Update all callers: rpc/commands/media, profile, auth, fonts
- Revert shell/exec! to require system with executor (no fallback)
- Fix lint warnings and formatting
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove unused app.svgo namespace
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove Node.js from backend Docker image
- Delete unused svgo-cli.js script
- Remove Node.js installation from Dockerfile.backend
- Remove svgo-cli.js copy from backend build script
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove unused process-error multimethod
- Remove process-error multimethod and its default handler
- Simplify media/run to directly call process
- Fix alignment in main.clj
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 📚 Add ImageMagick resource limits configuration to technical guide
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
---------
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
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>
- Convert .eleventy.js to eleventy.config.mjs (ESM) since
@11ty/eleventy-plugin-rss@3.0.0 is ESM-only
- Replace search-index.json.njk with search-index.json.11ty.js
to avoid async templateContent access in Nunjucks filters
- Update feed.njk to use new RSS plugin v3 filter names:
rssLastUpdatedDate -> getNewestCollectionItemDate | dateToRfc3339
rssDate -> dateToRfc3339
- Add 11ty.js to templateFormats for search index generation
* 🎉 Add background blur
* 🎉 Add test
* 🎉 Add background blur info to plugins API
* 🎉 Suport in wasm for both layer and background blur
* 🐛 Fix failing test
* ♻️ Fix comments
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
When an organization invitation token is verified by a logged-out recipient
(e.g. an unregistered invitee opening the emailed link), profile-id is nil.
The team-invitation branch still evaluated get-org-membership eagerly, calling
nitrate with that nil profile-id. That request fails and surfaces as a generic
error, masking the clean :invalid-token response and dropping the user on the
login screen instead of the dedicated "Invite invalid" page.
Only query membership when a logged-in profile is present, so a canceled or
otherwise invalid org invite reaches the :invalid-token path as intended.
Use direct backbuffer tile compositing for render_sync_shape (viewer/thumbnails)
instead of the tile texture atlas cache, which left 512×512 transparent holes.
* ✨ Remove guides from svg overlay
* 🎉 Draw guides in wasm
* 🎉 Serialize guides to wasm
* ✨ Store separate and sorted horizontal and vertical guides
* 🎉 Implement collision detection with guides
* 🎉 Right click on guides to change color or remove
* ✨ Implement dragging guides
* 🎉 Edit wasm guides by double clicking them
* 🎉 Implement changing mouse cursor on hovering a guide
* ✨ Show guide pill on hover
* 🎉 Implement removing guide on hovering + Del
* 🔧 Fix lint + fmt errors
* 🎉 Clip out outer board guide lines
* ♻️ Extract common code into guide-pill* component
* 🎉 Draw dotted lines on hovering board guides
* 🐛 Fix board rotation when it has guides
* 🎉 Make foreign guides not visible in focus mode
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>
Add the repository license SPDX identifier to the published @penpot/mcp package metadata.
Signed-off-by: martrend33 <145278472+martrend33@users.noreply.github.com>
Update SECURITY.md file to request that vulnerabilities be reported through the GitHub Security Advisories feature in the Penpot repository
Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
Preserve real size overrides during variant switches without copying stale absolute composite geometry from the source variant.
Signed-off-by: Codex <codex@openai.com>
* 📎 Ignore .iml files (IntelliJ module files)
* 🎉 Enable multi-instance horizontal scaling for MCP server
Allow the MCP server to run as multiple instances behind a plain
round-robin load balancer, removing the previous requirement that a
user's plugin WebSocket and MCP client connection terminate on the same
instance. Behaviour is unchanged when run as a single instance or
without Redis.
Cross-instance MCP sessions: when a request arrives with an
mcp-session-id that was initialised on another instance, the session is
adopted locally instead of rejected. The user token is read from the
query parameter (present on every request, as the configured endpoint
URL is never rewritten), so no shared session store is needed; the
transport is pre-initialised so the SDK's validateSession() accepts it.
Cross-instance task routing: when a Redis URI is configured in
multi-user mode, plugin task requests are routed via Redis pub/sub keyed
by user token. The instance holding a plugin's WebSocket subscribes to
that token's request channel; any instance handling a tool call
publishes the request and awaits the response on a per-request channel.
RedisBridge is a pure transport for the existing serialised
PluginTaskRequest/Response objects. PluginTask is split into an abstract
base plus a local (promise-backed) PluginTask and a RemotePluginTask
whose resolve/reject publish the outcome back over Redis, so the
existing local dispatch and response-correlation paths are reused
unchanged on the executing instance.
Refs #10000
Expose the user's `:lang` profile field alongside `:theme` from the
internal nitrate `authenticate` RPC so the Nitrate admin console can
load translations matching the user's Penpot language preference.
* 🐳 Split devenv compose for parallel workspaces
Move shared services into an infra compose file and keep the main devenv container plus Valkey in a separate compose file driven by defaults.env. Parameterize host-side ports, container names, source path, and runtime env while keeping container-internal ports fixed for same-origin proxying.
Make tmux startup idempotent, add attach-devenv for the live instance, move shared MinIO user setup to infra startup, and let exporter scripts load backend _env.local overrides.
Co-authored-by: Codex <codex@openai.com>
* 🐳 Run parallel devenv instances against shared infra
Add support for running N parallel devenv instances under separate compose
projects sharing Postgres, MinIO, mailer, and LDAP. Each instance has its
own main container, Valkey, source checkout, tmux session, and host port
range offset by 10000 (3449 -> 13449 -> 23449, etc.).
./manage.sh run-devenv-agentic --n-instances N reconciles the running set
to exactly {ws0..ws(N-1)}: missing instances are created (workspace sync
from the live repo via git ls-files + per-instance env-file generation
under docker/devenv/instances/ + detached tmux startup), surplus instances
are stopped highest-first via compose down (never -v), already-running
instances are left untouched. ws0 binds the live repo at PWD; ws1+ are
scratch clones under ~/.penpot/penpot_workspaces/.
Backend workers (enable-backend-worker) are gated on PENPOT_BACKEND_WORKER
in backend/scripts/_env; ws1+ overlays disable them so async-task
notifications stay bound to a single Valkey Pub/Sub instance.
Compose helpers wrap docker compose with env -i so per-instance overlay
--env-file actually overrides defaults.env -- without the strip, the shell
env from sourcing defaults.env at startup would shadow the overlay (Compose
gives shell precedence over --env-file).
Other:
- Drop network aliases (- main, - redis); use container_name for
cross-container DNS so multiple instances on the shared network don't
fight over the same DNS name.
- Pin volume names via name: (PENPOT_*_VOLUME) so volumes survive project
renames; ws0 keeps the pre-existing physical names (penpotdev_*).
- Remove cross-project depends_on from main.yml (postgres/minio-setup now
live in penpotdev-infra); manage.sh ensure-infra-up docker-waits on the
minio-setup one-shot.
- Strict arg parsing in run-devenv / run-devenv-agentic; --n-instances 0
rejected.
- Remove unused Host-matched server block from the Caddyfile.
Memory mem:devenv/core and developer docs updated.
Co-authored-by: Codex <codex@openai.com>
* ✨ Document and stabilise the parallel-workspace CLI; wire AI agents
Improve parallel-workspaces developer CLI,
and add an opt-in layer that lets four AI
coding agents (Claude Code, opencode, VS Code Copilot, OpenAI Codex CLI)
drive a specific workspace through a single launcher command.
Parallel-workspace semantics
----------------------------
each run-devenv-agentic call brings up one wsN;
--ws N (integer; default 0) targets a specific workspace and auto-starts
ws0 first when N>=1 so the worker invariant holds. --sync is forbidden on
ws0 and re-seeds the workspace from the live repo for ws1+. Stop semantics
mirror the start invariant -- ws0 is the last to stop, shared infra stops
with it, --all walks every instance highest-first. The worker policy
section explains why workers run only on ws0 (Postgres FOR UPDATE
SKIP LOCKED is safe across many workers but the cron dedup primitive is
best-effort, and :telemetry / :audit-log-archive are not idempotent).
Per-instance Valkey Pub/Sub isolation, msgbus topology, and the
"async task notifications miss ws1+ tabs" caveat are stated explicitly.
The mem:prod-infra/core memory captures the same external-services and
task-queue / Pub-Sub topology in agent-readable form, and
mem:backend/core and mem:critical-info now cross-link it so backend work
surfaces the horizontal-scaling constraints from the start.
AI coding agent integration
---------------------------
New top-level .devenv/ directory holds committed templates
(templates/{claude-code,opencode,vscode}.json and templates/codex.toml,
each with \${PENPOT_MCP_PORT} and \${SERENA_MCP_PORT} placeholders) plus
committed shared entries (matching shared/* files for Playwright, the
only workspace-independent server we ship today).
./manage.sh start-coding-agent <claude|opencode|vscode|codex> [--ws N]
launches the chosen client against one workspace. It cd's into the
target's directory (the live repo for ws0; workspace-path "wsN" for ws1+)
and refuses to launch unless (a) the binary is on PATH, (b) the
workspace directory exists for ws1+, and (c) the instance is up
(devenv-main-running) -- the MCP servers only exist while the devenv is
running. The agentic-devenv guide is restructured around this Quick
start path, with a per-client table and a Manual configuration fallback
for clients we don't cover.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ♻️ Scope the shadow devtools to the dev build
---------
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "🐛 Detect duplicated token names in the whole library (#9034)"
This reverts commit 61cd7573553b1c5e9fc2d7300cf9b2c36b4dcbb6.
* 🔧 Preserve some enhancements and fixes that are still valid
* 🔧 Fix broken integration tests
Rename the icon file and fix the icon ID from "elipse" (single "l")
to "ellipse" (double "l") across the codebase.
The root cause was a mismatch: the icon file/ID was named "elipse"
but get-shape-icon returns "ellipse" for circle shapes. Since the
icon* component validates icon-id against the auto-generated
icon-list set, the string "ellipse" failed validation.
Changes:
- Rename frontend/resources/images/icons/elipse.svg to ellipse.svg
- Fix icon def in icon.cljs: ^:icon-id elipse -> ^:icon-id ellipse
- Fix deprecated icon in icons.cljs: ^:icon elipse -> ^:icon ellipse
- Fix usage in top_toolbar.cljs: deprecated-icon/elipse -> ellipse
- Fix usage in history.cljs: deprecated-icon/elipse -> ellipse
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
The S3 storage backend uses DefaultCredentialsProvider which includes
WebIdentityTokenFileCredentialsProvider in its chain. However, that
provider requires software.amazon.awssdk/sts on the classpath to call
AssumeRoleWithWebIdentity. Without it, the provider silently fails and
credentials cannot be resolved when using IRSA on EKS.
Closes#9927
Signed-off-by: Joshua C <joshua.cullum@gmail.com>
Co-authored-by: Joshua C <joshua.c@data-edge.co.uk>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
`cls/show-in-viewer` unconditionally dissoc'ed `:hide-in-viewer` on the
interaction destination, so every `add-interaction`, `add-new-interaction`,
and `update-interaction` call silently re-enabled the destination's
view-mode visibility — even when the user had just deliberately hidden
that frame. Reporter (#9049) hid a board, dragged a prototype arrow at
it, and watched the board reappear in View Mode.
Make `show-in-viewer` a no-op when the destination already has
`:hide-in-viewer true`. The auto-unhide still fires on destinations with
no explicit hide flag (the original ergonomic — new prototype targets
default to visible), but explicit user intent is now preserved across
interaction-add / interaction-update.
Behaviour change: dropping the auto-unhide on explicitly-hidden
destinations matches the reporter's expectation ("nothing would show up
in View Mode unless explicitly marked as such") and the surrounding
`:hide-in-viewer`-aware UI in `measures.cljs`, which already lets users
toggle the same property directly.
Closes#9049.
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Fixes#9135.
The <link href="css/ui.css"> tag in
frontend/resources/templates/index.mustache references a CSS file that
the build pipeline never produces:
- compileStyles() in frontend/scripts/_helpers.js only writes main.css
(always) and debug.css (dev-only) — there is no write to ui.css
- compileStorybookStyles() writes ds.css (design system), not ui.css
- No ui.scss source exists anywhere in frontend/resources/styles/
The reference was added in 45d04942c ("✨ Add example ui
storybook") but no corresponding build step was added to emit the file.
Result: every page load issues a request for /css/ui.css that nginx
returns as 404. In self-hosted Penpot deployments behind a reverse
proxy, the SPA's CSS init promise rejects on the 404, the React root
never mounts, and the user sees a black screen.
This patch removes the dead reference. If a future change actually
emits ui.css (or another distinct UI bundle), the <link> can be
re-added at that time.
Co-authored-by: Admin <admin@Admins-MacBook-Pro.local>
The version preview banner in `enter-preview` derives its title from
`(:label snapshot)` directly. For system-created autosaves that
field is the internal snapshot label (e.g. `internal/snapshot/20`),
so the banner shows the raw internal string while the History sidebar
already renders the same autosave through `workspace.versions.autosaved.version`
plus a localized date. The mismatch makes it hard to be sure which
sidebar entry you're previewing, especially when several autosaves
sit close together (#9503).
Switch the label resolution to mirror the sidebar's `snapshot-entry*`:
- `:created-by "system"` snapshots format the label as
`(tr "workspace.versions.autosaved.version" (ct/format-inst ...
:localized-date))` — the exact same translation key + date format
the sidebar's autosave group already uses
- `:created-by "user"` (pinned) versions keep their custom `:label`
with the existing `unnamed` fallback
No behavior change for pinned/user-named versions or for the
restore/exit dialog buttons.
Closes#9503.
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix DTCG token import discriminator and group-level $type inheritance
Closes#8342.
The DTCG Community Group Final Report (W3C, 2025-10-28) specifies:
"The presence of a $value property definitively identifies an object
as a token."
"A token's type can be specified by the optional $type property [...]
Furthermore, the $type property on a group applies to all tokens
nested within that group."
Two bugs in `common/src/app/common/types/tokens_lib.cljc` violate the
spec and silently break import of any third-party DTCG file that uses
group-level type inheritance:
1. `flatten-nested-tokens-json` used `(not (contains? v "$type"))` as
the group-vs-token discriminator. A group node carrying only a
`$type` (to set a default for child tokens) was misidentified as a
token, then immediately discarded because it had no `$value`.
2. `schema:dtcg-node` declared both `$type` and `$value` as required,
so even after the discriminator was fixed any leaf token that
relied on group-level type inheritance failed `dtcg-node?`
validation and never reached the parser.
The combined effect: importing a spec-compliant DTCG file that
expressed types at the group level produced a TokensLib with no
tokens at all, because every leaf was discarded as "unknown type".
Penpot-exported files were unaffected because Penpot always emits
both `$type` and `$value` on every token and never attaches `$type`
to a group, so the existing tests covered only the inline-type
shape.
- `schema:dtcg-node`: mark `$type` optional.
- `flatten-nested-tokens-json`: use `$value` as the discriminator
(anything without `$value` is a group), accept an optional
`inherited-type` accumulator that carries the nearest enclosing
group `$type` down through recursion, and resolve a token's type
from its own `$type` first, falling back to the inherited type.
A token's own `$type` always wins over the inherited one (per
spec).
Added `parse-dtcg-group-type-inheritance` covering both cases:
- group `$type` is inherited by tokens that don't declare their own
(`colors.red`, `colors.blue`, `space.small`)
- token `$type` overrides the inherited group `$type`
(`colors.danger`, `space.large`)
Existing DTCG round-trip tests continue to pass because they all
declare `$type` at the token level, which the new code still honours.
CHANGES.md entry added under the 2.17.0 Bugs-fixed section.
* 📚 Do not update CHANGES.md
We are changing the procedures to not update the changelog on each PR. Instead, we use github tracking to check what issues come in a release, and update the changelog automatically in a batch.
Signed-off-by: Andrés Moya <hirunatan@hammo.org>
---------
Signed-off-by: Andrés Moya <hirunatan@hammo.org>
Co-authored-by: MilosM348 <milos.milic001@outlook.com>
Render owned organizations in the delete-account modal with the same
org-avatar* component used across the dashboard, so logo and avatar
background are shown consistently and initials are extracted via
d/get-initials instead of a raw first-character substring.
Extends the get-owned-organizations-summary endpoint and the underlying
nitrate API schema to carry :avatar-bg-url and :logo-id, deriving
:custom-photo from logo-id with the public uri, matching the pattern
already used by set-team-org-api.
Do not show the library sync popup when the only differences are global x/y changes on library components. We now generate the actual sync changes and only notify if there are real redo-changes to apply.
Run cll/generate-sync-file-changes for candidate libraries and filter out those with empty :redo-changes. The expensive check is deferred via rx/timer 0 so it runs asynchronously and does not block the UI.
Why: Position-only changes are normalized during sync (via reposition-shape) and never propagate to copies; showing the popup in that case was a false positive.
Performance: The check is deferred to the next tick to avoid UI stutter on large files with many libraries.
* 🐛 Token remap preserves child component sync after renaming a token group
* 📚 Do not update CHANGES.md
We are changing the procedures to not update the changelog on each PR. Instead, we use github tracking to check what issues come in a release, and update the changelog automatically in a batch.
Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>
---------
Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>
Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
StyleDictionary returns all nodes from the token tree, including
intermediate group nodes that have no corresponding origin token.
Previously process-sd-tokens tried to parse these as real tokens,
which produced garbage resolved values (e.g. {"$meta$": null, …})
and caused the entire token set validation to fail, blocking all
token creation and editing.
Skip sd-tokens whose origin lookup returns nil so that only actual
tokens are processed.
Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: Andrés Moya <andres.moya@kaleidos.net>
Co-authored-by: moorsecopers99 <46223049+moorsecopers99@users.noreply.github.com>
Add focused common JavaScript test execution with log-level control and a quiet test wrapper matching the frontend workflow.
Update developer docs and testing memories to reflect the common/frontend test split, and document why runner helper extraction is deferred.
Signed-off-by: Codex <codex@openai.com>
Lets a caller pin `app.common.logging`'s level for the duration of a
test run via `--log-level <trace|debug|info|warn|error>`. The flag is
off by default, so when absent the runner doesn't touch logger state
and stdout looks exactly as before.
When passed, the runner calls `(l/setup! {:app level})` right before
dispatching to the test block, so production code exercised by tests
emits only at the requested level or higher.
pnpm run test:quiet -- --focus frontend-tests.logic.groups-test \
--log-level warn
Composes with `--focus`; the two flags are independent.
Caveats worth knowing: top-level log calls fired at namespace load
time run before the runner parses CLI options and therefore slip past
this flag; direct `println` / `js/console.log` calls bypass the
logging system entirely and are unaffected.
Introduces `pnpm run test:quiet` for non-interactive runs (CI, scripted
invocations, agent loops). It runs the same pipeline as `pnpm run test`
— `build:wasm`, then `build:test`, then `node target/tests/test.js` —
but buffers each build step's stdout and stderr and only replays them
when that step exits non-zero. Test-runner output streams through
unchanged, so failures and the summary are never hidden. Short progress
hints (`Building wasm...`, `Building test bundle...`, `Running tests...`)
are written to stderr, leaving stdout to carry only the test results
for clean capture and parsing.
Forwards arguments verbatim, so `pnpm run test:quiet -- --focus ...`
composes with the existing `--focus` flag. The default `pnpm run test`
script and its output are unchanged.
Also documents the new command in the developer guide and updates the
frontend testing memory to recommend it for agent runs.
Previously `pnpm run test` always ran the full frontend-tests suite,
which made tight iteration on a single namespace or var painful. The
runner now accepts `--focus <ns>` or `--focus <ns>/<var>` and executes
only the matching tests, preserving each namespace's `:once` and `:each`
fixtures so behavior matches a full-suite run.
pnpm run test -- --focus frontend-tests.logic.groups-test
pnpm run test -- --focus frontend-tests.logic.groups-test/some-test
Also updates the developer guide and the testing memory so the flag is
discoverable from both docs and agent context.
Memories use a system of progressive disclosure:
Starting from a root memory, memories reference other memories using explicit
references.
The new system of hierarchical memories replaces AGENTS.md files.
GitHub #9215
Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
Co-authored-by: Codex <codex@openai.com>
Add SERENA_UPDATE_VERSION env var (in devenv docker-compose.yml)
to dynamically update Serena on agentic devenv without requiring
an image rebuild.
Apply for update to v1.5.0 (also changing initial installation
in Dockerfile to this version).
* ✨ Add search bar to prototype interaction destination dropdown
On pages with many boards the destination dropdown becomes hard to
navigate. Add an optional `searchable?` flag to the shared select
component that renders a case-insensitive filter input at the top of
the dropdown, and opt it in for the interaction destination select.
- Filtering reuses the already-computed option list (no extra queries).
- Arrow-key navigation tracks the filtered list.
- Search clears automatically when the dropdown closes, so reopening
starts with the full list.
- New `labels.no-matches` i18n key renders when nothing matches.
Closes#8618
Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
* ✨ Use trigger input as search field in destination dropdown
Per review on #9006, the searchable select now uses the visible trigger
input as the filter field itself rather than an extra sticky input
inside the dropdown. The trigger behaves like a filterable select:
typing filters the options without permitting free-text values.
Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>
* ♻️ Update css on old select component
---------
Signed-off-by: moorsecopers99 <patellscott18@gmail.com>
Signed-off-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: moorsecopers99 <patellscott18@gmail.com>
Co-authored-by: moorsecopers99 <vadanamihai409@gmail.com>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
* ✨ Bound the size of plugin task responses
When using the integrated remote MCP server, bound response size.
All responses are passed to LLMs, which themselves impose bounds.
This is a measure to bound memory usage in the centrally provided
MCP server.
GitHub #9493
* ✨ Bound parallelism in ExportShapeTool
Use an integer semaphore to bound parallel requests to this
memory-intensive tool, thus bounding memory usage.
GitHub #9493
* ✨ Add (manual) integration test script for ExportShapeTool parallelism
Add dependency tsx to facilitate executions.
GitHub #9493
* ✨ Make number of parallel export requests configurable in ExportShapeTool
Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure
the maximum number of requests in multi-user mode (default 0, no limit).
When a plugin call fails malli validation, the frontend renders one
"plugins.validation.message" line per error via
`app.plugins.utils/error-messages`, which reduces the explain via
`csm/interpret-schema-problem` and then destructures each entry as
`[field {:keys [message]}]` for translation.
That works only when the underlying malli error path has a single
element. `interpret-schema-problem` calls `(assoc-in acc field ...)`
where `field` can be a multi-element vector (e.g. `[:sets 0 :name]`).
For single-element paths the resulting map is flat
(`{:group {:message "..."}}`); for multi-element paths it is nested
(`{:sets {0 {:name {:message "..."}}}}`). The destructure assumes the
flat shape, so for a nested error the consumer reads:
field -> :sets
message -> nil (the nested entry has no :message at the top level)
and the produced i18n line resolves to `Field sets is invalid: ` --
or, when several errors are merged together at the same outer key,
to the user-facing `Field message is invalid` that the bug report
calls out, because `:message` then becomes the field name of the
deepest nested entry.
The original consumer carried a `#_(mapcat (comp seq val))` FIXME
that hinted at the missing flattening but did not implement one,
because the data shape produced by `interpret-schema-problem` is
not uniform.
Fix
---
Add a private `flatten-error-map` helper inside `app.plugins.utils`
that walks the error map produced by `interpret-schema-problem` and
yields `[path message]` pairs where `path` is the dot-joined field
path. Keywords use `(name k)`, strings pass through, anything else
(such as numeric indices from vector positions in the malli path)
is coerced via `str`. The recursion descends until it hits a leaf
that carries `:message`, which matches what
`interpret-schema-problem` produces in every branch.
The producer side (`csm/interpret-schema-problem` in
`common/src/app/common/schema/messages.cljc`) is left alone: it
already has another consumer (`collect-schema-errors` + the
form-validators pipeline) that depends on the keyed-by-field-path
shape, so normalising it at the source would require auditing every
validator. Flattening at the plugin consumer is the narrowest fix.
The FIXME comment is removed because the new helper supersedes it.
Tests
-----
`frontend-tests.plugins.utils-test` (new file, registered in
`runner.cljs`) covers:
- flat single-segment paths (`{:group {:message "..."}}`)
- nested multi-segment paths
(`{:sets {0 {:name {:message "..."}}}}`) -- the case from #9417
- mixed single- and multi-segment paths at the same explain
- mixed key types (keyword / string / numeric index)
- empty explain (no validation errors)
Closes#9417
Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Serena provides useful tools for the agentic workflow for penpot.
The following additional extensions are added:
1. uv and Serena installation, including a suitable serena_config.yml, are added to the devenv docker image
2. Serena configuration options are set via env vars and flags in manage.sh
3. run-devenv can now take -e flags which it forwards to docker exec
GitHub #9315
Adds a new MCP tool (devenv-only) that imports .penpot files into the
running Penpot instance. The tool downloads the file from a given URL,
stages it in the frontend's static directory, and triggers the import
via the ClojureScript REPL using the frontend's web worker infrastructure.
The temporary file is cleaned up after the import completes or fails.
Registered alongside CljsReplTool, sharing the same NreplClient instance.
Github #9217
Co-authored-by: Claude <noreply@anthropic.com>
Documents how to detect, diagnose, and recover from frontend crashes
(the Internal Error page) when working through the Penpot Plugin API:
- Detect via (some? (:exception @app.main.store/state)) in the cljs REPL
- Read cause from the same map (:type, :code, :hint, :details, :uri, ...)
- Reload by listing/selecting the workspace tab in playwright and
re-navigating to its URL, then re-checking the exception is gone
Co-authored-by: Claude <noreply@anthropic.com>
Add `critical-info` memory as an entrypoint (bootstrap memory) for the LLM, which
points to critical tools and memories, allowing the LLM to dynamically build up
relevant context
Exclude files like CONTRIBUTING.md or README.md from being ignored by /*.md pattern,
as this can influence agent behaviour (configurations that disallow ignored files
from being edited)
New tool to evaluate ClojureScript expressions by connecting to the
nREPL service already provided in devenv.
Add dependency 'nrepl-client' and a corresponding client class
as well as types to support this.
Add a new environment variable for 'devenv mode', which enables
the new tool (PENPOT_MCP_DEVENV).
* 🐛 Revert blend-mode hover preview when dismissing dropdown
When the blend-mode dropdown was dismissed by clicking outside instead
of selecting an option, the canvas kept rendering the last hovered
blend mode even though the inspector and data state had reverted. The
visible state of the shape no longer matched its stored state. Reset
the canvas render back to the shape's saved blend mode on dropdown
close so the preview never outlives the dropdown.
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* Fix blend-mode dropdown and various bugs
Fix blend-mode dropdown behavior and revert WASM render on pointer leave. Also, address multiple bugs related to user interactions and data handling.
Signed-off-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
---------
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Co-authored-by: Alejandro Alonso <alejandro.alonso@kaleidos.net>
* 📎 Add test that surfaces the bug described in #14070
The bug: alt-drag-duplicating a variant master into the variant container
auto-creates a new variant component cloned from the source
via duplicate-component, which preserves :touched on every cloned shape.
The resulting copy's children inherit those :geometry-group touched flags
through add-touched-from-ref-chain on switch.
variants-switch -> component-swap (keep-touched? true) -> generate-keep-touched
then runs update-attrs-on-switch for each touched child. The new shape's
:y is correctly skipped by the per-attr "different masters" guard, but
:selrect/:points fall through to a width/height-based safety check that
uses exact equality. In practice, the alt-drag modifier path leaves a
sub-pixel drift in :width on the copy, so equal-geometry? returns false,
the safety check is bypassed, and the :else branch copies the source
variant's :selrect verbatim onto the freshly instantiated target shape.
The shape ends up with :y from the target master but :selrect.y from the
source — the renderer reads :selrect, so the child appears at the source
position inside a parent that has resized to the target's dimensions:
the visible "button cut off" symptom.
The new test sets up a variant container whose children are themselves
component copies (matching production: variant masters' children carry
:touched), introduces the same kind of width drift on the copy that the
alt-drag path produces, and runs the swap directly via the existing
test harness. It asserts (a) :y matches the target, (b) :selrect.y
matches the target, and (c) :y and :selrect.y agree. With the current
code (a) passes and (b)/(c) fail — capturing both the wrong value and
the internal inconsistency that causes the visible regression.
* 🐛Fix#14070 by no longer comparing floats for exact equality in equal-geometry?
The font-family list at frontend/src/app/main/fonts.cljs registers
Source Sans Pro variants for weights 200, 300, 400, 700 and 900, but
omits the semibold (600) entries even though the font assets are
already bundled (frontend/resources/fonts/sourcesanspro-semibold.*)
and the CSS @font-face declarations that load them are present
(frontend/resources/styles/common/dependencies/fonts.scss:55-56).
Result: weight 600 cannot be selected from the font picker even
though the bytes are downloadable; users see a 400 -> 700 jump.
Add the two missing variant entries (600 and 600 italic) using the
same :suffix style as the other numeric-id entries (200, 300), since
the file-name component "semibold" doesn't match the weight number.
Issue mentions weights 500 and 800 as also missing, but no
sourcesanspro-medium or sourcesanspro-extrabold assets exist in the
repo, so this PR scopes to weight 600 only — the recoverable subset.
Closes#7378.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The workspace Actions history panel previously showed the operation
icon and a one-line message for each undo entry with no indication
of when the action happened, any stable way to refer to it, or who
made the change. The reporter of issue #7660 (and @Takhoffman's
follow-up comment) asked for a git-like display: `<hash> · <time> by
<name>`.
This change stamps each undo entry with its creation timestamp and
author at the moment it lands on the undo stack and surfaces three
extra pieces of information in the history sidebar:
- A short 7-character identifier derived from the entry's existing
`:undo-group` UUID. Hovering shows the full UUID.
- A relative timestamp (e.g. `just now`, `5 minutes ago`, `2 hours
ago`, `3 days ago`) rendered via `app.common.time/timeago` so it
matches the formatting already used for comments and the dashboard.
- The display name of the profile that created the entry, rendered
as `by <Name>` in the same metadata row.
The undo stack is client-side per profile, so every entry is always
the current user; the author is stored on the entry anyway so the UI
does not need to reach into profile state while rendering and so the
data stays correct if the stack shape ever changes.
Changes at a glance:
- `data/workspace/undo.cljs`: extend `schema:undo-entry` with an
optional `:timestamp` and `:by`; new `profile-display-name` helper
that falls back from full name to email to nil; `stamp-entry` now
takes state and fills in both fields on entries that do not
already carry them. Pre-stamped entries (e.g. coming out of an
accumulated transaction) keep their original values.
- `ui/workspace/sidebar/history.cljs`: propagate `:timestamp`,
`:undo-group`, and `:by` through `parse-entries`; add `short-id`
helper; render the metadata row in `history-entry` using
`app.common.time/timeago` against `:timestamp`; skip the author
span entirely when `:by` is nil.
- `ui/workspace/sidebar/history.scss`: styling for the new metadata
row (monospace hash, muted separator, truncated time/author).
- `translations/en.po`: 1 new string for `by %s`.
Existing undo entries created before this change have neither
timestamp nor author; the UI is defensive about both, so old entries
simply render with whatever data they have (and often the plain
title on its own).
Github #7660
Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Signed-off-by: FairyPiggyDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
SSE sessions were never included in the periodic inactivity timeout
checker, so a stale connection whose TCP close event never fired would
retain its SSEServerTransport and McpServer indefinitely.
Changes:
- Add lastActiveTime: number to the sseTransports entry type
- Initialise lastActiveTime at SSE session creation (GET /sse)
- Refresh lastActiveTime on every incoming message (POST /messages)
- Extend startSessionTimeoutChecker() to sweep and forcibly close SSE
sessions idle for more than SESSION_TIMEOUT_MINUTES, mirroring the
existing Streamable HTTP logic
- Update the checker log to count both transport maps
The existing res.on('close') cleanup path is preserved unchanged:
it remains the primary cleanup for normal disconnections; the timer
is a safety net for zombie sessions only.
Closes#9432
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Reject clipboard helpers gracefully on insecure origins
Closes#6514. Resolves the user-visible crash originally reported
in #4478.
`app.util.clipboard/to-clipboard` and `to-clipboard-promise` called
`(unchecked-get js/navigator "clipboard")` and then immediately
invoked `.writeText` / `.write` on the result, with no guard for the
case where `navigator.clipboard` is `undefined`. The W3C Clipboard
API spec requires a "secure context" (HTTPS or localhost), so a
Penpot instance served over plain HTTP - which the SSDP/LAN
self-hosted setup in #4478 was - throws
TypeError: Cannot read properties of undefined (reading 'writeText')
synchronously the moment the user clicks any copy button. The error
escapes the consuming function before any error-handling rx/of arm
runs, so the whole UI ends up on the error screen instead of just
the affected control showing a "could not copy" message.
A third helper (`to-clipboard-multi`) already guards `clipboard` and
`clipboard.write`, but if both are missing it silently returns nil
which is also surprising for callers expecting a Promise.
## Fix
Add a small `get-clipboard` accessor and an `unavailable-error`
factory that returns `Promise.reject(Error(...))` with a clear
message ("Clipboard API is unavailable. This usually happens when
the page is served over plain HTTP; serve Penpot over HTTPS to
enable copy-to-clipboard."). Wire all three helpers through the
same defensive contract:
- `to-clipboard` - return the rejected Promise when
`navigator.clipboard.writeText` is missing.
- `to-clipboard-promise` - return the rejected Promise when
`navigator.clipboard.write` is missing.
- `to-clipboard-multi` - convert the existing `if` into a `cond`
with three branches: prefer `clipboard.write` for true multi-MIME
output, fall through to `writeText` with the text/plain payload
when only the legacy text path is available, and finally reject
with the unavailable error when neither path exists. Previously
the no-API case fell off the `when-let` and silently returned
nil.
The contract is now consistent: every helper either resolves or
rejects a Promise, never throws synchronously, and never returns
nil. Callers (which are already structured around rx streams that
call `rx/from` on the helper's return value) can chain `.catch` /
`rx/catch` to surface a status-bar message instead of crashing.
The two stale `;; FIXME` comments on `to-clipboard` (rename to
`write-text`) and `to-clipboard-promise` (this API is very confuse)
are removed - the rename remains an open follow-up across 13+ call
sites and is intentionally out of scope, but the API is no longer
"confuse" once the contract is documented and uniform.
CHANGES.md entry added under the 2.17.0 Bugs-fixed section
describing the user-visible behaviour change.
* 🐛 Reject paste-from-navigator gracefully on insecure origins
Symmetric companion to the to-clipboard / to-clipboard-promise /
to-clipboard-multi guards added earlier in this PR. The paste path
went through fromNavigator (frontend/src/app/util/clipboard.js) which
called `navigator.clipboard.read()` with no nil-check; on insecure
origins (plain HTTP / non-localhost) this raised an opaque
`TypeError: Cannot read properties of undefined (reading 'read')` and
the workspace surfaced a generic 'Something wrong has happened' toast
instead of the descriptive 'serve Penpot over HTTPS …' message users
get for the copy direction.
Mirror the get-clipboard pattern from clipboard.cljs:
- Read `navigator.clipboard` once into a local.
- If it's missing the `.read` method, throw a descriptive Error that
matches the wording the copy direction already uses (only the verb
swaps: 'paste-from-clipboard' instead of 'copy-to-clipboard').
- Otherwise, dispatch through the local handle.
The existing app.util.clipboard/from-navigator (clipboard.cljs:32)
already wraps impl/fromNavigator in rx/from, so a rejected Promise
from the async function propagates as an rx error event. Existing
callers that subscribe with .catch / on-error see the structured
Error and surface the toast, identical to how to-clipboard's
unavailable-error already flows.
Repro (matches niwinz's reproduction in the PR comment):
Object.defineProperty(navigator, 'clipboard', { value: undefined });
// … then attempt a paste action in the workspace …
Before: TypeError in console + 'Something wrong has happened' toast.
After: descriptive Error caught by the rx subscription and rendered
through the existing unavailable-Clipboard-API surface.
Refs #6514, #4478
* 🐛 Show user-facing toast when clipboard API is unavailable
Niwinz's review on penpot#9188 caught that the rejected Promise from
to-clipboard / to-clipboard-promise / to-clipboard-multi /
fromNavigator now surfaces the correct error to the console, but the
workspace UI still falls through to the generic "Something wrong has
happened" toast because the on-clipboard-permission-error and the
paste error-handler in paste-from-clipboard only branched on
clipboard-permission-error?.
Apply the patch he suggested in the review:
- Add clipboard-unavailable-error? predicate that matches the
Promise.reject(Error("Clipboard API is unavailable. ...")) thrown
by the get-clipboard / unavailable-error helpers added earlier in
this PR. Uses str/starts-with? on the message prefix so the
predicate stays stable even if the trailing "serve Penpot over
HTTPS ..." advice text is reworded later.
- Convert on-clipboard-permission-error from `if` to `cond` and add
a third arm that fires errors.clipboard-api-unavailable as a
warning toast.
- Add the same arm in the second cond block inside
paste-from-clipboard, before the :not-implemented and :else arms.
- Add the matching errors.clipboard-api-unavailable entry to
frontend/translations/en.po with the wording niwinz suggested:
"Clipboard API is unavailable. Serve Penpot over HTTPS to enable
clipboard access".
Refs penpot#9188 review.
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Only fall back to anonymous on :not-found in get-profile
::get-profile caught Throwable and silently returned the anonymous
user payload for every error - contradicting the in-code comment that
states in all other cases we need to reraise the exception. Under
transient DB conditions (pool checkout timeout, replica lag, statement
timeout, network blip) this masked real DB outages as ordinary
anonymous responses, returning HTTP 200 instead of 5xx and leaving
logged-in users on the login screen with a valid session cookie.
Narrow the catch so only :type :not-found falls through; everything
else propagates and reaches the standard error pipeline.
Closes#9235
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
* 🐛 Only fall back to anonymous on :not-found in get-profile
::get-profile caught Throwable and silently returned the anonymous
user payload for every error - contradicting the in-code comment that
states in all other cases we need to reraise the exception. Under
transient DB conditions (pool checkout timeout, replica lag, statement
timeout, network blip) this masked real DB outages as ordinary
anonymous responses, returning HTTP 200 instead of 5xx and leaving
logged-in users on the login screen with a valid session cookie.
Narrow the catch so only :type :not-found falls through; everything
else propagates and reaches the standard error pipeline.
Closes#9253
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
---------
Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Closing the fill dialog while an image-fill upload is still in flight
(or while a gradient is mid-edit) leaves the colorpicker's
current-color with only :opacity — no :image, :gradient, or :color.
update-colorpicker-color's WatchEvent then constructed
`(add-recent-color partial)`, which runs the value through
`clr/check-color` and threw "expected valid color". The user saw an
Internal Assertion Error toast and lost the in-flight upload.
The existing `ignore-color?` guard reads `:type` from the *output* of
`get-color-from-colorpicker-state` — but that helper strips :type from
its result, so the guard never actually fires. Add a schema-based gate
(same validator add-recent-color itself uses) right before `rx/of`, so
a partial selection is silently dropped instead of crashing the
workspace. Behaviour for fully-valid colors is unchanged.
Tests cover three cases: (1) a partial image-tab state with only
:opacity returns nil from watch (was: throws); (2) the same partial
shape on the color tab also returns nil — pinning down that the prior
:type guard wouldn't have caught it; (3) a fully-populated plain color
still produces a watch observable so the guard isn't over-eager.
Closes#8443
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Rename component from link-button to link-button* and remove the legacy
::mf/wrap-props false metadata. Update all callsites to use the modern
[:> lb/link-button* ...] syntax instead of [:& lb/link-button ...].
Part of the #9260 issue.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Step toward issue #9260 (incremental migration of legacy UI
components to the modern `*`-suffixed syntax, removing the per-render
JS-to-Clojure props conversion overhead).
Twin namespaces with parallel structure: each defines six components
that drive a recursive text rendering pass over the editor's content
tree (root -> paragraph-set -> paragraph -> node -> text). Both files
were uniformly legacy: every component carried `::mf/wrap-props
false` and read its props with `(obj/get props "key")`. None had
`::mf/register`, `unchecked-get` or `obj/merge!`, so they qualify as
clean Case-A migrations.
frontend/src/app/main/ui/shapes/text/fo_text.cljs (6 components)
----------------------------------------------------------------
- `render-text` -> `render-text*`
- `render-root` -> `render-root*`
- `render-paragraph-set` -> `render-paragraph-set*`
- `render-paragraph` -> `render-paragraph*`
- `render-node` -> `render-node*` (forward-props case,
see below)
- `text-shape` -> `text-shape*` (`::mf/forward-ref`
preserved)
The four leaf components switch from `[props]` + per-key
`(obj/get props "key")` to standard destructuring. `text-shape`
already used destructuring under `::mf/props :obj`; that legacy
metadata is dropped because the modern `*` form handles props
automatically. Its single `::mf/forward-ref true` is kept per the
prompt's "preserve forward-ref" rule.
`render-node` is the recursive driver. It needs to forward all of
its incoming props to the matched paragraph-* / text component and
then to a child `render-node*` after overriding `:node`, `:index`
and `:key`. The migrated form uses `::mf/props :obj` together with
`{:keys [node] :as props}` to keep the JS-object props symbol
available, and `(mf/spread-props props {…})` replaces the previous
`obj/clone` + `obj/set!` chain.
`app.util.object` is no longer required by this namespace and the
`(:require ... [app.util.object :as obj] ...)` line is removed.
frontend/src/app/main/ui/shapes/text/html_text.cljs (6 components)
-----------------------------------------------------------------
Identical six-component shape as `fo_text.cljs`, plus a `code?`
flag threaded through every component to switch the rendering path
between regular shapes and code-style shapes.
- `render-text` -> `render-text*`
- `render-root` -> `render-root*`
- `render-paragraph-set` -> `render-paragraph-set*`
- `render-paragraph` -> `render-paragraph*`
- `render-node` -> `render-node*` (same forward-props
treatment as above,
plus `is-code` in
the spread)
- `text-shape` -> `text-shape*` (`::mf/forward-ref`
preserved)
The `code?` boolean prop is renamed to `is-code` per the migration
prompt's "?-suffixed boolean -> `is-` prefix" rule. The rename is
applied at every read site (5 components) and at the `text-shape*`
internal call to `render-node*`, so the prop is consistent inside
the namespace.
`app.util.object` is no longer required by this namespace either
and the corresponding `:require` line is dropped.
External call sites (3 files, 4 sites)
--------------------------------------
- `frontend/src/app/main/ui/shapes/text.cljs` - the legacy
text-shape wrapper (intentionally kept legacy in this PR because
it dispatches to `svg/text-shape`, which is still being touched by
the in-flight PR #9016) now calls `[:> fo/text-shape* props]`.
The `props` symbol is the wrapper's incoming JS-object; modern
destructured components accept JS-object props at the call site
via `[:>` so this works unchanged.
- `frontend/src/app/util/code_gen/markup_html.cljs` -
`(mf/element text/text-shape #js {:shape shape :code? true})`
becomes
`(mf/element text/text-shape* #js {:shape shape :is-code true})`
(component renamed and the `code?` JS key updated to match the
renamed prop).
- `frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs`
- `[:& html/text-shape {…}]` -> `[:> html/text-shape* {…}]`.
Behavior preserved verbatim
---------------------------
Same render output, same forward-ref forwarding semantics, same
recursive children-by-index keying, same default `:dir "auto"` on
`render-paragraph*`. The visible-prop changes are only the `code?`
-> `is-code` rename, all driven from this namespace and its single
caller in `markup_html.cljs`.
Github #9260
Signed-off-by: FairyPigDev <luislee3108@gmail.com>
* ✨ Update issue templates to include the issue type
Added the type "bug" to the "New render bug report" and the "Bug report" templates and the type "feature" to the "Feature request template".
This will allow us to use the issue Type instead of labels to identify what kind of issue is being created.
* ✨ Update bug_report.md to request screen recordings
Update the Screenshots section to also request screen recordings
Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
---------
Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
When the Stripe checkout fails to start, the subscription page now
shows an inline error in the Business Nitrate card under the CTA
instead of a toast. When the post-payment activation fails, the toast
message is updated to point users to support@penpot.app.
The nitrate-form modal also passed a URI object to
build-nitrate-callback-urls while the underlying append-query-param
relied on lambdaisland's u/parse, which only accepts strings. Switched
to the local u/uri helper so both strings and URI records work, so
failures opened from the modal land on the subscription page.
Adopts the rumext * suffix convention for the following components,
invoking them with the [:> JS-style syntax to match the rest of the
codebase (see e.g. rea*, single-selection* in viewport/selection):
- measurements: size-display, distance-display-pill, selection-rect,
distance-display, selection-guides, measurement
- shapes/svg-defs: svg-node, svg-defs (also drop the now-redundant
{::mf/wrap-props false} annotations)
Updates all call sites in inspect/selection_feedback, shapes/shape,
workspace/viewport, and workspace/viewport_wasm. Pure rename — no
behavioral change.
Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Update issue templates to include the issue type
Added the type "bug" to the "New render bug report" and the "Bug report" templates and the type "feature" to the "Feature request template".
This will allow us to use the issue Type instead of labels to identify what kind of issue is being created.
* ✨ Update bug_report.md to request screen recordings
Update the Screenshots section to also request screen recordings
Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
---------
Signed-off-by: Madalena Melo <madalena.melo@kaleidos.net>
When the Stripe checkout fails to start, the subscription page now
shows an inline error in the Business Nitrate card under the CTA
instead of a toast. When the post-payment activation fails, the toast
message is updated to point users to support@penpot.app.
The nitrate-form modal also passed a URI object to
build-nitrate-callback-urls while the underlying append-query-param
relied on lambdaisland's u/parse, which only accepts strings. Switched
to the local u/uri helper so both strings and URI records work, so
failures opened from the modal land on the subscription page.
Adopts the rumext * suffix convention for the following components,
invoking them with the [:> JS-style syntax to match the rest of the
codebase (see e.g. rea*, single-selection* in viewport/selection):
- measurements: size-display, distance-display-pill, selection-rect,
distance-display, selection-guides, measurement
- shapes/svg-defs: svg-node, svg-defs (also drop the now-redundant
{::mf/wrap-props false} annotations)
Updates all call sites in inspect/selection_feedback, shapes/shape,
workspace/viewport, and workspace/viewport_wasm. Pure rename — no
behavioral change.
Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Step toward issue #9260 (incremental migration of legacy UI
components to the modern `*`-suffixed syntax, removing the per-render
JS-to-Clojure props conversion overhead).
Two unrelated namespaces, both clean Case-A migrations grouped in a
single PR for review efficiency.
frontend/src/app/main/ui/shapes/text/fontfaces.cljs
---------------------------------------------------
Three components, all previously using `::mf/wrap-props false` with a
custom memoizer (`#(mf/memo' % (mf/check-props ["fonts"]))`) and
reading `fonts` via `(obj/get props "fonts")`. The custom memoizer
existed because the legacy components received raw JS-object props,
where the default `mf/memo` Clojure-equality comparison would always
fail.
- `fontfaces-style-html` → `fontfaces-style-html*`
- `fontfaces-style-render` → `fontfaces-style-render*`
- `fontfaces-style` → `fontfaces-style*`
Migration:
- Standard destructuring `[{:keys [fonts]}]` replaces the
`[props]` + `(obj/get props "fonts")` pattern.
- `::mf/wrap-props false` removed.
- Custom memoizer collapses to `::mf/wrap [mf/memo]`. With modern
destructuring the props are Clojure data, so default `=`-based memo
is structurally correct (and a stronger guarantee than the previous
shallow JS-prop check).
- `app.util.object` require dropped from the namespace — it was only
used for the `obj/get props "fonts"` reads that are now gone.
- Internal call site of `fontfaces-style-render*` (within
`fontfaces-style*`) keeps its `[:>` form, just with the new name.
External call sites updated:
- `frontend/src/app/main/render.cljs` — three sites
(`[:& ff/fontfaces-style {:fonts fonts}]` × 3) →
`[:> ff/fontfaces-style* {:fonts fonts}]`.
- `frontend/src/app/main/ui/workspace/shapes.cljs` — one site,
call signature unchanged. (Note: this caller passes `:shapes`
rather than `:fonts`; the legacy component already ignored
`:shapes` because it only read `(obj/get props "fonts")`, so the
modern destructuring `{:keys [fonts]}` preserves the same
behavior. Pre-existing bug, intentionally left out of scope.)
frontend/src/app/main/ui/viewer/thumbnails.cljs
-----------------------------------------------
Four components, all using standard destructuring already (no
`::mf/wrap-props false`, no `unchecked-get`). Migration is the
straight `*` rename plus `?`-prop renames per the prompt's mapping:
- `thumbnails-content` → `thumbnails-content*` (`expanded?` →
`is-expanded`)
- `thumbnails-summary` → `thumbnails-summary*` (no `?`-props)
- `thumbnail-item` → `thumbnail-item*` (`selected?` →
`is-selected`; `::mf/wrap [mf/memo #(mf/deferred …)]` preserved)
- `thumbnails-panel` → `thumbnails-panel*` (`show?` →
`show` — no `is-` prefix needed, reads naturally as a verb)
Internal callsites of all three sub-components in `thumbnails-panel*`
updated to `[:> …*` with renamed kwargs (`:expanded?` →
`:is-expanded`, `:selected?` → `:is-selected`).
The `expanded?` symbol still appears in `thumbnails-panel*`'s body —
it's a local `let`-binding deref'd from the `expanded-state` atom,
not a component param, so the `?` suffix is preserved per the
prompt's "local bindings stay" rule.
External call sites updated:
- `frontend/src/app/main/ui/viewer.cljs` — one site, plus the
`:refer [thumbnails-panel]` → `:refer [thumbnails-panel*]` require
update.
Github #9260
Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Change isVariant() return type from boolean to 'this is LibraryVariantComponent',
enabling TypeScript users to directly access variants, variantProps, and
variantError after a type-narrowing check. Update MCP instructions with
improved variant navigation guidance.
Closes#9185
Co-authored-by: Claude (Anthropic) <noreply@anthropic.com>
description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase
agent: build
---
# Fix Git Conflicts
Resolve conflicts in the local repository. The user handles finishing the
rebase themselves — you must **never** run `git rebase --continue`,
`git rebase --skip`, `git merge --continue`, or anything similar.
## Phase 1 — Understand the problem (read-only)
1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files.
2. For each conflicted (unmerged) file, understand the situation **without modifying anything**:
- Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).
- Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent.
- Identify what each side changed and why, and how they should be combined.
## Phase 2 — Present the resolution plan
3. **Present a clear plan to the user before touching any file.** For each conflicted file, state:
- What each side changed and why.
- Your proposed resolution and the reasoning behind it.
- How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context).
4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly.
5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything.
## Phase 3 — Execute
6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers.
## Phase 4 — Stage and verify
7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution.
8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths.
## Phase 5 — Report
9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command.
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
---
# Backport Commit
Port changes from a specific Git commit to the current branch by manually
applying the diff, avoiding `git cherry-pick` when it would introduce
complex conflicts.
## When to Use
Use this skill whenever the user asks to backport a commit, especially when:
- The commit touches multiple modules or files with significant divergence
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
- The target commit is old enough that conflicts are likely
- The commit introduces both source changes AND new files (tests, etc.)
- You need full control over how each hunk is applied
## Workflow
### 1. Identify the target commit
```bash
# Verify the commit exists and understand what it does
git log --oneline -1 <commit-sha>
# Get the full diff (including new/deleted files)
git show <commit-sha>
# Capture the original commit message for later reuse
git log --format='%B' -1 <commit-sha>
```
### 2. Identify affected modules
From the file paths in the diff, determine which Penpot modules are affected
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
files **before** making any changes. If a module has no `AGENTS.md`, skip
that step — verify with `ls <module>/AGENTS.md` first.
### 3. Read the current state of each affected file
For every file the diff touches, read the current version on disk to understand
context and ensure correct placement before editing.
### 4. Apply changes manually (the core of this approach)
Process every hunk in the diff using the appropriate tool:
| Diff action | Tool to use |
|-------------|-------------|
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
| Add new file | `write` — include proper license header and namespace conventions matching project style |
| Delete file | `bash rm <path>` |
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
> Use separate calls when hunks are far apart to keep `oldString` short and
> unambiguous.
Repeat until **all** hunks in the diff are ported.
### 5. Validate
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
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. 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
a reference, adapting it as needed for the target branch context.
## Key Principles
- **Context matters** — always read files before editing; never guess
indentation or surrounding code
- **Lint + format + test** — never skip validation before committing
- **Preserve intent** — keep the original commit message meaning; the
description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch.
---
# Code Review and Quality
## Overview
Multi-dimensional code review with quality gates. Every change gets reviewed before merge — no exceptions. Review covers five axes: correctness, readability, architecture, security, and performance.
**The approval standard:** Approve a change when it definitely improves overall code health, even if it isn't perfect. Perfect code doesn't exist — the goal is continuous improvement. Don't block a change because it isn't exactly how you would have written it. If it improves the codebase and follows the project's conventions, approve it.
## When to Use
- Before merging any PR or change
- After completing a feature implementation
- When another agent or model produced code you need to evaluate
- When refactoring existing code
- After any bug fix (review both the fix and the regression test)
## Core Principles
These principles underpin every axis. When in doubt, default to them.
- **DRY (Don't Repeat Yourself):** Every piece of knowledge has one authoritative representation. If the same logic appears in two places, extract it into a shared helper, model, or type. Reviewers: flag duplicated logic as a required change — it's not "just similar," it's drift that will diverge.
- **KISS (Keep It Simple, Stupid):** The simplest solution that works is the best solution. Complexity must earn its place. Reviewers: if you need more than one sentence to explain what a piece of code does, it's too complex — push for simplification before merge.
- **YAGNI (You Aren't Gonna Need It):** Don't add abstractions, hooks, or generalizations for hypothetical future use cases. Generalize on the third occurrence, not the first. Reviewers: delete speculative generality.
- **Don't invent problems:** Do not manufacture issues to produce more feedback. Every finding must be a real risk, a real readability barrier, or a real architectural concern — not a hypothetical or a stylistic preference disguised as a problem.
## The Five-Axis Review
Every review evaluates code across these dimensions.
### 1. Correctness
Does the code do what it claims to do?
- Does it match the spec or task requirements?
- Are edge cases handled (null, empty, boundary values)?
- Are error paths handled (not just the happy path)?
- Does it pass all tests? Are the tests actually testing the right things?
- Are there off-by-one errors, race conditions, or state inconsistencies?
### 2. Readability & Simplicity
Can another engineer (or agent) understand this code without the author explaining it?
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
- Are there any "clever" tricks that should be simplified?
- **KISS check:** Is this the simplest approach that solves the problem? A 20-line straightforward function beats a 5-line clever one that requires a comment to explain.
- Could this be done in fewer lines? (1000 lines where 100 suffice is a failure)
- Are abstractions earning their complexity? (Don't generalize until the third use case)
- Is a new conditional bolted onto an unrelated flow? Push the logic into its own helper, state, or policy.
- Do repeated conditionals on the same shape appear? They signal a missing model or dispatcher.
- Are there dead code artifacts: no-op variables, backwards-compat shims, or `// removed` comments?
### 3. Architecture
Does the change fit the system's design?
- Does it follow existing patterns or introduce a new one? If new, is it justified?
- Does it maintain clean module boundaries?
- **DRY check:** Is there existing code that does the same thing? Reuse the canonical helper instead of writing a near-duplicate. If two branches do nearly the same thing, collapse them.
- Are dependencies flowing in the right direction (no circular dependencies)?
- Is the abstraction level appropriate (not over-engineered, not too coupled)?
- Does this refactor reduce complexity or just relocate it? Count the concepts a reader must hold. Prefer the restructuring that makes whole branches disappear over one that re-centralizes the same logic. Prefer deleting an abstraction to polishing it.
- Is feature-specific logic leaking into a shared or general-purpose module?
- Are type boundaries explicit? Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks.
- **Structural remedies:** When you flag a problem, propose the move — not just the problem. Replace conditionals with dispatchers, collapse duplicate branches, separate orchestration from business logic, extract helpers, split large files. Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
### 4. Security
For detailed security guidance, see `security-and-hardening`.
- Is user input validated and sanitized?
- Are secrets kept out of code, logs, and version control?
- Is authentication/authorization checked where needed?
- Are SQL queries parameterized (no string concatenation)?
- Are outputs encoded to prevent XSS?
- Are dependencies from trusted sources with no known vulnerabilities?
- Is data from external sources (APIs, logs, user content, config files) treated as untrusted?
### 5. Performance
- Any N+1 query patterns?
- Any unbounded loops or unconstrained data fetching?
- Any synchronous operations that should be async?
- Any unnecessary re-renders in UI components?
- Any missing pagination on list endpoints?
- Any large objects created in hot paths?
## Review Process
1. **Understand the intent** — What is this change trying to accomplish? What spec or task does it implement?
2. **Review tests first** — Tests reveal intent and coverage. Do they test behavior, not implementation details? Are edge cases covered?
3. **Review the implementation** — Walk through each file with the five axes in mind.
4. **Categorize findings** — Label every comment with its severity:
| **Suggestion:** | Worth considering | Not required, but improves the code |
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
5. **Verify the verification** — What tests were run? Did the build pass? Was the change tested manually? Screenshots for UI changes?
## Review Output
Structure every review using this format:
### Summary
Briefly explain what the code does and give an overall assessment.
### Critical and High-Priority Issues
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
### Other Findings
List medium- and low-priority issues, including maintainability and design concerns.
### Suggested Refactoring
Provide focused code changes or revised snippets. Preserve existing behavior unless a behavior change is explicitly justified.
### Testing Recommendations
Identify missing tests and describe specific test cases, including edge cases and failure scenarios.
### Positive Observations
Mention implementation choices that are clear, safe, efficient, or well designed. This is not fluff — it reinforces good patterns and tells the author what to keep doing.
### Final Verdict
Choose one:
- **Approve** — Ready to merge
- **Approve with minor changes** — Good to merge after addressing low/medium issues
- **Request changes** — Critical or high issues must be resolved before merge
## Change Sizing
Small, focused changes are easier to review, faster to merge, and safer to deploy.
```
~100 lines changed → Good. Reviewable in one sitting.
~300 lines changed → Acceptable if it's a single logical change.
~1000 lines changed → Too large. Split it.
```
**Watch file size, not just diff size.** Around 1000 *total* lines in a single file is a common inspection signal. When a change materially grows an already-large file, decompose first.
**Splitting strategies:**
| Strategy | How | When |
|----------|-----|------|
| **Stack** | Submit a small change, start the next one based on it | Sequential dependencies |
| **By file group** | Separate changes for groups needing different reviewers | Cross-cutting concerns |
1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.)
3. Is it actively maintained? (Check last commit, open issues.)
4. Does it have known vulnerabilities? (`npm audit`)
5. What's the license? (Must be compatible with the project.)
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
**Upgrading dependencies:**
- Read the changelog, not just the version number. Semver is a promise the maintainer may not have kept.
- One dependency per change. When a bulk bump breaks the build, you've lost which package did it.
- Let the tests decide — a green suite before *and* after, not just "it installed."
- Review the lockfile diff, not just `package.json`. Commit it and never hand-edit it.
For supply-chain risk triage, follow the `security-and-hardening` skill.
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "It works, that's good enough" | Working code that's unreadable, insecure, or architecturally wrong creates debt that compounds. |
| "I wrote it, so I know it's correct" | Authors are blind to their own assumptions. Every change benefits from another set of eyes. |
| "We'll clean it up later" | Later never comes. The review is the quality gate — use it. |
| "AI-generated code is probably fine" | AI code needs more scrutiny, not less. It's confident and plausible, even when wrong. |
| "The tests pass, so it's good" | Tests are necessary but not sufficient. They don't catch architecture, security, or readability problems. |
| "The refactor makes it cleaner" | Relocating complexity isn't reducing it. If the reader still holds the same number of concepts, the structure didn't improve. |
| "It's only a small addition to this file" | Small diffs still push files past healthy size and bolt branches onto unrelated flows. |
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog. |
| "I'll upgrade everything in one PR" | A bulk bump hides which package broke the build. One per change. |
| "It's duplicated but it's only two places" | Two becomes three becomes five. Extract now, before the copies diverge. |
| "The abstraction is future-proof" | YAGNI. Delete speculative generality — generalize on the third occurrence, not the first. |
| "It's clever but efficient" | Cleverness is a readability tax. If it needs a comment to understand, simplify it. |
## Red Flags
- PRs merged without any review
- Review that only checks if tests pass (ignoring other axes)
- "LGTM" without evidence of actual review
- Security-sensitive changes without security-focused review
- Large PRs that are "too big to review properly" (split them)
- No regression tests with bug fix PRs
- Accepting "I'll fix it later" — it never happens
- A refactor that moves code around without reducing the number of concepts a reader must hold
- New conditionals scattered into unrelated code paths (a missing abstraction)
- A bespoke helper that duplicates an existing canonical one
- A bulk "bump dependencies" PR with no changelog review
## Verification
After review is complete:
- [ ] All Critical issues are resolved
- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
```
Different models have different blind spots.
## See Also
- For detailed security review guidance, see `security-and-hardening`
description: Create or update GitHub issues (from PR, from draft body, retitle existing). Routes to the canonical flow in `mem:workflow/creating-issues`.
---
# Skill: create-issue
Entry point for all GitHub issue work. All rules (title derivation, metadata,
body templates, Issue Type IDs), all flows, and all `gh` / GraphQL commands
live in `mem:workflow/creating-issues` (file:
`.serena/memories/workflow/creating-issues.md`). This skill routes to the
right flow.
## When to Use
- **Create from PR** — PR exists; the issue is the changelog/release unit,
the PR is the implementation. Issue = WHAT, PR = HOW.
→ memory section **Creating Issues from PRs**
- **Create from draft body** — Taiga story, user report, discussion; no PR
yet.
→ memory section **Creating Issues from Draft Body**
- **Retitle existing issue** — current title is vague, prefixed, or stale.
→ memory section **Retitling an Existing Issue**
Everything else (title derivation, metadata policy, body templates, Issue
Type IDs, create/verify/cleanup commands) lives in the memory — go to the
description: Create or update a GitHub PR following Penpot conventions, with a concise engineer-focused description
description: Create or update a GitHub PR following Penpot conventions.
---
# Pull Request (Create or Update)
# Skill: create-pr
Create or update a GitHub PR with proper title format and a concise description that explains reasoning, not implementation details.
Create or update a GitHub PR. Read and follow:
- `mem:workflow/creating-prs` — title format, description structure, writing principles
- `mem:workflow/creating-commits` — commit type emojis
## 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
- Creating a new PR from a feature branch
- Updating an existing PR's title or description to match conventions
## Conventions
## Prerequisites
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.
- **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
<whatbreaks,whattheuserexperiences>
### Steps to reproduce
1. <step1>
2. <step2>
### Expected behavior
<whatshouldhappeninstead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<whattheusercannowdothattheycouldn'tbefore>
### Use case
<whythisisuseful,whobenefits>
### 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'
<bodycontenthere>
ISSUE_BODY
```
Create:
```bash
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
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)
- **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` |
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
---
# Plan Review
## Overview
Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
## When to Use
- After the planner skill produces a plan
- Before starting implementation on any non-trivial task
- When reviewing a plan written by another agent or a human
- When a plan feels too large, vague, or risky to start
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
## The Six-Axis Review
Every plan gets evaluated across these dimensions:
### 1. Completeness
Does the plan cover everything needed to implement successfully?
- Is the **context** clear? (What problem, why now, what's the goal?)
- Are **affected modules** identified with paths?
- Are **architecture decisions** documented with rationale?
- Is there a **testing strategy**?
- Are **verification commands** explicit (not "run the tests")?
- Are **open questions** listed (not buried in someone's head)?
- Is there a **parallelization** assessment for multi-task plans?
**Missing any of these is a gap, not a nit.**
### 2. Task Quality
Are the tasks well-defined and independently executable?
- Does every task have **acceptance criteria**? (Testable, not vague)
- Does every task have **verification steps**?
- Are tasks **sized appropriately**? (XS–M is ideal, L is acceptable, XL must be split)
- Are **dependencies** between tasks explicitly stated?
- Are **files likely touched** listed?
- Is each task a **single, self-contained change**? (Not "implement the whole feature")
- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
### 3. Architecture & Sequencing
Is the plan structured so implementation flows correctly?
- Does implementation order follow the **dependency graph** (foundations first)?
- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
- Does each task leave the system in a **working state**?
- Are there **checkpoints** between major phases?
- Are **high-risk tasks early** (fail fast)?
- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
### 4. Risk Coverage
Are the hard parts acknowledged and mitigated?
- Are **edge cases** identified?
- Are **breaking changes** or **migration concerns** noted?
- Are **security implications** considered?
- Are **performance implications** considered?
- Are **external dependencies** or integration risks flagged?
- Is there a plan for **rollback** if something goes wrong?
- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
### 5. Actionability
Can an implementer actually execute this?
- Are **file paths** specific (not "update the relevant files")?
- Are **function/method names** mentioned where applicable?
- Are **verification commands** copy-pasteable (not "run the linter")?
- Are **test commands** project-specific (not generic)?
- Is the **code shape** described where the implementation isn't obvious?
- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
- Does the plan reference **existing code** the implementer should read first?
### 6. Proposed Code Quality *(when the plan includes implementation details)*
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria:
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
- **Readability:** Are proposed names descriptive and consistent with project conventions?
- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
## Structural Remedies
When you flag a structural problem in a plan, propose the fix — not just the problem:
- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
- **Missing acceptance criteria:** Draft 2–3 specific, testable conditions for the task.
- **Wrong sequencing:** Identify the dependency and propose the correct order.
- **No checkpoints:** Suggest where checkpoints should go (typically after every 2–3 tasks).
- **Vague verification:** Replace "run tests" with the actual project command.
- **Horizontal slicing:** Restructure into vertical feature paths.
- **Missing risk section:** Draft the risks you can identify from the plan content.
Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
## Plan Sizing
Plans should be scoped to a single deliverable:
```
1–5 tasks → Good. A focused feature or bug fix.
6–10 tasks → Acceptable for a moderate feature.
11–15 tasks → Large. Consider splitting into phases.
15+ tasks → Too large. Split into multiple plans.
```
**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
## Categorize Findings
Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| *(no prefix)* | Required change | Must address before implementation starts |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
## Review Process
### Step 1: Understand the Goal
Before evaluating structure, understand intent:
```
- What is this plan trying to accomplish?
- What problem does it solve?
- What does "done" look like?
```
### Step 2: Check Completeness First
Scan for missing sections before diving into content:
```
- Context present?
- Affected modules listed?
- Architecture decisions documented?
- Risks acknowledged?
- Testing strategy defined?
- Verification commands explicit?
```
### Step 3: Review Task Quality
Walk through each task:
```
For each task:
1. Can I tell exactly what to build?
2. Are acceptance criteria specific and testable?
3. Is the size reasonable (not XL)?
4. Are dependencies clear?
5. Would I know which files to touch?
```
### Step 4: Validate Sequencing
Check the dependency graph:
```
- Are foundations built first?
- Does each task leave the system working?
- Are checkpoints placed correctly?
- Are high-risk items early?
- Is it vertically sliced?
```
### Step 5: Assess Actionability
Put yourself in the implementer's shoes:
```
- Could I pick up task 1 and start coding without asking any questions?
- Are the verification commands copy-pasteable?
- Are file paths and function names specific?
- Is existing code referenced where I'd need to read it?
```
### Step 6: Verify the Verification Story
Check that the plan can actually confirm it worked:
- [ ] No performance issues in proposed structures
### Verdict
- [ ] **Approve** — Ready to implement
- [ ] **Request changes** — Gaps must be addressed
```
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
## Red Flags
- No acceptance criteria on any task
- Tasks that say "implement the feature" without specifics
- No verification steps anywhere in the plan
- All tasks are XL-sized
- No checkpoints between phases
- Dependency order isn't considered (e.g., API handler before domain model)
- No testing strategy
- Verification commands are generic ("run tests") instead of project-specific
- Plan has 20+ tasks (scope too large for one plan)
- No risk section on a plan with migrations, breaking changes, or security implications
- Horizontal slicing (all domain, then all services, then all API)
- File paths are vague ("update the relevant files")
- Missing open questions section despite stated unknowns
- Proposed code ignores project conventions or existing patterns
- Proposed types use gratuitous `any`/`unknown`/optional without justification
- Proposed APIs don't validate input at boundaries
## See Also
- For producing plans, use the `planner` skill
- For reviewing implemented code, use `code-review` — also the criteria source for axis 6
- For security-specific concerns, see `security-and-hardening`
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans that engineers or other agents can execute. Never writes
or modifies code.
## When to Use
- The user asks for a plan, design, or analysis of a feature or bug.
- The user wants to understand which parts of the codebase a task will touch.
- The user needs a step-by-step implementation plan with file paths, function
names, and test strategy.
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
- The user is about to start non-trivial work and wants a bite-sized task
breakdown.
Do **not** use this skill to actually implement anything — it is read-only.
## Role
You are a Senior Software Architect working on Penpot, an open-source design
tool. Your sole responsibility is planning and analysis — you do NOT write or
modify code.
You help users understand the codebase, design solutions, and create detailed
implementation plans that other agents or developers can execute. Document
everything they need to know: which files to touch for each task, code patterns,
tests, and how to verify correctness. Apply DRY and KISS principles.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
## Required Reading Before Planning
Before drafting any plan, work through the project's own guidance:
1. Read `critical-info` (`.serena/memories/critical-info.md`) — the entry point
that describes the monorepo structure and module dependency graph.
2. From `critical-info`, identify which modules your task affects.
3. Read each affected module's core memory, e.g. `mem:frontend/core`,
description: Refine and improve a user-supplied prompt for maximum clarity and effectiveness using prompt-engineering best practices and Penpot project context. Outputs a rewritten prompt (and brief rationale); never executes the prompt.
---
# Refine Prompt
Expert prompt-engineering pass on a user-supplied prompt. Takes a draft prompt
and returns a clearer, more effective, well-structured version — ready to be
used with any AI model. Never executes the prompt itself.
## When to Use
- The user shares a prompt and asks to improve, refine, polish, or rewrite it.
- The user asks "make this prompt better" or "can you clean this up?".
- The user wants to add structure, constraints, examples, or output format to
a vague prompt.
- The user wants a prompt adapted for a specific target model, audience, or
task type.
Do **not** use this skill to actually answer the prompt or do the task — it
only rewrites the prompt.
## Role
You are an expert Prompt Engineer with strong knowledge of Penpot. Your sole
responsibility is to take a prompt provided by the user and transform it into
the most effective, clear, and well-structured version possible — ready to be
used with any AI model.
You do **not** execute tasks. You do **not** write code. You only design and
refine prompts.
## Required Reading Before Refining
Before rewriting, internalize the project context the prompt will likely run
against:
1. Read `AGENTS.md` (root) for the project-level rules and conventions.
2. Read `.serena/memories/critical-info.md` (or the equivalent entry point) to
understand the module layout (`frontend`, `backend`, `common`,
description: Hardens code against vulnerabilities. Use when handling user input, authentication, data storage, or external integrations. Use when building any feature that accepts untrusted data, manages user sessions, or interacts with third-party services.
---
# Security and Hardening
## Overview
Security-first development practices for web applications. Treat every external input as hostile, every secret as sacred, and every authorization check as mandatory. Security isn't a phase — it's a constraint on every line of code that touches user data, authentication, or external systems.
## When to Use
- Building anything that accepts user input
- Implementing authentication or authorization
- Storing or transmitting sensitive data
- Integrating with external APIs or services
- Adding file uploads, webhooks, or callbacks
- Handling payment or PII data
## Process: Threat Model First
Controls bolted on without a threat model are guesses. Before hardening, spend five minutes thinking like an attacker:
1. **Map the trust boundaries.** Where does untrusted data cross into your system? HTTP requests, form fields, file uploads, webhooks, third-party APIs, message queues, and **LLM output**. Every boundary is attack surface.
2. **Name the assets.** What's worth stealing or breaking? Credentials, PII, payment data, admin actions, money movement.
3. **Run STRIDE over each boundary** — a quick lens, not a ceremony:
| Threat | Ask | Typical mitigation |
|---|---|---|
| **S**poofing | Can someone impersonate a user/service? | Authentication, signature verification |
| **T**ampering | Can data be altered in transit or at rest? | Integrity checks, parameterized queries, HTTPS |
| **R**epudiation | Can an action be denied later? | Audit logging of security events |
| **I**nformation disclosure | Can data leak? | Encryption, field allowlists, generic errors |
| **D**enial of service | Can it be overwhelmed? | Rate limiting, input size caps, timeouts |
| **E**levation of privilege | Can a user gain rights they shouldn't? | Authorization checks, least privilege |
4. **Write abuse cases next to use cases.** For each feature, ask "how would I misuse this?" — then make that your first test.
If you can't name the trust boundaries for a feature, you're not ready to secure it. This is OWASP **A04: Insecure Design** — most breaches begin in design, not code.
## The Three-Tier Boundary System
### Always Do (No Exceptions)
- **Validate all external input** at the system boundary (API routes, form handlers)
- **Parameterize all database queries** — never concatenate user input into SQL
if (!API_KEY) throw new Error('STRIPE_API_KEY not configured');
```
### Server-Side Request Forgery (SSRF)
Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, `localhost`, private IPs).
```typescript
// BAD: fetch whatever the user gives you
await fetch(req.body.webhookUrl);
// GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('https only');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
// Resolve ALL records; a single private/reserved address fails the check.
The `range() !== 'unicast'` check covers loopback, link-local `169.254.169.254` (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6.
**Caveat — this still has a TOCTOU gap.** `fetch` resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a filtering agent in front (`request-filtering-agent` / `ssrf-req-filter`).
throw new ValidationError('File type not allowed');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('File too large (max 5MB)');
}
// Don't trust the file extension — check magic bytes if critical
}
```
## Triaging npm audit Results
Not all audit findings require immediate action. Use this decision tree:
```
npm audit reports a vulnerability
├── Severity: critical or high
│ ├── Is the vulnerable code reachable in your app?
│ │ ├── YES --> Fix immediately (update, patch, or replace the dependency)
│ │ └── NO (dev-only dep, unused code path) --> Fix soon, but not a blocker
│ └── Is a fix available?
│ ├── YES --> Update to the patched version
│ └── NO --> Check for workarounds, consider replacing the dependency, or add to allowlist with a review date
├── Severity: moderate
│ ├── Reachable in production? --> Fix in the next release cycle
│ └── Dev-only? --> Fix when convenient, track in backlog
└── Severity: low
└── Track and fix during regular dependency updates
```
**Key questions:**
- Is the vulnerable function actually called in your code path?
- Is the dependency a runtime dependency or dev-only?
- Is the vulnerability exploitable given your deployment context (e.g., a server-side vulnerability in a client-only app)?
When you defer a fix, document the reason and set a review date.
### Supply-Chain Hygiene
`npm audit` catches known CVEs; it won't catch a malicious or typosquatted package. Also:
- **Commit the lockfile** and install with `npm ci` (not `npm install`) in CI — reproducible builds, no silent version drift.
- **Review new dependencies before adding them** — maintenance, download counts, and whether they truly earn their place. Every dependency is attack surface (OWASP **A06: Vulnerable Components**, **LLM03: Supply Chain**).
- **Be wary of `postinstall` scripts** in unfamiliar packages — they run arbitrary code at install time.
- **Watch for typosquats** — `cross-env` vs `crossenv`, `react-dom` vs `reactdom`.
## Rate Limiting
```typescript
import rateLimit from 'express-rate-limit';
// General API rate limit
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
}));
// Stricter limit for auth endpoints
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes
}));
```
## Secrets Management
```
.env files:
├── .env.example → Committed (template with placeholder values)
**If a secret is ever committed, rotate it.** Deleting the line or rewriting history is not enough — assume it's compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history.
## Securing AI / LLM Features
If your app calls an LLM — chatbots, summarizers, agents, RAG — it inherits a new attack surface. Map it to the [OWASP Top 10 for LLM Applications (2025)](https://genai.owasp.org/llm-top-10/):
- **Treat all model output as untrusted input (LLM05: Improper Output Handling).** Never pass LLM output straight into `eval`, SQL, a shell, `innerHTML`, or a file path. Validate and encode it exactly as you would raw user input.
- **Assume prompts can be hijacked (LLM01: Prompt Injection).** Untrusted text in the context window — a user message, a fetched web page, a PDF — can carry instructions. The system prompt is not a security boundary; enforce permissions in code, not in the prompt.
- **Keep secrets and other users' data out of prompts (LLM02 / LLM07).** Anything in the context can be echoed back. Don't put API keys, cross-tenant data, or the full system prompt where the model can repeat it.
- **Constrain tool and agent permissions (LLM06: Excessive Agency).** Scope tools to the minimum, require confirmation for destructive or irreversible actions, and validate every tool argument.
- **Bound consumption (LLM10: Unbounded Consumption).** Cap tokens, request rate, and loop/recursion depth so a crafted input can't run up cost or hang the system.
- **Isolate retrieval data (LLM08: Vector and Embedding Weaknesses).** In RAG, treat the vector store as a trust boundary: partition embeddings per tenant so one user can't retrieve another's data, and validate documents before indexing so poisoned content can't steer answers.
```typescript
// BAD: trusting model output as a command or as markup
description: Write or rewrite text in ASD-STE100 Simplified Technical English. ONLY use this skill when the user explicitly invokes it by name — i.e. they type "/ste" or literally write "use the ste skill" / "apply ASD-STE100". Do NOT trigger it on paraphrased intent such as "simplify this", "make it clearer", "write technical documentation", or "shorter sentences please" — the user has deliberately scoped this skill to explicit invocation only. For those requests, respond normally without loading this skill unless they name it.
---
# ASD-STE100 Simplified Technical English
Apply the ASD-STE100 standard to all prose you produce in this task. Do not announce that you use STE, do not name the standard, and do not explain the style unless the user asks. If the user later asks you to "write more naturally," ask one short question to confirm they want to leave STE before you drop it.
Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance.
## Step 0 — Classify the text
Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section.
## Core rules
### Sentences
- Procedural: maximum **20 words** per sentence.
- Descriptive: maximum **25 words** per sentence.
- Maximum **6 sentences** per paragraph. One topic per paragraph.
- One instruction per sentence. Two actions in one sentence only if they occur at the same time.
- Put a condition BEFORE its command: "If the pressure decreases, close the valve."
- Do not omit articles, subjects, or verbs to save words. "Ensure file exists" is wrong; "Make sure that the file exists" is correct. Keep the word "that" after verbs like "make sure."
- Numbers, units with numbers, abbreviations, quoted strings, code identifiers, and proper nouns each count as one word.
### Verbs
- Allowed forms only: infinitive, imperative, simple present, simple past, simple future, and past participle used as an adjective.
- Never use present perfect or continuous forms. "We have received" → "We received." "is being tested" → a simple form.
- Never use an -ing form as a verb. An -ing word is allowed only inside a technical name ("the mounting bracket," "logging").
- Active voice. Passive is allowed only in descriptive text when the agent is unknown or unimportant.
- Instructions use the imperative: "Open the panel," not "You must open the panel" or "The panel should be opened."
- Express actions as verbs, not nouns: "compress the file," not "perform compression of the file."
- Modals: use **can** (possibility), **will** (future), **must** (requirement). Do not use should, would, could, may, might. A hedge becomes a fact or a "can": "an explosion can occur."
- One word, one meaning, one part of speech, used consistently. Never rotate synonyms: pick one name for a thing and repeat it.
- Before drafting, replace unapproved vocabulary. Read `references/word-substitutions.md` and apply it; it is the working dictionary for this skill.
- Domain-specific nouns (part names, tool names, product names, UI labels) and domain verbs (drill, ream, boot, compile) are your **technical nouns/verbs** — keep them as-is, use each consistently, and do not verb a noun or noun a verb.
- Noun clusters: maximum **3 words** ("overhead panel light" is the limit). Longer clusters get decomposed with prepositions or hyphenated on first use: "main-gear-door retraction-winch handle."
- American English spelling.
- No Latin abbreviations: "e.g." → "for example," "i.e." → "that is," delete "etc."
### Punctuation
- No semicolons — write two sentences.
- Parentheses only for references, abbreviations, and item numbers.
- Hyphenate words that act as one unit; a hyphenated word counts as one word.
- No contractions.
### Warnings, cautions, notes
- **WARNING** = risk of injury or death. **CAUTION** = risk of damage. **NOTE** = information only, never an instruction.
- Start a warning or caution with the command or condition, then give the risk:
"WARNING: Do not touch the terminal. The terminal has a dangerous voltage."
- Notes obey the 25-word descriptive limit.
## Step 2 — Self-check pass
After drafting, scan your text once for each of these and fix every hit before you respond:
1. Any sentence over the 20/25-word limit for its type
7. Synonym rotation (the same object under two names)
8. Any word in the unapproved column of `references/word-substitutions.md`
9. Warnings that state the risk before the command
## Reference files
- `references/word-substitutions.md` — unapproved → approved word mappings and one-meaning rulings. Read it before drafting; it is short.
- `references/examples.md` — worked before/after rewrites (procedural, descriptive, warnings, common mistakes). Read it when rewriting existing text or when unsure how a rule applies.
## What NOT to touch
Code blocks, command strings, file paths, error messages, quoted UI text, and proper nouns stay exactly as written. STE applies to the prose around them.
| We have received the technical reports from HQ. | We received the technical reports from HQ. |
| This device has been being used at Boeing since 2005. | Boeing started to use this device in 2005. |
| The test is continued by the operator. | Continue the test. |
| The screws should be replaced. | Replace the screws. |
| The system is currently running diagnostics. | The system does diagnostic tests now. |
## Vocabulary and phrasing
| Before | After |
|---|---|
| Ensure file exists before running. | Make sure that the file exists before you run the command. |
| Rotate the cover until the jacks are accessible. | Turn the cover until you can get access to the jacks. |
| Extend the jack until the wheels are clear of the ground. | Extend the jack until the wheels do not touch the ground. |
| Make sure that these steps are followed. | Obey these steps. |
| Utilize approximately 3 liters of water. | Use about 3 liters of water. |
| Perform a compression of the log files prior to upload. | Compress the log files before you upload them. |
## Noun clusters
| Before | After |
|---|---|
| Main gear door retraction winch handle | Main-gear-door retraction-winch handle |
| Runway light connection resistance calibration | Calibration of the resistance of the runway light connection |
| Remove the engine transmission housing attachment bolts. | Remove the bolts that attach the transmission housing to the engine. |
## Procedural rewrite (condition first, one instruction per sentence)
Before:
> You should check that the reservoir has been filled with the correct fluid and, after attaching a clear tube to the bleed screw while ensuring its free end is submerged, the pedal can be pumped and held so that opening the screw releases trapped air.
After:
> 1. Fill the reservoir with the correct fluid.
> 2. Attach a clear tube to the bleed screw.
> 3. Put the free end of the tube in a container of fluid.
> 4. Push the pedal three times. Hold the pedal down.
> 5. Open the bleed screw one half turn. Air and fluid flow into the tube.
> 6. Close the bleed screw. Release the pedal.
> 7. If air continues to come out, do steps 4 thru 6 again.
## Warnings and cautions (command first, then risk)
Before:
> Note that serious data loss may potentially occur if the --force flag is used against production.
After:
> CAUTION: Do not use the --force flag on the production database. The flag deletes the rows that do not match the source.
Before:
> Touching the terminal could result in electrocution.
After:
> WARNING: Do not touch the terminal. The terminal has a dangerous voltage.
## Common mistakes checklist
- Dropped articles: "Insert pin in bracket" → "Insert the pin in the bracket."
- Synonym rotation: check/verify/confirm for the same action → one term, everywhere.
- Hedges: "you may want to," "it is recommended that" → an imperative or "must."
- Instruction buried in a NOTE: notes never instruct. Move the instruction to a numbered step.
- Semicolon joining two clauses → two sentences.
- "There are three bolts on the panel" → "The panel has three bolts."
Compiled from public secondary sources (STEMG/ASD public pages, TechScribe, Acrolinx, training materials). This is a working approximation, not the official ASD dictionary. When a word is not listed here and feels formal or Latin-derived, prefer the shortest common alternative.
| accessible | (rewrite: "you can get access to") |
| remainder | rest |
| demonstrate | show |
| modify, alter | change |
| construct, fabricate, build | assemble, make |
| retain | keep |
| locate (=find) | find |
| depress (a button) | push, press |
| proceed | continue, go |
## One meaning, one part of speech (canonical rulings)
- **close** — verb only: to move to a position that stops flow, or to operate a circuit breaker. The adjective is unapproved → use **near** ("do not go near the propeller").
- **test** — noun only: "do a test," never "test the system."
- **check** — do not use as a verb for verification → "make sure that" or "examine."
- **follow** — means only "come after." For rules and steps use **obey**: "Obey the safety instructions."
- **fall** — means only "move down by gravity." For quantities use **decrease**. Never the season.
- **oil** — noun only. "Oil the bearing" → "Put oil on the bearing" / "Lubricate the bearing."
- **right** — direction only, never "correct."
- **clear** — "without blockage." "Wheels are clear of the ground" → "wheels do not touch the ground."
- **help** — verb only; the noun is **aid** ("with the aid of a mirror").
- **above / below** — physical position only. For quantities: **more than / less than**.
- **about** — two approved senses: "approximately" and "on the subject of." Use carefully.
- **turn** — the general verb for rotation; "turn on / turn off" for power state is standard.
- **level** — approved as noun and adjective (documented exception to the one-POS rule).
## Frequent-offender function words
- **should / would / could / may / might** — never. Requirement → **must**. Possibility → **can**. Future → **will**.
- **etc.** — delete, or write the full list.
- **e.g. / i.e.** — "for example" / "that is."
- **any / appropriate / applicable / relevant** as hedges — replace with the specific thing meant.
- **there is / there are** openers — rewrite with a real subject: "There are three bolts on the panel" → "The panel has three bolts."
description: Enforce TDD workflow and testing best practices for Penpot. Use when implementing features, fixing bugs, or modifying behavior. Reads testing memory for full guidance.
---
# Testing Skill
Enforces test-driven development and Penpot testing conventions.
## When to Use
- Implementing new logic or behavior
- Fixing any bug (reproduction test required)
- Modifying existing functionality
- Adding edge case handling
**Skip:** Pure configuration changes, documentation updates, or static content with no behavioral impact.
## Workflow
Follow TDD (Red → Green → Refactor) whenever practical:
1. **RED** — Write a failing test first
2. **GREEN** — Write minimal code to pass
3. **REFACTOR** — Clean up while tests stay green
For bug fixes, use the Prove-It Pattern: write a test that reproduces the bug, confirm it fails, implement the fix, confirm it passes.
@ -39,6 +40,9 @@ Backend RPC command areas without focused memories include access tokens, binfil
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 interactive PostgreSQL access with correct dev defaults, use `scripts/psql`; to dump
the current DDL schema, use `scripts/db-schema` (see `mem:scripts/psql`).
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
@ -53,14 +57,14 @@ 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>`.
`./scripts/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
@ -89,13 +93,18 @@ Fixtures can populate local data for manual testing/perf work. From the backend
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.
* **Linting:**`pnpm run lint:clj`.
* **Formatting:**`pnpm run check-fmt:clj` to check, `pnpm run fmt:clj` to fix. After running `fmt:clj`, `check-fmt:clj` is redundant. Avoid unrelated whitespace diffs.
**Before linting:** if delimiter errors are suspected (after LLM edits), run
`scripts/paren-repair` on the affected files first. Delimiter errors produce
misleading linter/compiler output. See `mem:scripts/paren-repair`.
## Testing
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. JVM tests are invoked directly via `clojure -M:dev:test` — there is no pnpm wrapper. If you need to filter output, tee to a temp file first: `clojure -M:dev:test 2>&1 | tee /tmp/penpot-test-output.txt`. See `mem:testing` for execution discipline.
* **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.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.
- 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.
@ -28,4 +25,4 @@
- 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.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
- Each object has a `storage_object` database row.
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
- The backend stores the binary content.
- Supported backends are `:fs` and `:s3`.
- FS uses one root directory and a UUID-derived path.
- S3 uses one configured bucket and an optional prefix.
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
- Deprecated asset-storage config keys remain supported for migration.
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
## Object Lifecycle
- `put-object!` creates the database row before it writes backend content.
- Backend content is written only when the row is new.
- A failed backend write can leave an unreferenced database row.
- Callers often set `:touched-at` so garbage collection can remove such rows.
- `get-object` excludes rows with `deleted_at`.
- Existing object values can remain readable until physical deletion.
- `:expired-at` blocks reads after the expiration time.
- `del-object!` sets `deleted_at`. It does not remove backend content.
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
- `storage-gc-touched` finds references before it sets `deleted_at`.
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
## Deduplication
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
- The lookup does not include file ID, profile ID, team ID, or organization ID.
- Objects can therefore share content across users and files within one bucket.
- Deleted objects are not reused.
- `tempfile` objects never use deduplication, even when the caller requests it.
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
## Bucket Rules
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
| --- | --- | --- | --- | --- |
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
- The valid bucket set lives in `app.storage/valid-buckets`.
- `file-media-object` is the default bucket for old rows without bucket metadata.
- Do not assign a new bucket without adding its access and cleanup behavior.
- The touched-object collector raises an internal error for an unknown bucket.
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
- It does not support `file-data-fragment` or `file-change`.
## Access Rules
- `app.http.assets` decides direct object authentication from the bucket.
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
- Other valid buckets require a session or access-token profile ID.
- File-media routes also require file read permission.
- Non-public direct responses set `content-disposition: attachment`.
- FS responses use `x-accel-redirect` for the configured asset path.
- S3 responses use a presigned URL and an HTTP redirect.
## File Data
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
- `db` stores encoded data in `file_data.data`.
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
- The `file_data.metadata.storage-ref-id` value points to the storage object.
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.
@ -24,6 +24,12 @@ Variant masters are main instances and component roots. Their descendants may th
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.
## Swap slots and positional matching
- A swap slot (stored via `ctk/set-swap-slot`, a `:touched` group `swap-slot-<uuid>`) marks a copy sub-head that was SWAPPED to another component; `compare-children` then pairs it to the main child by slot instead of by `shape-ref`.
- Copy sub-heads without a slot are paired to main children by `shape-ref` (seek, not index). `find-near-match` (positional) is only a validator/repair heuristic; validity requires membership of the ref among the near-main parent's children, not index equality (`mem:common/file-change-validation-migration-subtleties`).
- Copy child ORDER converges to the main's via the async sync (`moved` branch of `compare-children`); local code must never reorder copy children directly (guards in `:mov-objects`/`:reorder-children`).
## 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.
- `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.
- Copy structure is guarded at change application: `:mov-objects` (`is-valid-move?`) and `:reorder-children` both refuse to alter children of shapes inside component copies unless the change carries `allow-altering-copies` (sync/swap flows set it). New structural change types must follow the same rule.
- `cls/generate-delete-shapes` propagates deletions from INSIDE a component main to the copy shapes referencing them (transitively, all pages of the file) so no dangling `shape-ref`s remain; skipped when the main root itself is deleted (copies then resolve into the deleted component) and for `allow-altering-copies` flows (swap replaces the shape; sync reconciles).
## Shape tree edits
@ -19,6 +21,7 @@
- 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.
- `:missing-slot` fires only for a REAL swap: a copy sub-head whose `shape-ref` is no longer a child of the near main parent. A pure positional mismatch (ref still a sibling elsewhere) is a reorder — valid, realigned by the async component sync; do not "repair" it by assigning swap slots (a slot freezes the child out of normal sync). `fix-missing-swap-slots` (migration 0019) follows the same membership rule.
- 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.
- `position-absolute?` counts HIDDEN shapes as absolute: hiding a grid child frees its cell on the next `assign-cells`.
- `reorder-grid-children` rewrites the parent's `:shapes` to the REVERSE of the sorted cell order, but children with no cell (hidden/absolute) keep their original index — do not "fix" this into moving them to an end; that broke copy/main positional slot alignment (referential-integrity crash).
- The `:reorder-children` change it emits is refused on parents inside component copies unless `allow-altering-copies` (same rule as `:mov-objects`); `pcb/reorder-grid-children` also skips copy grids producer-side. Copy child order is owned by the component sync engine.
- 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.
`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.
## Running tests
## Unit tests
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS and JVM test runs.
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 (always builds, suppressed output): `pnpm run test:quiet`
- Full JS test run (always builds, build output visible): `pnpm run test`
- 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 (no run): `pnpm run build:test`
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
```bash
pnpm run test:jvm
clojure -M:dev:test
pnpm run test:jvm --focus common-tests.logic.variants-switch-test
pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test
pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn
pnpm run watch:test
```
Use `test:quiet` for non-interactive JS runs; it buffers `build:test` output and forwards runner args. Common JS runner args support `--focus <namespace-or-var>` and `--log-level trace|debug|info|warn|error`. After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`. 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.
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
@ -45,4 +50,4 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit
## Debugging
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
- Cross-cutting testing principles, TDD workflow, and anti-patterns: `mem:testing`.
# 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`.
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
- 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 `scripts/paren-repair` BEFORE running lint/format checks.
See `mem:scripts/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.
- `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.
- `backend/`: JVM Clojure HTTP/RPC server with PostgreSQL, Redis, storage, mail, and workers; core conventions: `mem:backend/core`. 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; core conventions: `mem:common/core`.
@ -25,7 +25,9 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## 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`.
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. 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`.
Each workspace is independent and can be started/stopped in any order. Shared infra (postgres, minio, etc.) is shut down only when no instances remain running.
`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.
`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.
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.
@ -63,8 +65,8 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## 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 --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).
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
- 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`.
- From `exporter/`: setup `./scripts/setup`; watch `pnpm run watch` or `pnpm run watch:app`; production build `pnpm run build`; test bundle `pnpm run build:test`; tests `pnpm run test` or`pnpm run test:quiet`; lint`pnpm run lint:clj`; format check/fix `pnpm run check-fmt:clj` / `pnpm run fmt:clj`.
- Because exporter consumes `common/`, shared file/shape/model changes may need exporter verification even when the immediate change is not under `exporter/`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- Exporter test conventions and CI: `mem:exporter/testing`.
## HTTP and browser pool
@ -30,4 +32,4 @@
- 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.
- Temporary resources schedule local deletion, then uploads POST to `/api/management/methods/upload-tempfile` with `X-Shared-Key: exporter <management-key>` and Bearer auth.
READ `mem:testing` FIRST — it defines the execution discipline (no piping, tee to file, preferred commands) that applies to all CLJS/JS test runs.
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`.
- Full unit test run (always builds, suppressed output): `pnpm run test:quiet`.
- Full unit test run (always builds, build output visible): `pnpm run test`.
- 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`.
- Build test target only (no run): `pnpm run build:test`.
- After `build:test` has been run, run the compiled runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
- 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.
- 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.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
Stateless HTTP service for Penpot image and font processing. Handles image info extraction, thumbnail generation (sharp), and font conversion (FontForge, woff-tools).
## Tech Stack
- Language: TypeScript
- Runtime: Node.js
- Framework: Express
- Image processing: sharp (libvips)
- Font processing: FontForge (TTF/OTF), sfnt2woff, woff2_decompress
- Upload handling: multer (hybrid storage: memory for small, disk for large)
- 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:**`.
- Cross-cutting testing principles and anti-patterns: `mem:testing`.
- 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`.
- **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`.
- **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, object lifecycle, and file-data backends): `mem:backend/storage`.
- **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`.
@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
## See also
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
- 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.
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
- Frames with a fill may use `render_frame_container_drop_shadow` (direct rrect +
blur saveLayer on `DropShadows`) when `uses_direct_container_drop_shadow` is true.
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
## When to use
- Querying error reports from the database for debugging or analysis
- Filtering errors by source, kind, tenant, or backend version
- Exporting error data in JSON, NDJSON, or table format
**Streaming behavior:** With `--all`, output must be `ndjson` or `table`; `--all --format json` is rejected because `--all` streams output. `--all --format table` prints rows immediately. `--format ndjson` always streams one JSON object per line.
| Mocking everything | Tests pass, production breaks | Prefer real implementations or fakes |
| No test isolation | Pass individually, fail together | Per-test state fixtures |
| Testing framework/platform code | Wastes time | Only test YOUR code |
| Snapshot abuse | Nobody reviews, break on any change | Focused assertions |
| Skipping tests to make suite pass | Hides real failures | Fix the test or fix the code |
## Execution discipline
**CRITICAL: Test output handling rules**
When running ANY test command (CLJS/JS or JVM):
1. **NEVER pipe test output directly to `| head`, `| tail`, `| grep`, or similar filters** — this can hide failures and cause you to miss critical errors.
2. **ALWAYS pipe to a file first, then read the file:**
```bash
# CORRECT:
pnpm run test 2>&1 > /tmp/test-output.txt
grep -A 5 "failures" /tmp/test-output.txt
# WRONG:
pnpm run test 2>&1 | tail -20
pnpm run test 2>&1 | grep "failures"
```
3. **Use `--focus` to narrow test scope** instead of filtering output.
4. **Read the full output file** to understand test results completely.
When running CLJS/JS tests (frontend, common):
- **Always use `pnpm run test:quiet`** — it silently builds the test bundle then runs the test runner, giving you clean test output.
- Use `pnpm run test` when you want to see build output alongside test results (always builds, then runs).
- After `build:test` has been run once, you can invoke the runner directly: `node target/tests/test.js [--focus ...] [--log-level ...]`.
When running JVM tests (backend, common):
- Use `clojure -M:dev:test` directly (no pnpm wrapper).
- Same file-piping rule applies.
## Verification Checklist
After completing any implementation:
- [ ] Every new behavior has a corresponding test
- [ ] All tests pass for touched modules
- [ ] Bug fixes include a reproduction test that failed before the fix
- [ ] Test names describe the behavior being verified
- [ ] No tests were skipped or disabled
- [ ] Lint/formatter passes for touched modules
- [ ] New test files registered in the module's runner/entrypoint (see module
| **Labels** | `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) · do **not** add `bug` or `enhancement` labels (use Issue Type instead) |
| **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. |
@ -79,6 +79,8 @@ Write the body to a temp file to avoid shell quoting issues:
<version>
```
Note: do not soft-wrap paragraphs in the body. Each paragraph is a single line in the source; newlines are reserved for structural breaks (section headers, list items, code-block fences, blank-line separators). List items stay on a single line each. GitHub renders single-line paragraphs correctly, and wrapping makes diffs noisy on every small wording change. Same rule applies to PR bodies.
PR only on explicit request. Branch: issue/feature-specific; fallback `<type>/<short-description>` (`fix/...`, `feat/...`, `refactor/...`, `docs/...`, `chore/...`, `perf/...`).
## Target Branch
Auto-detect the base branch with `scripts/detect-target-branch`:
```bash
TARGET=$(scripts/detect-target-branch)
```
This outputs `staging` or `develop` by walking the local commit graph (pure local, no remote/network). Do not ask the user for the target branch unless the tool fails.
## Metadata
Always add the PR to the Main project (`--project "Main"`) unless the user explicitly requests a different project.
## Title Format
PR titles follow commit title conventions:
@ -16,7 +30,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
- related GitHub issues or Taiga stories (`Fixes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
- related GitHub issues or Taiga stories (`Closes #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.
@ -24,19 +38,19 @@ Include concise sections covering:
PR descriptions follow this structure:
```markdown
**Note:** This PR was created with AI assistance as part of the Penpot MCP self-improvement initiative.
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
@ -45,6 +59,8 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- **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?
- **Prefer bullets over paragraphs.** Short bullet items, grouped by area with bold lead-ins where helpful, are far easier to digest than prose; keep any remaining paragraph to a few sentences.
- **No manual line wraps.** Markdown renders adapting to the viewport; hard-wrapped lines degrade rendering. One line per paragraph or bullet, however long.
- **Skip the obvious.** Don't explain what `git diff` already shows.
### What NOT to Include
@ -60,3 +76,24 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- 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.
- When the user says the code is already pushed, trust that — do not verify remote branch existence via `git ls-remote` or `git fetch`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.