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
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
* 🐛 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>
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>
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
* ✨ 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>
* 🐛 Fix too much recursion when clicking shape in comments mode
Remove `deselect-all` from `handle-interrupt` in comments mode. The
`select-shape` event emits `:interrupt` which the comments stream
watcher routes to `handle-interrupt`. In comments mode, calling
`deselect-all` cleared the selection and emitted a competing
`rt/nav`, creating a synchronous recursion cycle in the potok store
that overflowed the JS call stack.
Fixes#10620
AI-assisted-by: opencode
* 🐛 Add unit tests for comments handle-interrupt
Make `handle-interrupt` public (defn- → defn) and add 4 tests covering
each branch: draft thread, open thread, comments mode, and noop.
AI-assisted-by: opencode
* ✨ Adds static dispatch safe stubs in tests
* 🐛 Fix shapesColors metadata key to match ColorShapeInfo
* 🐛 Fix CommentThread.remove rejecting the owner's own threads
* 🐛 Fix page.removeCommentThread throwing on a spurious Promise
* ✨ Implement ShapeBase.swapComponent in the plugin API
* ✨ Expose File.revn in the plugin API
* 🐛 Fix FileVersion.createdAt calling Luxon method on a js/Date
* 🐛 Fix plugin font/typography application to text and ranges
* 🐛 Default plugin overlay interaction position for non-manual types
* 🐛 Fix plugin interaction setters passing an id-only shape
* 🐛 Fix grid addColumnAtIndex rejecting valid track types
* 🐛 Expose libraryId on library color/typography/component proxies
* ✨ Implement LibraryTypography.setFont in the plugin API
* 🐛 Fix typography.applyToTextRange reading unexposed range bounds
* 🐛 Fix utils.geometry.center argument mismatch
* 🐛 Fix localStorage.removeItem calling getItem
* 🐛 Fix shape backgroundBlur proxy key casing
* 🐛 Report boolean shape type as 'boolean' in the plugin API
* 🐛 Return the resulting paths from plugin flatten
* 🐛 Make plugin z-order methods act on the target shape
* 🐛 Make is-variant-container? return a boolean
* ✨ Implement Group.isMask in the plugin API
* 🐛 Return a shape proxy from TextRange.shape
* 🐛 Return the duplicated set from TokenSet.duplicate
* 🐛 Fix theme addSet/removeSet reading set name with a keyword
* 🐛 Accept string fontFamilies token value in the plugin API
* 🐛 Fix combineAsVariants ignoring the passed component ids
* 🐛 Fix board removeRulerGuide ignoring its argument
* 🐛 Fix board guides setter schema and parser
* 🐛 Avoid 0-byte allocation when syncing empty grid tracks
* 🐛 Validate grid track indices in the plugin API
* 🐛 Return null for empty input in group() and centerShapes()
* 🐛 Return TokenTypographyValue[] from a typography token's resolvedValue
* 🐛 Return TokenShadowValue[] from a shadow token's resolvedValue
* 🐛 Return string[] from a fontFamilies token's resolvedValue
* 🐛 Clear mutually-exclusive reps when setting LibraryColor gradient/image
* 🐛 Add readonly tags to types, deprecate Image type
* 📚 Update plugins changelog
The plugin text API rejected negative letter-spacing even though the
product UI allows -200..200 (typography.cljs). Two defects in
frontend/src/app/plugins/text.cljs:
- `letter-spacing-re` (`#"^\d*\.?\d*$"`) had no provision for a leading
minus, so any negative value failed validation.
- The text-range `:letterSpacing` setter inverted its guard: it used
`(or (empty? value) (re-matches ...))` to mean "invalid", which
rejected matching values and let non-numeric input through. The
text-shape setter and the sibling `lineHeight` range setter both
correctly use `(not (re-matches ...))`.
Fix the regex to allow an optional leading minus and add the missing
`not` so the range setter matches the shape setter. Adds regression
coverage for the regex accept/reject contract.
Fixes#9780
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* ✨ Add MCP connection badge to the workspace toolbar
* ✨ Add MCP status button with single-tab connection control
* ♻️ Extract component for MCP indicator in the toolbar
* ♻️ Some improvements
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Add focused common JavaScript test execution with log-level control and a quiet test wrapper matching the frontend workflow.
Update developer docs and testing memories to reflect the common/frontend test split, and document why runner helper extraction is deferred.
Signed-off-by: Codex <codex@openai.com>
Lets a caller pin `app.common.logging`'s level for the duration of a
test run via `--log-level <trace|debug|info|warn|error>`. The flag is
off by default, so when absent the runner doesn't touch logger state
and stdout looks exactly as before.
When passed, the runner calls `(l/setup! {:app level})` right before
dispatching to the test block, so production code exercised by tests
emits only at the requested level or higher.
pnpm run test:quiet -- --focus frontend-tests.logic.groups-test \
--log-level warn
Composes with `--focus`; the two flags are independent.
Caveats worth knowing: top-level log calls fired at namespace load
time run before the runner parses CLI options and therefore slip past
this flag; direct `println` / `js/console.log` calls bypass the
logging system entirely and are unaffected.
Previously `pnpm run test` always ran the full frontend-tests suite,
which made tight iteration on a single namespace or var painful. The
runner now accepts `--focus <ns>` or `--focus <ns>/<var>` and executes
only the matching tests, preserving each namespace's `:once` and `:each`
fixtures so behavior matches a full-suite run.
pnpm run test -- --focus frontend-tests.logic.groups-test
pnpm run test -- --focus frontend-tests.logic.groups-test/some-test
Also updates the developer guide and the testing memory so the flag is
discoverable from both docs and agent context.
When a plugin call fails malli validation, the frontend renders one
"plugins.validation.message" line per error via
`app.plugins.utils/error-messages`, which reduces the explain via
`csm/interpret-schema-problem` and then destructures each entry as
`[field {:keys [message]}]` for translation.
That works only when the underlying malli error path has a single
element. `interpret-schema-problem` calls `(assoc-in acc field ...)`
where `field` can be a multi-element vector (e.g. `[:sets 0 :name]`).
For single-element paths the resulting map is flat
(`{:group {:message "..."}}`); for multi-element paths it is nested
(`{:sets {0 {:name {:message "..."}}}}`). The destructure assumes the
flat shape, so for a nested error the consumer reads:
field -> :sets
message -> nil (the nested entry has no :message at the top level)
and the produced i18n line resolves to `Field sets is invalid: ` --
or, when several errors are merged together at the same outer key,
to the user-facing `Field message is invalid` that the bug report
calls out, because `:message` then becomes the field name of the
deepest nested entry.
The original consumer carried a `#_(mapcat (comp seq val))` FIXME
that hinted at the missing flattening but did not implement one,
because the data shape produced by `interpret-schema-problem` is
not uniform.
Fix
---
Add a private `flatten-error-map` helper inside `app.plugins.utils`
that walks the error map produced by `interpret-schema-problem` and
yields `[path message]` pairs where `path` is the dot-joined field
path. Keywords use `(name k)`, strings pass through, anything else
(such as numeric indices from vector positions in the malli path)
is coerced via `str`. The recursion descends until it hits a leaf
that carries `:message`, which matches what
`interpret-schema-problem` produces in every branch.
The producer side (`csm/interpret-schema-problem` in
`common/src/app/common/schema/messages.cljc`) is left alone: it
already has another consumer (`collect-schema-errors` + the
form-validators pipeline) that depends on the keyed-by-field-path
shape, so normalising it at the source would require auditing every
validator. Flattening at the plugin consumer is the narrowest fix.
The FIXME comment is removed because the new helper supersedes it.
Tests
-----
`frontend-tests.plugins.utils-test` (new file, registered in
`runner.cljs`) covers:
- flat single-segment paths (`{:group {:message "..."}}`)
- nested multi-segment paths
(`{:sets {0 {:name {:message "..."}}}}`) -- the case from #9417
- mixed single- and multi-segment paths at the same explain
- mixed key types (keyword / string / numeric index)
- empty explain (no validation errors)
Closes#9417
Signed-off-by: bitcompass <devwiz.sh@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
When the Stripe checkout fails to start, the subscription page now
shows an inline error in the Business Nitrate card under the CTA
instead of a toast. When the post-payment activation fails, the toast
message is updated to point users to support@penpot.app.
The nitrate-form modal also passed a URI object to
build-nitrate-callback-urls while the underlying append-query-param
relied on lambdaisland's u/parse, which only accepts strings. Switched
to the local u/uri helper so both strings and URI records work, so
failures opened from the modal land on the subscription page.
* 🐛 Fix Plugin API token application for JS array of strings
Plugin code calling `shape.applyToken(token, ["fill"])` or
`token.applyToShapes([rect], ["fill"])` from JavaScript supplies a JS
array of strings. The plugin proxies expected a Clojure set of
keywords, and two coupled defects made the calls silently no-op (or,
with `throwValidationErrors` enabled, throw "check error"):
1. `token-attr-plugin->token-attr` only consulted its alias map when
the input was already a keyword. String inputs like "fill" fell
through to the identity branch, so the downstream
`cto/token-attr?` predicate (which checks against a set of
keywords) returned false for every string. Coerce strings to
keywords first.
2. The `applyToken` / `applyToShapes` / `applyToSelected` schemas
used plain `[:set ...]`, which has no `:decode/json` transformer
for JS array → Clojure set coercion. Switch to the registered
`[::sm/set ...]` (in `app.common.schema`) which provides the
array → set decoder. After the switch, the standard JSON pipeline
converts `["fill"]` to `#{"fill"}`, then the inner
`[:and ::sm/keyword [:fn token-attr?]]` decodes each element to a
keyword and validates it.
Also extends the docstring on `token-attr-plugin->token-attr` to make
the string-friendly contract explicit, and registers a new
`tokens-test` ns under `frontend/test/frontend_tests/plugins/` with
six `deftest` blocks covering:
- known keywords passing through unchanged
- keyword aliases (`:r1` → `:border-radius-top-left`, etc.)
- string inputs coerced to keywords (regression for #9162)
- `token-attr?` accepting both keyword and string inputs
- `token-attr?` rejecting unknown attrs and nil
Closes#9162
* 🐛 Fix wrong direction in plugin-name alias tests
The added tests in tokens_test.cljs and the new docstring in tokens.cljs
described the alias resolution in the wrong direction. The map is
{:r1 :border-radius-top-left, …} then map-invert'd, so
token-attr-plugin->token-attr maps verbose plugin-side names
(:border-radius-top-left) to canonical internal short names (:r1),
not the other way around. Inputs already in canonical form (:r1, :fill,
"fill", …) pass through unchanged. Flipped the alias-resolution test
expectations and the keyword/string-input cases, refreshed the docstring
and the regression-coverage comment to match.
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The plugin parser's parse-point returned a plain `{:x … :y …}` map,
but shape interaction schemas (for example schema:open-overlay-interaction)
require the attribute to be a `::gpt/point` record. `(instance? Point {:x 0 :y 0})`
is false, so validation silently rejected plugin `addInteraction` calls
that passed `manualPositionLocation`; only a console warning was produced.
Change parse-point to return a `gpt/point` record via `gpt/point`.
All three call sites (parser.cljs:open-overlay, plugins/page.cljs,
plugins/comments.cljs) continue to work because Point records support
the same `:x`/`:y` access plain maps do.
Add a unit test that covers nil input and verifies the returned value
satisfies `gpt/point?`.
Github #8409
Signed-off-by: FairyPigDev <luislee3108@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>