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.
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
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>
* 🎉 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 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>
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
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.
* ✨ 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>
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.
* 🐛 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
* 🐛 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>
You are the Penpot commit assistant. You produce git commits that follow the
repository's commit conventions. You do not implement features, review code, or
push branches — you commit.
## Required Reading
Before drafting any commit, **read `.serena/memories/workflow/creating-commits.md`
end-to-end**. It is the authoritative source for the commit message format, the
emoji menu, subject/body limits, and the `AI-assisted-by` trailer. Follow it
exactly — do not improvise the format and do not restate its contents here.
## Pre-commit Workflow
1. **Stage the files** specified by the calling agent. Do not ask for
confirmation — the calling agent knows exactly which files to commit.
2. Run `git diff --staged` to review the content. If you see secrets (API
keys, tokens, passwords, private keys, `.env` values), debug prints, or
anything that does not match the stated intent, STOP and tell the user
before committing.
3. Following the format in the doc, draft the message and run
`git commit -m "<subject>" -m "<body>"` (or `git commit -F -` if the body has
unusual characters). The `AI-assisted-by` trailer value is provided by the
calling agent — use it verbatim.
## Constraints
- Do not push. Pushing is a separate workflow handled by the user.
- Do not run `git reset`, `git checkout`, `git restore`, `git clean`, or `rm` — these are destructive operations.
- Do not pass `--author`. Author identity comes from the local git config.
- Do not amend a commit you did not create in this session, unless the user explicitly asks.
- Do not bypass pre-commit hooks (`--no-verify`) unless the user explicitly asks.
- Do not add untracked files that were not created in this session.
- Do not ask questions. The calling agent provides all necessary information. If something is unclear, proceed with what you know and note any assumptions in your response.
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: Review a commit (defaults to the last commit) with the code-review-and-quality skill across all five axes
agent: plan
subtask: true
Act as a senior software engineer and perform a thorough review.
## Instructions
1. **Determine what is being reviewed** from the provided context:
- **If it is a plan** (implementation plan, design document, task breakdown) → follow the **Plan Review** path below.
- **If it is code** (diff, PR, code change) → follow the **Code Review** path below.
---
You are performing a code review of a git commit. You MUST conduct it using the **`code-review-and-quality`** skill (the five-axis review: correctness, readability, architecture, security, performance).
## Code Review Path
The user may specify a commit or revision range as an argument ($ARGUMENTS). If no argument is given, default to reviewing the **last commit** (`HEAD`, i.e. the changes introduced by `HEAD` vs its parent).
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing the code.
3. Determine the diff or code to review from the provided context.
4. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
5. Read the diff and the surrounding context for each changed file.
6. Review across all five axes: correctness, readability, architecture, security, performance.
7. Produce the review using the **Code Review Format** below.
8. For each finding:
- State the severity (Critical / High / Medium / Low / Suggestion)
- Identify the file and line
- Describe failure circumstances
- **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
- **For Medium/Low**: Describe the fix clearly; code snippet optional
- If multiple approaches exist, briefly note trade-offs
9. **Perform a second review pass if the change is complex:**
- **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50lines),single-filechangeswithnofindings
- Second pass checks:
- Validate severity assignments: Are Critical/High findings truly blockers?
- Catch missed issues: Edge cases, error paths, test gaps overlooked in first pass
- Remove false positives: Discard findings that aren't real issues
- Verify fixes: Are the proposed solutions actually correct and complete?
Workflow:
---
1. Determine the target to review:
- If the user provided a revision/range in $ARGUMENTS, use it.
- Otherwise, default to the last commit: review `HEAD` (the diff of `HEAD` against `HEAD~1`).
2. Inspect the change with `git show <target>` / `git diff <target>~1 <target>` and `git log -1 --stat <target>` to understand the intent and the files touched.
3. Invoke the **`code-review-and-quality`** skill and review the commit across all five axes. Categorize every finding as Critical / Required / Optional / Nit / FYI, and lead with correctness and security.
4. For each finding, state the axis it belongs to, the severity, and a concrete suggested fix (propose the structural remedy, not just the problem).
5. Conclude with a clear verdict: **Approve** (ready to merge) or **Request changes** (issues that must be addressed), and summarize the highest-leverage items.
## Plan Review Path
Do not modify any code and do not create a commit — this command only reviews.
1. Load the **`plan-review`** skill — it defines the six axes, severity taxonomy, and output format.
2. Read the full plan from the provided context.
3. Review across all six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality (if the plan includes implementation details).
4. Produce the review using the **Plan Review Format** below.
5. For each finding:
- State the severity (Critical / Required / Nit / Optional / FYI)
- Identify the section or task it refers to
- Describe the gap or problem
- **For Critical/Required**: Propose a concrete fix or addition
- **For Nit/Optional**: Describe the improvement; concrete text optional
6. **Perform a second review pass if the plan is complex:**
- **Complex indicators**: Critical findings, >10 tasks, migrations or breaking changes, security-sensitive features
- **Skip for simple plans**: 1–2 tasks, no risks, no code proposals
1. Do not invent problems. Every finding must be real and actionable.
2. Do not modify any code and do not create a commit — this command only reviews.
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
4. Prioritize by impact. One structural issue outweighs ten nits.
5. Missing tests are an issue, not a suggestion. If tests are missing or inadequate for new functionality, report it as a severity-tagged finding in the findings sections below — High severity (code) or Required (plan) — never as a recommendation.
## Context
$ARGUMENTS
## Expected Format — Code Review
```
## Review Summary
[1-2 sentences on what the change does and overall assessment]
## Critical/High Findings
### [Severity] file.ts:123
**Issue**: [Description of the problem]
**Impact**: [What could go wrong if this is not fixed]
**Fix**:
````[language]
// Current code
[problematic code]
// Fixed code
[corrected code]
[Optional: note trade-offs if multiple approaches exist]
````
### [Severity] file.ts:456
**Issue**: [Description of the problem]
**Impact**: [What could go wrong if this is not fixed]
**Fix**: [Clear description of the fix; code snippet if it clarifies]
@ -19,9 +19,18 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef
- 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:
Every review evaluates code across these dimensions.
### 1. Correctness
@ -39,14 +48,13 @@ Can another engineer (or agent) understand this code without the author explaini
- Are names descriptive and consistent with project conventions? (No `temp`, `data`, `result` without context)
- Is the control flow straightforward (avoid nested ternaries, deep callbacks)?
- Is the code organized logically (related code grouped, clear module boundaries)?
- Are there any "clever" tricks that should be simplified?
- **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)
- Would comments help clarify non-obvious intent? (But don't comment obvious code.)
- Are there dead code artifacts: no-op variables (`_unused`), backwards-compat shims, or `// removed` comments?
- **Is a new conditional bolted onto an unrelated flow?** That's a design smell, not a nit — push the logic into its own helper, state, or policy instead of tangling an existing path.
- **Do repeated conditionals on the same shape appear?** They signal a missing model or dispatcher. A "temporary" branch is usually permanent debt.
- **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
@ -54,16 +62,17 @@ 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?
- Is there code duplication that should be shared?
- **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 to follow the change. If a "cleaner" version leaves that count unchanged, it isn't cleaner — prefer the restructuring that makes whole branches, modes, or layers 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?** Keep logic in its owning layer, reuse the existing canonical helper instead of a near-duplicate, and don't normalize architectural drift.
- **Are type boundaries explicit?** Question gratuitous `any`/`unknown`/optional/casts and silent fallbacks that paper over an unclear invariant — making the boundary explicit often makes the surrounding control flow simpler.
- 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`. Does the change introduce vulnerabilities?
For detailed security guidance, see `security-and-hardening`.
- Is user input validated and sanitized?
- Are secrets kept out of code, logs, and version control?
@ -72,12 +81,9 @@ For detailed security guidance, see `security-and-hardening`. Does the change in
- 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?
- Are external data flows validated at system boundaries before use in logic or rendering?
### 5. Performance
Does the change introduce performance problems?
- Any N+1 query patterns?
- Any unbounded loops or unconstrained data fetching?
- Any synchronous operations that should be async?
@ -85,24 +91,66 @@ Does the change introduce performance problems?
- Any missing pagination on list endpoints?
- Any large objects created in hot paths?
## Structural Remedies
## Review Process
When you flag a structural problem, propose the move — not just the problem. A review that only says "this is complex" leaves the author guessing. Reach for a named restructuring:
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:
- **Replace a chain of conditionals** with a typed model or an explicit dispatcher.
- **Collapse duplicate branches** into a single clearer flow.
- **Separate orchestration from business logic** so each reads on its own.
- **Move feature-specific logic** out of a shared module into the package that owns the concept.
- **Reuse the canonical helper** instead of a bespoke near-duplicate.
- **Make a type boundary explicit** so downstream branching disappears.
- **Delete a pass-through wrapper** that adds indirection without clarifying the API.
- **Extract a helper, or split a large file** into focused modules.
| **Suggestion:** | Worth considering | Not required, but improves the code |
Prefer the remedy that removes moving pieces over one that spreads the same complexity around.
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. Target these sizes:
Small, focused changes are easier to review, faster to merge, and safer to deploy.
```
~100 lines changed → Good. Reviewable in one sitting.
@ -110,11 +158,9 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
~1000 lines changed → Too large. Split it.
```
**Watch file size, not just diff size.** A small diff can still push a file past a healthy boundary — around 1000 *total* lines in a single file (distinct from the ~1000 *changed*-lines threshold above) is a common inspection signal, not a hard cap. When a change materially grows an already-large file, ask whether to extract helpers, subcomponents, or modules *first*, before piling more on. Decompose, then add.
**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.
**What counts as "one change":** A single self-contained modification that addresses one thing, includes related tests, and keeps the system functional after submission. One part of a feature — not the whole feature.
**Splitting strategies when a change is too large:**
**Splitting strategies:**
| Strategy | How | When |
|----------|-----|------|
@ -123,164 +169,17 @@ Small, focused changes are easier to review, faster to merge, and safer to deplo
| **Vertical** | Break into smaller full-stack slices of the feature | Feature work |
**When large changes are acceptable:** Complete file deletions and automated refactoring where the reviewer only needs to verify intent, not every line.
**Separate refactoring from feature work.** A change that refactors existing code and adds new behavior is two changes — submit them separately. Small cleanups (variable renaming) can be included at reviewer discretion.
**Separate refactoring from feature work.** A change that refactors and adds new behavior is two changes — submit them separately.
## Change Descriptions
Every change needs a description that stands alone in version control history.
- **First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC."
- **Body:** What is changing and why. Include context and reasoning not visible in the code itself.
**First line:** Short, imperative, standalone. "Delete the FizzBuzz RPC" not "Deleting the FizzBuzz RPC." Must be informative enough that someone searching history can understand the change without reading the diff.
## Dependencies
**Body:** What is changing and why. Include context, decisions, and reasoning not visible in the code itself. Link to bug numbers, benchmark results, or design docs where relevant. Acknowledge approach shortcomings when they exist.
**Anti-patterns:** "Fix bug," "Fix build," "Add patch," "Moving code from A to B," "Phase 1," "Add convenience functions."
## Review Process
### Step 1: Understand the Context
Before looking at code, understand the intent:
```
- What is this change trying to accomplish?
- What spec or task does it implement?
- What is the expected behavior change?
```
### Step 2: Review the Tests First
Tests reveal intent and coverage:
```
- Do tests exist for the change?
- Do they test behavior (not implementation details)?
- Are edge cases covered?
- Do tests have descriptive names?
- Would the tests catch a regression if the code changed?
```
### Step 3: Review the Implementation
Walk through the code with the five axes in mind:
```
For each file changed:
1. Correctness: Does this code do what the test says it should?
2. Readability: Can I understand this without help?
3. Architecture: Does this fit the system?
4. Security: Any vulnerabilities?
5. Performance: Any bottlenecks?
```
### Step 4: 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 merge |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
This prevents authors from treating all feedback as mandatory and wasting time on optional suggestions.
**Lead with what matters.** Order findings by leverage: correctness and security first, then structural regressions and missed simplifications, then everything else. Don't bury a real issue under cosmetic nits — a few high-conviction comments beat a long list. If you have one structural problem and ten nits, the structural problem *is* the review.
### Step 5: Verify the Verification
Check the author's verification story:
```
- What tests were run?
- Did the build pass?
- Was the change tested manually?
- Are there screenshots for UI changes?
- Is there a before/after comparison?
```
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code
│
▼
Model B reviews for correctness and architecture
│
▼
Model A addresses the feedback
│
▼
Human makes the final call
```
This catches issues that a single model might miss — different models have different blind spots.
**Example prompt for a review agent:**
```
Review this code change for correctness, security, and adherence to
our project conventions. The spec says [X]. The change should [Y].
Flag any issues as Critical, Required, Optional, or Nit.
```
## Dead Code Hygiene
After any refactoring or implementation change, check for orphaned code:
1. Identify code that is now unreachable or unused
2. List it explicitly
3. **Ask before deleting:** "Should I remove these now-unused elements: [list]?"
Don't leave dead code lying around — it confuses future readers and agents. But don't silently delete things you're not sure about. When in doubt, ask.
```
DEAD CODE IDENTIFIED:
- formatLegacyDate() in src/utils/date.ts — replaced by formatDate()
- OldTaskCard component in src/components/ — replaced by TaskCard
- LEGACY_API_URL constant in src/config.ts — no remaining references
→ Safe to remove these?
```
## Review Speed
Slow reviews block entire teams. The cost of context-switching to review is less than the waiting cost imposed on others.
- **Respond within one business day** — this is the maximum, not the target
- **Ideal cadence:** Respond shortly after a review request arrives, unless deep in focused coding. A typical change should complete multiple review rounds in a single day
- **Prioritize fast individual responses** over quick final approval. Quick feedback reduces frustration even if multiple rounds are needed
- **Large changes:** Ask the author to split them rather than reviewing one massive changeset
## Handling Disagreements
When resolving review disputes, apply this hierarchy:
1. **Technical facts and data** override opinions and preferences
2. **Style guides** are the absolute authority on style matters
3. **Software design** must be evaluated on engineering principles, not personal preference
4. **Codebase consistency** is acceptable if it doesn't degrade overall health
**Don't accept "I'll clean it up later."** Experience shows deferred cleanup rarely happens. Require cleanup before submission unless it's a genuine emergency. If surrounding issues can't be addressed in this change, require filing a bug with self-assignment.
## Honesty in Review
When reviewing code — whether written by you, another agent, or a human:
- **Don't rubber-stamp.** "LGTM" without evidence of review helps no one.
- **Don't soften real issues.** "This might be a minor concern" when it's a bug that will hit production is dishonest.
- **Quantify problems when possible.** "This N+1 query will add ~50ms per item in the list" is better than "this could be slow."
- **Push back on approaches with clear problems.** Sycophancy is a failure mode in reviews. If the implementation has issues, say so directly and propose alternatives.
- **Accept override gracefully.** If the author has full context and disagrees, defer to their judgment. Comment on code, not people — reframe personal critiques to focus on the code itself.
## Dependency Discipline
Part of code review is dependency review:
**Before adding any dependency:**
Before adding any dependency:
1. Does the existing stack solve this? (Often it does.)
2. How large is the dependency? (Check bundle impact.)
@ -290,67 +189,14 @@ Part of code review is dependency review:
**Rule:** Prefer standard library and existing utilities over new dependencies. Every dependency is a liability.
**Upgrading an existing dependency** is a code change like any other, and the riskiest upgrades are the ones merged in bulk with a message like "bump deps." Review them with the same discipline:
**Upgrading dependencies:**
1. **Read the changelog, not just the version number.** Semver is a promise the maintainer may not have kept — a "patch" can carry a behavioral change. For a major bump, read the migration notes and find what breaks.
2. **One dependency per change.** Upgrade and merge them individually (or in small related groups). When a bulk bump breaks the build, you've lost which package did it; a single-package change makes the cause obvious and the revert clean.
3. **Let the tests decide.** The upgrade is verified by a green suite before *and* after, not by "it installed." If coverage around the dependency's behavior is thin, that gap is the real finding — add a test first.
4. **Mind the transitive graph.** Most installed packages are ones nobody chose directly. Review the lockfile diff, not just `package.json`; a single direct bump can pull in dozens of indirect changes.
5. **Keep the lockfile honest.** Commit it, review its diff, and never hand-edit it. The lockfile is the thing that actually pins what ships.
- 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 triaging `npm audit` findings and supply-chain risk (typosquatting, compromised maintainers), follow the `security-and-hardening` skill — this section covers the upgrade *workflow*, that one covers the security verdict.
## The Review Checklist
```markdown
## Review: [PR/Change title]
### Context
- [ ] I understand what this change does and why
### Correctness
- [ ] Change matches spec/task requirements
- [ ] Edge cases handled
- [ ] Error paths handled
- [ ] Tests cover the change adequately
### Readability
- [ ] Names are clear and consistent
- [ ] Logic is straightforward
- [ ] No unnecessary complexity
### Architecture
- [ ] Follows existing patterns
- [ ] No unnecessary coupling or dependencies
- [ ] Appropriate abstraction level
- [ ] Refactors reduce complexity rather than relocate it
- [ ] No feature logic in shared modules; file stays within a healthy size
### Security
- [ ] No secrets in code
- [ ] Input validated at boundaries
- [ ] No injection vulnerabilities
- [ ] Auth checks in place
- [ ] External data sources treated as untrusted
### Performance
- [ ] No N+1 patterns
- [ ] No unbounded operations
- [ ] Pagination on list endpoints
### Verification
- [ ] Tests pass
- [ ] Build succeeds
- [ ] Manual verification done (if applicable)
### Verdict
- [ ] **Approve** — Ready to merge
- [ ] **Request changes** — Issues must be addressed
```
## See Also
- For detailed security review guidance, see `security-and-hardening`
For supply-chain risk triage, follow the `security-and-hardening` skill.
## Common Rationalizations
@ -358,13 +204,16 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
|---|---|
| "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. Require cleanup before merge, not after. |
| "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 problems, security issues, or readability concerns. |
| "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 — look for the version where branches disappear. |
| "It's only a small addition to this file" | Small diffs still push files past a healthy size and bolt branches onto unrelated flows. Judge the resulting structure, not the diff size. |
| "It's just a version bump" | A bump is a behavior change you didn't write. Read the changelog; semver doesn't guarantee no breakage. |
| "I'll upgrade everything in one PR to save time" | A bulk bump that breaks the build hides which package did it. One dependency per change keeps the cause and the revert clean. |
| "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
@ -374,14 +223,11 @@ For triaging `npm audit` findings and supply-chain risk (typosquatting, compromi
- 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
- Review comments without severity labels — makes it unclear what's required vs optional
- 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
- A change that grows an already-large file instead of decomposing it
- New conditionals scattered into unrelated code paths (a missing abstraction)
- A bespoke helper that duplicates an existing canonical one, or feature logic placed in a shared module
- A bulk "bump dependencies" PR with no changelog review and no per-package isolation
- A lockfile change that's hand-edited, uncommitted, or merged without reviewing its diff
- A bespoke helper that duplicates an existing canonical one
- A bulk "bump dependencies" PR with no changelog review
## Verification
@ -392,6 +238,18 @@ After review is complete:
- [ ] Tests pass
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades were reviewed against their changelog, isolated per package, and verified by a green suite with the lockfile diff reviewed
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
**Presumptive blockers:** surface and propose the simpler design for each of these; escalate to Required only when the change actively makes structure worse: a refactor that relocates complexity instead of reducing it; a change that pushes a file past the size boundary with no decomposition; feature logic added to a shared module; a near-duplicate of an existing canonical helper; a silent fallback that hides an unclear invariant.
## 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: 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-and-quality` 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-and-quality` — also the criteria source for axis 6
- For security-specific concerns, see `security-and-hardening`
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.
* **Formatting:**`cljfmt check src/ test/` to check, `cljfmt fix src/ test/` 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
@ -107,4 +108,3 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. J
* **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.
- 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.
@ -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.
## Port layout
@ -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`.
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)
- **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.
- 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.
@ -137,17 +137,32 @@ E2E tests should not be added unless explicitly requested.
## Execution discipline
When running CLJS/JS tests (frontend, common):
**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.
- **Never pipe test output through `tail`, `head`, or similar filters** — doing so can silently hide test failures. Use `--focus` to narrow scope instead.
- **If you need to filter output, tee to a temp file first:**`pnpm run test:quiet 2>&1 | tee /tmp/penpot-test-output.txt`. The full output is preserved on disk so you can `grep`/`tail`/`head` the file without re-running.
- 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).
- The same no-piping rule applies: use `--focus` to narrow scope.
The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
@ -59,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.
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
- Fix synced component copy not reflowing children after spacing token update [#9892](https://github.com/penpot/penpot/issues/9892)
- Fix spacebar activating pan mode while typing a comment (by @Krishcode264) [#10285](https://github.com/penpot/penpot/issues/10285) (PR: [#10287](https://github.com/penpot/penpot/pull/10287))
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
### :sparkles: New features & Enhancements
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
## 2.17.1
### :bug: Bugs fixed
- Fix overrides lost after switching component variant [#10588](https://github.com/penpot/penpot/issues/10588) (PR: [#10619](https://github.com/penpot/penpot/pull/10619))
- Fix malformed get-font-variants request when team-id is missing from dashboard URL [#10644](https://github.com/penpot/penpot/issues/10644) (PR: [#10645](https://github.com/penpot/penpot/pull/10645))
- Fix malformed get-profiles-for-file-comments request when file-id is missing from workspace URL [#10652](https://github.com/penpot/penpot/issues/10652) (PR: [#10655](https://github.com/penpot/penpot/pull/10655))
- Fix internal error when dragging inner layout with Boolean operations [#10647](https://github.com/penpot/penpot/issues/10647) (PR: [#10778](https://github.com/penpot/penpot/pull/10778))
- Fix frontend throwing raw TypeError on undefined .getData receivers across import, paste, drag, and text editor paths [#10709](https://github.com/penpot/penpot/issues/10709) (PR: [#10718](https://github.com/penpot/penpot/pull/10718))
- Fix workspace crash with 'can't access dead object' in Firefox when navigating between pages [#10719](https://github.com/penpot/penpot/issues/10719) (PR: [#10721](https://github.com/penpot/penpot/pull/10721))
- Fix workspace crash when holding an arrow key on a selection due to excessive re-renders [#10726](https://github.com/penpot/penpot/issues/10726) (PR: [#10736](https://github.com/penpot/penpot/pull/10736))
- Fix asset download failing with S3 auth conflict when using access token [#10776](https://github.com/penpot/penpot/issues/10776) (PR: [#10777](https://github.com/penpot/penpot/pull/10777))
- Fix import worker crashing when importing non-Penpot zip files [#10781](https://github.com/penpot/penpot/issues/10781) (PR: [#10782](https://github.com/penpot/penpot/pull/10782))
- Fix viewer crash with WASM panic when opening URL with page-id [#10800](https://github.com/penpot/penpot/issues/10800) (PR: [#10805](https://github.com/penpot/penpot/pull/10805))
- Fix backend returning 500 when JSON request body has unrecognized escape sequence [#10804](https://github.com/penpot/penpot/issues/10804) (PR: [#10808](https://github.com/penpot/penpot/pull/10808))
- Fix color picker eyedropper crashing when viewport is unmounted during pointer move [#10811](https://github.com/penpot/penpot/issues/10811) (PR: [#10812](https://github.com/penpot/penpot/pull/10812))
- Fix flex layout crash when dragging shapes with missing bounds [#10843](https://github.com/penpot/penpot/issues/10843) (PR: [#10845](https://github.com/penpot/penpot/pull/10845))
- Fix export failing when shape has blank layer name [#10849](https://github.com/penpot/penpot/issues/10849) (PR: [#10852](https://github.com/penpot/penpot/pull/10852))
- Fix area selection (marquee) being aborted by select-shapes interrupt [#10872](https://github.com/penpot/penpot/issues/10872) (PR: [#10870](https://github.com/penpot/penpot/pull/10870))
- Fix gradient editor sending invalid stop offset when clicking outside gradient line [#10879](https://github.com/penpot/penpot/issues/10879) (PR: [#10881](https://github.com/penpot/penpot/pull/10881))
- Fix audit event validation failing when error reports contain string profile-id and missing token context [#10897](https://github.com/penpot/penpot/issues/10897) (PR: [#10898](https://github.com/penpot/penpot/pull/10898))
- Fix MCP tool call timeout being too low for some operations [#10953](https://github.com/penpot/penpot/issues/10953) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix MCP requests running into timeouts after leaving a file in Penpot [#10958](https://github.com/penpot/penpot/issues/10958) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
- Fix duplicate WebSocket MCP connection attempts deregistering the original connection's routing entries [#10961](https://github.com/penpot/penpot/issues/10961) (PR: [#10967](https://github.com/penpot/penpot/pull/10967))
## 2.17.0
### :rocket: Epics and highlights
@ -36,49 +86,28 @@
- Render guides in WebGL [#10068](https://github.com/penpot/penpot/issues/10068) (PR: [#10014](https://github.com/penpot/penpot/pull/10014))
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Add color variants and positioning to selection size badge [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
- Add separate internal URI for exporter to handle Docker deployments where internal and public URIs differ [#10627](https://github.com/penpot/penpot/issues/10627) (PR: [#10630](https://github.com/penpot/penpot/pull/10630))
### :bug: Bugs fixed
- Fix LDAP provider params schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration; the schema slot now matches the runtime key actually read by `prepare-params` (`:password (:bind-password cfg)`) and `try-connectivity` (`(:bind-password cfg)`), so a wrong type for the password no longer slips through unvalidated
- Fix `login-with-ldap` silently dropping its error message on the `ldap-not-initialized` restriction (typo `:hide` → `:hint`); the message `"ldap auth provider is not initialized"` now actually surfaces in logs and error responses instead of being discarded into an unread key
- Fix `get-view-only-bundle` crashing when a share-link viewer encounters a team member whose email lacks `@` (NullPointerException in `obfuscate-email`) or whose domain has no `.` (previously produced a dangling-dot `****@****.`); now the viewer-side obfuscation is nil-safe and omits the trailing dot when the domain has no TLD
- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838)
- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582)
- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526)
- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534)
- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924)
- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639)
- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786)
- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841)
- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631)
- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906)
- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871)
- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923)
- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930)
- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628)
- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959)
- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960)
- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984)
- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990)
- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057)
- Fix Plugin API variant creation failing due to undocumented multi-step workflow [#10075](https://github.com/penpot/penpot/issues/10075) (PR: [#10149](https://github.com/penpot/penpot/pull/10149))
- Fix workspace crash when editing text shapes with degenerate selrect [#10617](https://github.com/penpot/penpot/issues/10617) (PR: [#10618](https://github.com/penpot/penpot/pull/10618))
- Fix SVG stroke line join not applied when pasting strokes [#4836](https://github.com/penpot/penpot/issues/4836) (PR: [#9982](https://github.com/penpot/penpot/pull/9982), [#10019](https://github.com/penpot/penpot/pull/10019))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @jack-stormentswe) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix blend-mode hover preview on canvas not reverted when dismissing dropdown (by @davidv399) [#9235](https://github.com/penpot/penpot/issues/9235) (PR: [#9237](https://github.com/penpot/penpot/pull/9237))
- Fix View Mode mouse-leave and click in combination not working [#4855](https://github.com/penpot/penpot/issues/4855) (PR: [#9991](https://github.com/penpot/penpot/pull/9991))
- Fix font selector missing intermediate font weights for Source Sans Pro and similar fonts (by @dhgoal) [#7378](https://github.com/penpot/penpot/issues/7378) (PR: [#9247](https://github.com/penpot/penpot/pull/9247))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @leonaIee) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API `typography.remove()` passing wrong parameter format (by @peter-rango) [#8223](https://github.com/penpot/penpot/issues/8223) (PR: [#9279](https://github.com/penpot/penpot/pull/9279))
- Fix plugin API fills and strokes array elements being read-only (by @RenzoMXD) [#8357](https://github.com/penpot/penpot/issues/8357) (PR: [#9161](https://github.com/penpot/penpot/pull/9161))
- Fix "Show Guides" shortcut not working on German keyboards (by @RenzoMXD) [#8423](https://github.com/penpot/penpot/issues/8423) (PR: [#9209](https://github.com/penpot/penpot/pull/9209))
- Fix token validation failing when a malformed token exists in the Component category [#9010](https://github.com/penpot/penpot/issues/9010) (PR: [#9025](https://github.com/penpot/penpot/pull/9025), [#9825](https://github.com/penpot/penpot/pull/9825))
- Fix MCP media upload error and SVG data URI image parsing (by @claytonlin1110) [#9164](https://github.com/penpot/penpot/issues/9164) (PR: [#9201](https://github.com/penpot/penpot/pull/9201))
- Fix lost-update race on team features during concurrent file creation (by @JPette1783) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @jack-stormentswe) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix lost-update race on team features during concurrent file creation (by @Lobster-0429) [#9197](https://github.com/penpot/penpot/issues/9197) (PR: [#9198](https://github.com/penpot/penpot/pull/9198))
- Fix get-profile RPC method silently masking DB errors as "Anonymous User" (by @davidv399) [#9253](https://github.com/penpot/penpot/issues/9253) (PR: [#9254](https://github.com/penpot/penpot/pull/9254))
- Fix crash when creating or editing tokens named "white" or "black" [#9256](https://github.com/penpot/penpot/issues/9256) (PR: [#9034](https://github.com/penpot/penpot/pull/9034))
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}.
{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:50 }}"{% if organization %}, part of the organization "{{ organization.name|abbreviate:50 }}"{% endif %}.
{% if organization.sso-active %}
"{{ organization.name|abbreviate:50 }}" has set up single sign-on (SSO) in Penpot. Access to its teams and files now goes
through your organization's identity provider.
If you can't get in, your account probably isn't in the directory yet. To get access, contact the organization owner.
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.