23930 Commits
Author SHA1 Message Date
Alonso Torres 9d08e26cb3 ✨ Add japanese translations (#11922) 2026-09-25 14:57:56 +02:00
Andrey AntukhandSumit Ridhal eec06a5987 🐛 Allow registration with disabled public registration (#11912)
* 🐛 Allow invitation-based registration when disable-registration is set (#5178)

Per documentation, disable-registration 'disables registration
(still enabled for invitations only)'. Two bugs prevented this:

1. verify_token.clj: when processing an invitation token for a
   non-logged-in user with no member-id, the redirect included
   registration-disabled? in its condition, sending invited users
   to the login page instead of the register page.

2. auth.clj validate-register-attempt!: the registration-disabled
   check fired unconditionally before the invitation-token check,
   rejecting the actual register RPC even with a valid invitation.

Fix: in verify_token.clj remove registration-disabled? from the
redirect condition for new-user invitations. In auth.clj restructure
the check as an if/else: with an invitation token, validate the token
and allow registration; without one, enforce the flag as before.

* 🐛 Allow registration with disabled public registration

Allow valid team invitations to create new profiles when public
registration is disabled, while keeping password login and invitation
validation required.

Add backend regression coverage for flag combinations and verify-token
redirects, frontend route coverage, and configuration documentation.
Closes #5178

AI-assisted-by: space-bunny-free

* 🐛 Revalidate active invitation during registration

Require a live, unexpired team invitation before using the
registration exception, and recheck it before creating a profile.
Reuse the same lookup in invitation token verification.

Add regression tests for canceled and expired invitations, the
registration race, and explicit redirect contracts. Update docs
and backend auth guidance.

AI-assisted-by: Space Bunny Free

* 🐛 Lock and normalize invitation registration checks

Lock active invitation rows during transactional registration and
acceptance so cancellations cannot race with profile or membership
creation.

Normalize invitation emails before comparisons and database lookups.
Add concurrency, email casing, and final flag regression tests.

AI-assisted-by: Space Bunny Free

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
2026-09-25 14:13:12 +02:00
Elena Torró 4943285e63 🐛 Fix inner and outer rect strokes ignoring corner radius (#11926) 2026-09-25 14:12:01 +02:00
Luis de Dios 98daf1592d ✨ Apply tokens to stroke per side (#10913)
* ♻️ Derive per-side stroke widths from the side values

The per-stroke `:stroke-per-side` boolean only gated whether the
renderer looked at the four side widths, and the CSS generator used it
to decide whether to emit one `border-width` or four. Comparing the
sides is enough, so drop the attribute from the shape schema and from
the stroke attribute list.

The WASM property and upload bridges and `stroke-per-side-widths` now
derive the per-side widths from the values alone.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Declare per-side stroke width token attributes

Replace the single `:stroke-width` token attribute with
`:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom`
and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map
the new attributes to the strokes shape attribute and to the
dimensions token type.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Unapply only the token of the changed stroke side

A stroke change that reports a single per-side sub-attribute now
resolves to that side's token only. A plain `:stroke-width` change
still resolves to every side, and a change with no sub-attribute
resolves to all width keys plus the color.

Add a regression test that tokens on untouched sides survive a change
to another side.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add a predicate for per-side stroke shapes

Boards and rectangles support independent stroke widths per side.
Expose `per-side-stroke-shape?` so callers can gate the per-side UI,
and cover the supported and unsupported shape types with a test.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add stroke side width materialization helper

`materialize-stroke-side-widths` concretizes the four per-side width
keys from a stroke: edited sides take the new value, the others keep
their current width (0 when there is no stroke), and `:stroke-width`
mirrors the top side for legacy consumers.

This pulls the logic already duplicated in the token apply path into a
shared helper, ready for the direct-edit path.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Apply a stroke width token to every side

`update-stroke-width` now writes the four per-side width keys, both
when the shape already has a stroke and when it gets a new default
one, so the applied-token bookkeeping matches the stroke attributes.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Apply a stroke width token to a single side

Add `update-stroke-width-side`, which changes only the sides named in
`attributes` on the first stroke of each shape. The remaining sides
keep their current width (0 when the shape had no stroke) and all side
keys are materialized through the shared helper, so consumers never
fall back to `:stroke-width`.

Route the per-side token keys to the new function and update the
apply, remap and component tests.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Complete a partially applied per-side token on toggle

When explicit attributes come from an input or a plugin call, toggle
the token off only if it already covers every target attribute on every
selected shape. A partial per-side application is completed instead of
removed. The token pill keeps the previous any-attribute behavior.

Add tests for both the completion and the full removal.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Offer per-side stroke width actions in the token menu

Boards and rectangles get a stroke width submenu with an all-sides
action and one action per side; other shapes keep the single global
action. The global action targets every per-side attribute so the
design tab keeps showing the token on each side.

Add the pill labels for the new attributes, the menu test, and the
`workspace.tokens.stroke-width` string.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Persist the per-side stroke preference

Add `:stroke-per-side` to the user profile props schema and expose it
through a derived `stroke-per-side` ref. The design tab will read the
preference from here instead of a per-stroke attribute.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add per-side stroke width helpers to the stroke menu

Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which
checks the feature flag and that a single board or rectangle, or a
uniform multi-selection of them, is selected. Use it instead of the
inline shape-type check and drop the old per-side toggle handler.

Cover both helpers with a test.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add per-side stroke width token inputs to the design tab

Turn the four side width fields into token inputs with detach actions
and a `:multiple` mixed value when the sides differ. The per-side
toggle now reads the persisted profile preference instead of a
per-stroke attribute, so previous per-side edits survive.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Materialize stroke sides on direct width edit

The stroke menu per-side handler only wrote the edited side key and,
for the top side, the global `:stroke-width`. A stroke holding just
`:stroke-width` made every consumer fall back to the global value, so
editing one side changed all the others.

Add a `change-stroke-side-width` event that materializes the four side
keys through the shared helper and reports only the edited key as
changed, so tokens on untouched sides are not unapplied. Route the menu
handler through it.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Make stroke width fields non nullable

Drop `:nillable` from the global and per-side stroke width inputs and
use `:multiple` for the mixed state, so an empty field no longer
represents a null width.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Fix the numeric-input props schema key

The schema declared `:applied-token`, but the component body and every
caller use `:applied-token-name`, so the prop was never validated.
Rename the schema entry to match.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add token-disabled support to the numeric input

The design-system numeric input accepts `:token-disabled` and
`:token-tooltip`; the token button is disabled and shows the reason.
Scope the disabled input style to `input:disabled` so a disabled token
button no longer dims the whole field. Pass both props through the
token wrapper.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Disable token controls below the first fill or stroke

Design tokens only apply to the first fill or stroke of a shape. Add
`tokens-allowed-position?` and mark the fill and stroke lists with
`tokens-first-only`, so later entries disable their token controls and
explain why. The colorpicker opens on the direct color tab and disables
the token tab for those rows.

Cover the helper with a test and add the new translation.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Refactor colorpicker style switcher to DS radio buttons

Replace the legacy `components/radio-buttons` markup in the colorpicker
with the design system `radio-buttons*`, using its declarative options
API. Switching between direct color and token mode now passes string
values, as the DS component expects.

The previous keyword values broke the round trip back to color mode:
the DOM stringifies keywords with a leading colon, so the value never
matched `:direct-color`. Using plain strings keeps the conversion
clean.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add playwright tests

* ✨ Scope per-side stroke controls to each stroke

Give every stroke row its own expanded state instead of sharing one
profile-wide preference. The state lives in `:workspace-local`, keyed by
`[ids index]`, so it survives selecting another shape and coming back but
resets on reload.

Remove the `:stroke-per-side` profile prop and its ref. The ref now derives
from `:workspace-local`.

Update the Playwright spec to expand the controls per stroke through the
toggle, and assert that strokes toggle independently, that the state resets
on reload, and that it survives switching shapes.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Keep stroke tokens when editing or removing later strokes

The token unapply logic decided which tokens to clear from the shape
using only the changed sub-attributes, without knowing which stroke was
edited. Since stroke tokens only live on the first stroke, editing or
removing a later stroke cleared the first stroke's tokens.

Add a `:changed-item-index` option to `generate-update-shapes` and skip
unapplying fill/stroke tokens when the changed item is not the first.
The stroke color, attrs, side-width and remove events now report the
index they touch.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Ignore token shortcuts when tokens are disabled for input

The numeric input opened the token dropdown on `{` regardless of
`token-disabled?`, so inputs that cannot hold tokens (for example,
strokes after the first one) still opened it, and typing `{token}` plus
`}` could apply a token there.

Extract the key handling into `token-shortcut`, which returns nil when
tokens are disabled, and use it for both `{` and `}`.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Gate per-side stroke tokens on the WASM renderer

The token context menu offered per-side stroke width actions whenever
the feature flag was on and the shape was a board or rectangle, without
checking the renderer. The classic renderer only draws the single
`:stroke-width`, so applying a per-side token there wrote inert data,
the token pill reported it, and the stroke changed appearance when the
WASM renderer was later enabled.

Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from
both the design tab and the token context menu. Thread the renderer
flag into the context menu through `:render-wasm`.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Keep first-stroke tokens when reordering later strokes

Fill and stroke tokens only ever live on the first item of the
collection. When a stroke update arrives without a changed item
index (for example reordering the second and third strokes), the
unapply logic assumed the first item had been edited and removed
every stroke token from the shape.

Compare the first item before and after the update instead: when
no item index is given, unapply only if the first item actually
changed. Reordering later strokes now leaves the first stroke and
its tokens untouched, while moving the first stroke away still
detaches them. Explicit item edits keep their previous behavior.

AI-assisted-by: deepseek-v4-flash
2026-09-25 13:09:55 +02:00
Alejandro Alonso ec5a1edbed ✨ Make export follow the active renderer only (#11910)
Drop the separate :wasm-export flag and wasm-export/v1 feature. Single
export, clipboard PNG, plugins, and batch :is-wasm now key off
render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM
and always keeps a worker pool ready.
2026-09-25 12:40:56 +02:00
Elena Torró 63baf86152 🐛 Fix groups, masks and booleans drop shadow cases (#11911)
* 🐛 Fix blocky and black drop shadows on masked groups

* 🐛 Fix drop shadow spread on paths, bools and circles
2026-09-25 11:50:19 +02:00
Miguel de Benito Delgado fa81a3f648 🐛 Refactor batch serialization and fix derived svg-attrs in exporter (#11909)
* ♻️ Share structural batch upload through common helper

- Merge svg-filters and svg-fills to app.common.render-wasm.svg-derived
- Add serialize-shapes-batch! in common, shared by the sync and chunked
  workspace paths
- Add a routing test for the helper and wires the svg-filters test.

AI-assisted-by: muse-spark, GLM 5.3

* 🐛 Derive SVG effects inside single-shape serializer

- Single and batch paths: one svg effect derivation step
  owned by shared serializers.
- set-object forwards the derived shape to its host attrs,
  and the exporter reads the derived fills, so SVG-attr
  fills, blur and shadow render as in the frontend.
- Adds regression test to the exporter.

AI-assisted-by: muse-spark, GLM 5.3, deepseek-flash
2026-09-25 11:48:21 +02:00
Marina LópezandAndrey Antukh 0255bed6c4 ✨ Add deployment info to events (#11867)
* ✨ Add deployment info to events

* ✨ Add get-environment-data RPC method

Add a single public RPC method returning the deployment type and
the enabled environment flags. It replaces get-deployment on the
management API and get-enabled-flags on the main API.

get-enabled-flags stays as a deprecated alias returning only the
flags, so existing callers keep working until it is removed.

The frontend event initialization now reads the flags from the new
method. The exposed flags stay limited to audit-log and telemetry
to avoid leaking internal backend flags.

AI-assisted-by: deepseek-v4.1-flash

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-25 11:22:43 +02:00
Andrey Antukh 4b978767ea 🌐 Clean up en translations (#11853)
Drop 277 keys nothing references from en.po (verified against
frontend/src and common/src) and let sync propagate the
deletions to every locale. Clear all 10 fuzzy entries: fill
the 5 empty translations, keep the 4 valid ones, and drop the
duplicated max-quote-reached in favor of max-quota-reached
(the backend code stays, the UI maps it to the quota text).

Recover 22 used-but-missing keys with translations: the 19
shortcuts section/subsection labels plus connected-to,
pixel-grid-color and tokens.add-set. Make the rest
statically visible to rehash instead: :label fns on shortcut
commands, sections and subsections (one debug-only and one
colorpicker-local id exempt); case branches in place of
dm/str-built keys (export modal, text decoration and
transform, undo history with raw-key fallback); hoist
conditionals out of tr calls; pre-translate modal props and
role labels; replace the lone (i18n/tr ...) site with tr.
Turn static :error/code data into eager :error/fn calls in
the common schemas and the auth/password forms. Rename the
two keys containing spaces and point team leave at
max-quota-reached. Backend-driven keys stay dynamic by
design, declared with (tr ...) comments: the five
weak-password details, team and organization notifications.

Tooling: rehash also scans common/src and no longer treats
a missing -l as no locale; new clj-kondo tr-dynamic warning
flags non-literal tr args (lint scripts use --fail-level
error so it never fails CI); tr docstring states the
literal-only rule. Tests cover the shortcut label wiring,
the undo-history fallback and the :error/fn schemas.
Translations memory rewritten to match; es check word list
gains three entries.

Rebased onto develop: adopt the register field-error UX
(the weak-password declarations move onto the :options
code), keep develop's newer keys (connection-error,
account-locked, save-retrying, tokens-source strings) with
fresh references, and reword the shortcuts.cljs prose
comment so rehash does not invent a "literal" key.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-25 11:11:44 +02:00
Marina López 76b9c1c859 ✨ Remove team design review (#11881) 2026-09-25 10:42:29 +02:00
María Valderrama f35e12f716 🐛 Fix organization/team switcher issues from UX review (#11899)
* 🐛 Fix organization/team switcher issues from UX review

* 🐛 Let members leave an organization without SSO credentials

* 🐛 Fix style from previewed organization in the team switcher

* 🐛 Fix leave-organization modal test stubs
2026-09-25 09:54:58 +02:00
Andrey Antukh dfd28b1e57 🐛 Move legacy background blur out of the layer blur attribute (#11908)
Before background blur got its own shape attribute, the `:blur` attribute
accepted both `:layer-blur` and `:background-blur` types, so the editor and
the plugin API could save a background blur under `:blur`. The shape schema
was later tightened to only allow `:layer-blur` on `:blur`, but no migration
moved the existing values, so those files fail server schema validation.

Add migration 0029: when a shape has a `:blur` map with `:type
:background-blur`, move it to the `:background-blur` attribute. When the
shape already has a `:background-blur`, keep it and drop the mis-typed
`:blur`. The migration walks both pages and components.

Closes #11904

AI-assisted-by: deepseek-v4.1-flash
2026-09-25 09:49:53 +02:00
Andrey Antukh 5ef70c7284 ✨ Serialize render-wasm builds per checkout (#11903)
Protect shared setup, compilation, artifact copy, and target cleanup with
one flock lock per checkout. Route watch builds and frontend cleanup
through the protected scripts.

Document the lock contract and normalize the frontend and exporter
build:wasm commands.

Closes #11901

AI-assisted-by: Space Bunny Free
2026-09-25 09:42:27 +02:00
Eva Marco e4a8aa5c62 ✨ Improvements on the dimension badge (#11884)
* 🎉 Add flip option to measures badge

* 🎉 Show dimension badge while resizing

* 🎉 Show dimension badge while moving

* 🎉 Hide badge when is smaller than shape

* ♻️ Clean format

* ♻️ Reduce comments

* ♻️ Add memoization to selected-shapes
2026-09-25 09:37:29 +02:00
Yuito Akatsuki (Tani Yutaka) aeeca59b1a 🐛 Fix shared library deletion with storage-backed file data
Inject the storage component into the delete-object task handler and
validate it on initialization, so library absorption no longer fails with
"expected valid storage to be provided" for storage-backed file data.

Also strengthen the regression test: it now creates a real component in
the shared library and an instance in the referencing file, and verifies
the component is absorbed after deletion instead of only checking the
revision bump. The test disables the fdata/pointer-map feature to stay
focused on the storage fix.

Signed-off-by: Yuito Akatsuki (Tani Yutaka) <yuito@yuito-it.jp>
AI-assisted-by: deepseek-v4.1-flash
2026-09-24 18:17:07 +02:00
Andrey Antukh de14311ce7 ⚡ Add xf:add-index and memoize interactions menu rendering (#11915)
Introduce a shared xf:add-index transducer in app.common.data that
attaches the position to each item, and cover it with unit tests.

Use it in the workspace interactions menu: the indexed interactions
list is now derived in a memoized step keyed on the interactions
prop, so it is not rebuilt when the section is collapsed or
expanded. The previous code called d/enumerate on every render.

Update the frontend UI conventions memory with the pattern and the
constraint that the transducer only works on associative items.

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 18:09:49 +02:00
Andrey Antukh cdb0b3950d Merge remote-tracking branch 'origin/staging' into develop 2026-09-24 18:07:35 +02:00
abb4e00746 🐛 Show loader instead of empty state while libraries load (#11594)
The libraries dashboard rendered the "no shared libraries" placeholder
while the shared files request was still in flight. On a slow connection
this told the user their team had no libraries when it did.

The page derived its file list eagerly, so an absent :shared-files entry
in the state and a fetched-but-empty result both collapsed to an empty
sequence. The grid could not tell the two apart.

Keep the derived list nil until :shared-files is present. The grid
already renders the pencil loader for a nil file list, so the loading and
empty states now read differently.

Closes #11452

AI-assisted-by: claude-opus-5

Claude-Session: https://claude.ai/code/session_01DB3Jtt1LJvp1vW9tA9DRGY

Signed-off-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 17:07:44 +02:00
María Valderrama 711ec04598 🐛 Fix renewal-email greeting falling back to the wrong name (#11848)
* 🐛 Fix renewal-email greeting falling back to the wrong name

* 📎 Code review

* 📎 Code review 2
2026-09-24 16:33:18 +02:00
0xTHAC0 d19ab6e060 🐛 Reorder watch handler steps to prevent sprite deletion (#11198)
copyAssets uses rsync --delete which removes the sprites/ directory
from resources/public/images/ because it only exists in public, not in
the source resources/images/ tree. compileSvgSprites must run AFTER
copyAssets so the generated sprites are not deleted before
compileTemplates reads them.

The initial build sequence (lines 65-70) already had the correct order;
the watch handlers in watch.js and watch-storybook.js were reversed.
2026-09-24 16:08:42 +02:00
Andrey Antukh dad6026132 🐛 Test and document MCP plugin terminal-close handling (#11906)
Extract the WebSocket close-code decision into a pure ReconnectPolicy
module and cover it with node:test. The plugin now names the 1008
policy-violation code instead of using a magic number, and the terminal
behavior of a 1008 close is pinned by a regression test so a future
refactor cannot reintroduce the reconnect storm.

Document the contract in mem:mcp/core: 1008 is terminal, it comes from a
duplicate user token or a missing token in multi-user mode, and recovery
is manual via "Connect here".

Closes #11510

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 15:57:23 +02:00
breken 9b3594748c 🐛 Fix MCP plugin reconnect loop on duplicate connection (#11520)
The MCP server closes duplicate plugin connections with 1008 (policy
violation). The plugin treated every close as retryable: shouldReconnect
stayed true, scheduleReconnect re-armed, and each brief onopen reset the
backoff - looping at ~1s until the browser froze. Treat 1008 closes as
terminal and stay disconnected until the user reconnects explicitly.
2026-09-24 15:55:09 +02:00
0xTHAC0andAndrey Antukh 128c495b2a ✨ Don't show duplicate cursor when selection cannot be alt-duplicated (#11195)
* 🐛 Don't show duplicate cursor when selection cannot be alt-duplicated (#11165)

When pressing Alt and dragging a shape that is inside a component copy
(but is not its root), Penpot showed a :duplicate cursor, suggesting
the operation would clone the shape.  However duplicate-shapes filters
those shapes out via ctk/allow-duplicate?, so the move proceeds but no
duplicate is ever created — the cursor lied.

Fix: compute can-alt-duplicate? in the viewport, which is truthy only
when at least one selected shape passes ctk/allow-duplicate?.  Pass it
to setup-cursor and gate the :duplicate cursor branch on it.  When
none of the selected shapes can be duplicated the cursor falls through
to :pointer-inner, honestly indicating that only a move will happen.

* 📎 Add alt-duplicate check to cursor setup

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

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-24 15:29:33 +02:00
Alejandro Alonso e3e75725d4 ✨ Export WASM SVG background blur via foreignObject (#11765)
Emit an XHTML foreignObject with CSS backdrop-filter for visible
background blur. The inner div uses clip-path:path(...) for the GPU
fill+stroke silhouette (glyph outlines for text). The FO sits outside
opacity and layer-blur filters. Stacked on the masks SVG export work.
2026-09-24 15:29:25 +02:00
0xTHAC0andAndrey Antukh 9d7a685f3c 🐛 Fix MCP server build failures on Windows (#11197)
Two Windows-specific issues in packages/server/package.json:

1. Path resolution: `node scripts/copy-resources.js` could fail in some
   Windows environments (bash-in-CMD) because the bare relative path gets
   mis-parsed. Change to `node ./scripts/copy-resources.js` to make the
   path explicit.

2. esbuild binary not found: bare `esbuild` fails when the node_modules/.bin
   shim is not on PATH in certain Windows setups. Change to `pnpm exec esbuild`
   so pnpm resolves the local binary via its own mechanism, consistent across
   platforms.

Fixes #8637

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-24 15:16:59 +02:00
Andrey Antukh eda9e670be 🔧 Update OpenCode development tooling
Expose selected agent skills as slash commands and pass invocation
arguments to their entry points.

Update the devenv OpenCode CLI to v2.0.16 with checksums for both
supported architectures.

AI-assisted-by: space-bunny-free
2026-09-24 14:53:08 +02:00
773fc2295e 🐛 Raise validation error for incompatible binfile export options (#10341)
Requesting a binfile export with both `include-libraries` and
`embed-assets` enabled threw a raw `IllegalArgumentException`, which the
RPC layer surfaced to API clients as a generic
`:server-error`/`:unexpected` (HTTP 500) with no actionable code -- the
opaque failure reported in #7649 (perceived as an "empty" export).

The two options are genuinely mutually exclusive, so the request is
invalid input, not a server fault. Replace the raw throw in both the v1
and v3 export paths with `ex/raise :type :validation :code
:incompatible-options`, matching how the rest of the binfile code
reports bad input. Also fix the "mutally" -> "mutually" typo in the hint.

Verified on a local devenv: the export RPC now returns
`{:type :validation :code :incompatible-options}` instead of a 500, and
valid single-option exports are unaffected. Adds a regression test.

Fixes #7649

Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-24 14:48:43 +02:00
Andrey Antukh 84e651c231 📚 Keep plan files out of commits
Clarify that plan files stay local and that only implementation,
tests, memories, and documentation belong in the final commit.

AI-assisted-by: Space Bunny Free
2026-09-24 12:24:16 +00:00
Marina LópezandAndrey Antukh dfc1848ffc ♻️ Build organization invitation audit event in frontend (#11825)
* ♻️ Build organization invitation audit event in frontend

* ♻️ Align invitation token profile-id with created-by

The invitation token carried the minter in :profile-id while the
invitation row tracks the creator in :created-by. Both mean the
inviter, so re-sends or re-requested links made them disagree and
forced a second response key, :user-who-send-invitation.

Mint :profile-id from :created-by in both token creators, backfill
it from the row on accept (covers stale in-flight tokens), and drop
the duplicate response key. The frontend maps :profile-id to the
unchanged :user-who-send-invitation audit prop.

AI-assisted-by: Muse Spark 1.3 Free

* ♻️ Reuse token ids in invitation accept response

Backfill :member-id with the accepting profile and drop the
:user-id duplicate from the verify-token response, mirroring the
:profile-id/:user-who-send-invitation cleanup. The frontend maps
:member-id to the unchanged :user-id audit prop.

AI-assisted-by: Muse Spark 1.3 Free

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-24 14:13:52 +02:00
Eva Marco 62153dcb0b ♻️ Fix small errors (#11897)
* ♻️ Remove blank space on token sets

* ♻️ Disabled button instead of hidding it

* ♻️ Update typography
2026-09-24 13:45:17 +02:00
Andrey Antukh 32d6ee195e 🔧 Expand link preview crawler detection
Add the User-Agent values used by Mattermost, OpenGraph.xyz and the
Twitter Card Validator to the production and development
link-preview routing maps.

This lets those crawlers reach the dynamic Open Graph response instead
of receiving the generic Penpot preview.

AI-assisted-by: space-bunny-free
2026-09-24 13:39:47 +02:00
Belén Albeza a34b4011cf 🎉 Render different stroke styles per side (#11883)
* 🎉 Render different stroke styles per side

* 🔧 Stabilise per-side stroke snapshots against clip id churn

SVG clip ids come from a counter that lives for the whole test
process, so a snapshot holding one depends on how many clips other
tests emitted first. The masked-group export tests that landed on
develop shifted that count and broke two per-side snapshots, whose
only diff was the id.

Route the remaining per-side snapshots through with_stable_clip_ids,
which renumbers ids in order of appearance. No snapshot in the suite
now carries a generated id.

AI-assisted-by: claude-opus-5
2026-09-24 13:20:33 +02:00
Dr. Dominik Jain cb64570f13 🐛 Fix text response construction from base64 data (#11422)
Fixes SVG exports of shapes, as the export util method always produces
a byte array, even for text data.
2026-09-24 12:52:02 +02:00
Andrey Antukh acd146f6f4 🐛 Restore rate-limit headers and add Retry-After on 429 (#11895)
* 🐛 Restore rate-limit headers and add Retry-After

The account-lockout change replaced the header-forwarding 429 handler
with a body-only one, so existing RPC rate-limit responses lost their
x-rate-limit-remaining and x-rate-limit-reset headers. Account lockout
never sent Retry-After either.

Make handle-error :rate-limit preserve ::http/headers and add a
retry-after header when the exception carries a non-nil :ttl in
seconds, keeping the current JSON body. Add focused tests for both the
lockout and the RPC limiter paths.

Document activation, defaults, password/LDAP scope, Redis fail-open
behavior, and the lockout risk, and record the final HTTP contract in
the backend auth memory.

Refs #11397

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Add Retry-After to RPC 429 and expose headers in CORS

Address review follow-ups on the account-lockout 429 contract:

- The RPC limiter now sets retry-after in its 429 headers (seconds
  until the longest rejecting limit resets), so it matches the
  account-lockout response and the HTTP standard.
- CORS exposes retry-after, x-rate-limit-remaining, and
  x-rate-limit-reset so browser clients can read them.
- Use backticks for Retry-After and account-locked in the docs for
  consistency with nearby sections.

Refs #11397

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 12:19:29 +02:00
Andrey Antukh fe8a305811 🐛 Fix RPC test helper dropping injected request metadata
The push-audit-events initiator test injects a request with
:app.http/auth-key-id via :app.http/request metadata. prepare-rpc-params
always replaced that metadata with a fresh DummyRequest, so the key id
was lost and the initiator fell back to "app".

Honor a caller-supplied request map, merging body params into its
:params, and keep the dummy request only for non-map IRequest stubs
(reify requests used by the audit tests, which cannot be assoc'd).

Add focused regression tests for the three cases and document the
helper contract in the backend testing memory.

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 11:42:14 +02:00
Alejandro Alonso ad7b93beb7 ✨ Export masked groups to WASM SVG as alpha masks (#11652)
Paint the mask subtree into a native <mask mask-type="alpha"> and wrap
content under mask="url(#…)", matching GPU DstIn (soft/group masks).
Drop silhouettes use the same alpha mask; empty-fill SVG paths inherit
group fills.

Closes #11378
2026-09-24 11:25:34 +02:00
Andrey Antukh 25eff238ae 🔧 Remove the link-issue verification step from gh.py
GitHub does not report mutation-created issue-to-PR links through
closedByPullRequestsReferences(userLinkedOnly: true), so the
verification in `gh.py link-issue` failed even when
addCloseIssueReferences succeeded and the link existed.

Drop the re-query and trust the successful mutation: the command now
fails only when a link target is missing or the mutation does not
return the issue. Update the tests, the gh helper memory, the PR/issue
workflow memories, and the create-pr skill so they no longer promise
verification.

AI-assisted-by: deepseek-v4.1-flash
2026-09-24 09:14:08 +00:00
Alonso Torres cbb9e5d971 ✨ Add end-to-end tests for plugins validation (#11587)
* ✨ Add missing plugin data validations

* ✨ Add migration to fix the new schema validations

* ✨ Add end-to-end tests for plugins validation

* 🐛 Fix unit tests after merge

* 🐛 Change normalize behavior
2026-09-23 19:59:22 +02:00
eb7019fce4 ✨ Preserve sidebar scroll positions across tab switches (#11694)
* 🐛 Preserve layers panel scroll position across tab switches

Fixes #7440. Switching between the Layers, Assets and Tokens tabs in
the workspace left sidebar unmounts the active panel component, causing
its scroll position to reset to the top on re-entry.

Add a module-level `scroll-positions` atom keyed by page-id. The
layers scroll handler now also saves the current scrollTop value into
the atom; a `mf/with-effect` on the page-id dep restores it whenever
the `layers-toolbox*` component mounts or the page changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* 🐛 Preserve sidebar scroll positions across tab switches

Replace the Layers-only global atom with a scroll store held in a
use-var in left-sidebar*, shared by the Layers, Assets and Tokens
panels through a new sidebar.scroll helper. Positions are keyed per
panel and page (or token set) and restore waits for list content to
settle, so deep positions in lazily rendered lists survive tab
switches.

Closes #7440.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Add e2e coverage for sidebar scroll preservation

Port the regression tests from closed PR #7544 for issue #7440,
adapted to the current ref-based implementation and fixtures:
async restore needs polled assertions, and setup uses the shared
tokens helpers. Also add data-scroll-container hooks to the Assets
and Tokens scroll containers so the specs can locate them.

AI-assisted-by: muse-spark-1.3-contributor

* 📎 Fix rebase issue

---------

Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-23 19:42:46 +02:00
Andrey Antukh f64cc1eb8e ⬆️ Upgrade opencode2 to 2.0.15
Bump the OpenCode V2 binary in the devenv image from 2.0.12 to the
current npm latest tag (2.0.15) and refresh the per-architecture
SHA-256 checksums for both arm64 and amd64 tarballs.

AI-assisted-by: deepseek-v4.1-flash
2026-09-23 19:37:00 +02:00
Andrey Antukh b3c1aab720 Merge remote-tracking branch 'origin/staging' into develop 2026-09-23 19:19:18 +02:00
Andrey Antukh d6abd2fecd 🔧 Add explicit issue-to-PR linking command
Add a link-issue command that creates GitHub's Development reference
and verifies both sides. Keep Closes in descriptions for context, but
make the API link the source of truth, including for merged PRs.

Add tests for successful links, missing verification, output, and
failures. Update the PR workflow memories and create-pr skill to use
the command.

AI-assisted-by: space-bunny-free
2026-09-23 18:53:25 +02:00
Andrey Antukh 992170a9a4 🐛 Remove OpenCode V1 plugin support
Remove the legacy @opencode-ai/plugin import and V1 server export so
the local plugin loads under OpenCode V2 without project dependencies.

Add a focused smoke test for the V2 export and tool registration, and
update the related script memories.

AI-assisted-by: space-bunny-free
2026-09-23 18:53:25 +02:00
Luis de Dios 58b1acbb66 🐛 Fix register flow from workspace url (#11756)
* 🐛 Fix show validation errors below inputs instead of showing a toast

* 🐛 Fix redirect to send email screen after a successful registration
2026-09-23 18:18:14 +02:00
Andrey Antukh de6fb9d13e 🔧 Drop loopback bind on the stub_status endpoint
A compose port mapping delivers traffic to the container address,
never to loopback, so `listen 127.0.0.1:8082` made
`ports: <host>:8082` fail from the host. Both configs (image
template and devenv) now use `listen 8082`, which binds every
interface and matches the implicit bind of the public
`listen 8080 default_server`.

Rewrite both block comments to state the new bind and who decides
access from outside the host. The scrape URI stays on
127.0.0.1:8082: it still reaches the socket.

AI-assisted-by: mimo-v2.6-flash-free
2026-09-23 18:05:27 +02:00
Andrey Antukh 5b3844c37a ✨ Add observability improvements (#11854)
* 🐳 Add upstream diagnostics to nginx access log

Enrich every access-log line with the internal journey of the request:
the status the backend answered (us), the time spent connecting to it
(uct), the time spent waiting for its answer (urt) and the internal
address that served the request (ua).

A plain 502 line used to say nothing about where the request died. With
this format, the tail of the line classifies the failure: connection
rejected, backend accepted and hung (uct + urt under 1s), or backend
stuck until read timeout. This was the missing witness in the Sep 20
incident, where nginx received connection resets with zero timeouts and
zero rejections.

Applied both to the production image template and the devenv config.
With proxy_pass on variables there is no upstream keepalive, so uct
measures one real TCP connection per request.

Parsing the new fields (us, uct, urt, ua) on the log shipper is left to
ops, so they can be filtered in Loki.

AI-assisted-by: glm-5.3-flash

* 🐳 Add stub_status endpoint for nginx metrics

Add a dedicated localhost-only server (listen 127.0.0.1:8082) exposing
/stub_status next to every other location of the public server. Ops can
run the official nginx-prometheus-exporter as a sidecar against
http://127.0.0.1:8082/stub_status and get nginx_connections_active,
accepted vs handled, reading/writing/waiting and request rates in
Prometheus.

Binding it to localhost and its own server keeps it unreachable from
outside the host and out of the public surface, and access_log off
avoids polluting Loki with one line per Prometheus scrape. The base
image already ships stub_status compiled in, so no image rebuild is
needed.

Applied both to the production image template and the devenv config.

AI-assisted-by: glm-5.3-flash

* ✨ Expose http server gate metrics (worker and connector)

The backend already measured dispatch latency but nothing reported the
state of the "house door": the xnio worker queue and threads, and the
monitor-level listener counters. This was the exact blind spot of the
Sep 20 incident, where the server kept answering health checks while it
accepted connections and dropped them without response.

Add a periodic metrics sampler that lives and dies with the http
server (single daemon thread, 15s interval, each sample guarded so an
unexpected error does not cancel subsequent runs) and publishes:

- worker (xnio MXBean gauges): penpot_http_worker_queue_size,
  busy_threads, pool_size and max_pool_size. Negative samples are
  discarded: the MXBean transiently reports -1 on the busy thread
  count (verified live), and a stale negative would read as zero.
- listener (Undertow connector statistics, enabled via the new
  :server/statistics yetti option): penpot_http_connector_active*
  _connections gauge and requests_total / errors_total counters.
  Undertow exposes absolute totals, so the sampler keeps a watermark
  atom and publishes deltas, skipping (and moving forward past) a
  counter reset.

The connector-level part depends on yetti v11.11, which now accepts
a :server/statistics server option (patch authored and released
upstream; before it, ListenerInfo#getConnectorStatistics always
returned nil).

New tests cover the samplers with fake MXBean/collector statistics
against real prometheus collectors, including the negative-sample
filter, the delta/watermark logic and the sampler lifecycle.

AI-assisted-by: glm-5.3-flash

* 🐛 Include jdk.management in the backend runtime JRE

The production image builds a trimmed JRE with jlink and omitted
jdk.management. Without that module the OS MXBean is
sun.management.BaseOperatingSystemImpl, which has no
getProcessCpuTime, getOpenFileDescriptorCount nor
getMaxFileDescriptorCount. The prometheus client StandardExports
reads those getters reflectively and collect() swallows the
NoSuchMethodException, so process_open_fds, process_max_fds and
process_cpu_seconds_total silently disappeared from /metrics while
the other process_* families kept flowing.

Verified against Prometheus: the app job only ever exposed
process_start_time_seconds, process_virtual_memory_bytes and
process_resident_memory_bytes; the fd and cpu families were absent.
Reproduced locally by running the backend metrics registry on a JRE
built with the same jlink module list (false/false/false) and on one
with jdk.management added (true/true/true).

Add the module to --add-modules and pin the metric contract with
backend-tests.metrics-test.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Build the http metrics sampler on promesa.exec

Replace the hand-rolled ScheduledThreadPoolExecutor and ThreadFactory
with promesa.exec primitives: px/scheduled-executor with a daemon
thread factory, and a px/schedule chain that reschedules the next
sample when the current one finishes.

Beyond fitting the existing periodic-task pattern (worker/cron,
rpc/rlimit), the chained schedule makes the docstring promise real:
with scheduleAtFixedRate an exception escaping the runnable cancelled
the following executions, while the reschedule now happens in a
finally block.

The sampler shutdown uses px/shutdown-now (shutdown! is deprecated in
promesa 12.0.0) to cancel the pending sample, keeping the previous
halt semantics.

The lifecycle test moves to the promesa predicates and a new test
covers the error-resilience promise: the first sample runs, throws,
and the next one is still scheduled.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Tighten the http metrics samplers

The samplers are leaf functions: they receive what they need and
publish it. Drop the internal nil guards (if there is no metrics
instance or no mxbean there is nothing to call them for) and move the
checks to the boundary, where the optional data is resolved:
sample-http-metrics now short-circuits with some-> and when-let.

Write the four worker gauges as four static operations instead of a
vector of pairs walked by doseq: the set is fixed, so the collection
only adds an allocation and hides each operation.

Drop the ! suffix from the sample-*-metrics family: ! marks a function
whose contract is to mutate state, while these report, and the mutation
happens in the mtx/run! they call. The constant true return, which only
existed so the removed guard tests could assert it, goes away too.

Tests follow the move: the internal-guard tests are replaced by one
boundary test (a nil server publishes nothing).

AI-assisted-by: deepseek-v4.1-flash

* 📚 Add the function design rules memory

Document the rules that came out of the http metrics sampler review:
preconditions are checked at the boundary instead of re-checked in the
core, optional-by-design data is guarded where the optionality is born,
a fixed set of operations is written statically, ! marks mutation and
not reporting, and production code is not shaped for tests.

Also state in the memory maintenance guide that memories must not use
manual line wrapping.

Linked from critical-info so it is read when designing a solution or
an API, not only when touching the samplers.

AI-assisted-by: deepseek-v4.1-flash

* 📚 Unwrap the critical-info memory lines

The memory maintenance guide forbids manual line wrapping, so rewrite
critical-info with one line per bullet and paragraph. A stray `*` at
the start of one continuation line is dropped.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Drop the redundant guard in the http server halt

create-metrics-sampler always returns the scheduler, so the sampler is
always present when integrant calls halt-key!; the nil check was dead
code, same as the yt/stop! call next to it.

AI-assisted-by: deepseek-v4.1-flash

* ✨ Add srepl helper to delete profiles by email

Add `delete-profiles-by-email!` to app.srepl.main. It accepts a
single email, a comma separated list of emails or a coll of emails,
resolves each profile, logs it to audit and enqueues the
delete-object task. The deleted-at is backdated with the configured
deletion-delay so profiles and their owned teams are purged on the
next gc pass.

Extract the per-email deletion logic into a private fn and reuse it
from `delete-profiles-in-bulk!`. Add tests for the new
`parse-emails` helper.

AI-assisted-by: glm-5.3-flash
2026-09-23 18:03:03 +02:00
Andrés Moya ea7e5d2473 🐛 Add tokens status to the penpot import (#11850) 2026-09-23 17:59:28 +02:00
Eva Marco 07cece6d80 ✨ Improvements on tokens source section (#11849)
* 🐛 Hide buttons from viewers

* ♻️ Update css on tokens sidebar and fix extra padding
2026-09-23 17:50:28 +02:00
Eva Marco afdb6e9570 ♻️ Fix spacing on token-source title (#11817) 2026-09-23 17:50:16 +02:00
Eva Marco e140bd5393 🎉 Add an empty state message on libraries modal (#11818) 2026-09-23 17:49:57 +02:00