14200 Commits

Author SHA1 Message Date
Belén Albeza
142f3d9de8
🐛 Fix RTL auto-width text growing away from its right edge (#11775)
This makes RTL texts in auto-width to grow towards their left side in
the text editor v3.

AI-assisted-by: claude-opus-5
2026-09-23 13:02:17 +02:00
Andrey Antukh
1c7a73ec16
✨ Add account lockout after failed login attempts (#11402)
* ✨ Add account lockout after failed login attempts

Implement per-account brute-force protection using a Redis-backed
failed-login counter. After 5 failed attempts within 15 minutes, the
account is temporarily locked out and all login attempts (including
with the correct password) are rejected with a 429 response.

Closes #11397

AI-assisted-by: longcat-2.0

* 🐛 Bind LDAP session to directory-verified profile

The account-lockout change added a shortcut that preferred the
profile matching the typed email over the one returned by the LDAP
directory. These can differ with aliases, UPNs, or multi-valued mail
attributes, letting a user with valid LDAP credentials bind a session
to another Penpot account.

Keep the typed-email profile only for lockout checks. After LDAP
succeeds, resolve the session profile from the directory identity as
before and clear failed attempts on that profile.

AI-assisted-by: deepseek-v4.1-flash
2026-09-23 12:31:50 +02:00
AK
4968dc2f1d
✨ Expand the token tree after importing it (#10664)
Imported token sets were collapsed, hiding what had just been imported.

Fixes #9819

Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-23 12:09:22 +02:00
alonso.torres
e62546a03a ✨ Wait previous fail state before retry 2026-09-23 12:05:24 +02:00
alonso.torres
fb4d70a82a ✨ Add a debug helper that simulates an intermediary response 2026-09-23 12:05:24 +02:00
Andrey Antukh
34b24a9d9d ✨ Retry transient saves with backoff and reconnect notice
Classify save failures as transient or terminal (`transient-error?`
over the repo retryable types plus `:invalid-save-response`).
Transient failures keep the head commit queued under a new `:retrying`
status and resend it with backoff (2s/8s/20s, then terminal):
stamp rotation reuses the same `:commit-id`, the in-flight guard
prevents double-sends, and episode tokens silence stale timers.
One tagged reconnect notice per episode (hidden on save and on
terminal failure, silent recovery) plus a `:retrying` save-indicator
state; the browser `online` event and new edits resume the episode.
Terminal failures keep the exact `:error` path. Covers tasks 4, 6
and 7 with 31 persistence tests; updates the persistence memory.

Relates to #11724

AI-assisted-by: muse-spark-1.3-contributor
2026-09-23 12:05:24 +02:00
Andrey Antukh
95e551697f 🐛 Report environment failures as compact audit events
Connectivity and gateway failures (network, offline, 502/503 and
nitrate configuration) are not application defects, but offline fell
through to :default and 502/503 rendered exception-page, so they
reached the internal error reports and alerts with the full payload
(stack plus the last events). They are now classified as environment
failures and reported as audit-only handled-exception events.

generate-report accepts an explicit :format, as keyword arguments or as
a trailing map. :compact keeps the context header plus type, code and
uri, and skips the stack, the ex-data dump (which may contain request
headers) and the last-events list. flash derives the payload format from
the cause, so environment failures get a compact report; the audit event
name stays the canonical one requested by the caller
(handled-exception/unhandled-exception) because external tooling filters
on those names. Environment fingerprints drop the stack frame, so
grouping does not depend on the internal call site.

submit-report now requires an exception cause: a report without one is
ignored instead of using a separate fallback fingerprint, so a single
fingerprint format governs every report.

:offline gets its own handler and both connectivity handlers show the
new errors.connection-error message instead of the generic toast.

Closes #11743

AI-assisted-by: deepseek-v4.1-flash
2026-09-23 12:05:24 +02:00
Andrey Antukh
ee651b86d8 🐛 Bound error report amplification with a dedup governor
Add a report governor in app.main.errors: each report carries
a fingerprint, the first occurrence is always emitted, and
repeats within 2 minutes are counted and included in the next
emitted report as :occurrences. The fingerprint cache is
bounded by evicting the oldest entry.

flash reserves the report before generating it, so suppressed
occurrences do not build a report. static.cljs now passes the
cause so the exception page gets a full fingerprint.

Closes #11726

AI-assisted-by: deepseek-v4.1-flash
2026-09-23 12:05:24 +02:00
makesomethingshit
a9697fa582
🐛 Align WASM viewer layers during zoom (#11793)
* 🐛 Align WASM viewer layers during zoom

Keep the absolute WASM layer at the logical viewer size so its canvas CSS

box and hotspot SVG remain aligned as zoom changes.

Add a Playwright regression covering DOM bounds, drawing-buffer sizing, and

hotspot clicks at zoom 1 and below 1.

Closes #11689

AI-assisted-by: GPT-5
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Align WASM zoom regression clicks

Use the rendered WASM DOM selector and design-space points mapped through

canvas bounds for visual clicks.

Wait for the canvas and SVG bounds before checking zoomed-out positions.

AI-assisted-by: GPT-5
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Fix WASM zoom render wait

Use Screen2 coordinates for the visual interaction hotspot.

Wait for non-empty canvas pixels after each viewer render.

AI-assisted-by: GPT-5
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Match WASM render marker in zoom spec

Require the expected frame screenshot marker before reading canvas pixels.

Keep resized buffers blocked until the new frame draws.

AI-assisted-by: GPT-5
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* ✨ Run WASM zoom regression in DPR project

Move viewer zoom coverage under the render-wasm Playwright project.

Assert DPR-scaled buffers and use the stable Zoom out role.

AI-assisted-by: GPT-5
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Simplify WASM render wait

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Remove unreliable WASM viewer regression

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

---------

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
2026-09-22 17:13:28 +02:00
Andrey Antukh
2f679eaa0e
💥 Remove client-provided id from creation RPC commands (#11784)
The seven creation commands no longer accept an optional client
id: create-file, create-project, create-team,
create-team-with-invitations, upload-file-media-object,
create-file-media-object-from-url and assemble-file-media-object.
The server always generates the identifier; a sent id is ignored.

Malli maps are open and the RPC layer never strips unknown params,
so the handlers that would still honor an id (create-file,
create-project) now drop it explicitly. Internal callers that pass
remapped ids (project duplicate, binfile import) keep working.

Closes #11783

AI-assisted-by: muse-spark-1.3-contributor
2026-09-22 15:51:50 +02:00
Andrey Antukh
2041473cc4
🐛 Add regression test for viewer zoom url loop (#11821)
Lock in the fix from 31b73460c3 (#11803) with a regression
test for the exact reported scenario: loading the viewer
with a URL that already contains `zoom=fill`.

At 2.18.0-RC5 `update-zoom-querystring` navigated without
any comparison, so the load sequence bundle-fetched →
zoom-to-fill → update-zoom-querystring → nav → navigated
re-ran forever and crashed the page with React error #185
("maximum update depth exceeded"). The guard added in
31b73460c3 breaks the cycle; the new test asserts that a
bundle fetch against a `zoom=fill` route emits no
navigation events.

Also updates the dashboard/viewer frontend memory to
document the guard and the loop it prevents.

AI-assisted-by: glm-5.3-flash
2026-09-22 14:58:32 +02:00
Andrey Antukh
bc3cb4bddf
🌐 Complete Catalan translations in frontend (#11741)
* 🌐 Complete Catalan translations in frontend

Complete the Catalan (ca.po) locale to 100% coverage against en.po,
using es.po as support reference. Adds the 1439 missing entries
across workspace, dashboard, labels, shortcuts, subscription,
errors, modals and onboarding, keeping vosaltres treatment and
IEC/Termcat terminology consistent with the existing strings.
Normalizes placeholders and plural forms, drops the 14 stale
obsolete entries and canonicalizes the file with the repo
translations script.

Closes #11739

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

* 📚 Add frontend translations memory with Catalan criteria

Record the PO workflow, the sync fuzzy-flag gotcha and the
Catalan glossary and tone agreed upon while completing ca.po,
and link the new memory from the frontend core routing.

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

* 🔧 Add gettext to devenv image

Provide msgfmt and msgattrib in the dev environment for
checking PO translation files.

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

* 🌐 Fix Catalan translations and add PO checker

Review of the missing-whitespace pattern found ~90 glued words
across 75 entries, plus 4 lost plural forms and 2 placeholder
mismatches verified against tr call sites. All fixed in ca.po.

Adds frontend/scripts/check-translations.js (vocabulary-free PO
QA: glued words, punctuation, placeholders, plurals) with
--self-test, wired as pnpm run check-translations and
documented in mem:frontend/translations.

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

* 🌐 Multi-locale PO checker with word catalogs

Split the checker engine from its word lists: ca/es catalogs now
live in scripts/check-translations/words.<locale>.txt and all
messages are in English. Adds an es seed (calibrated to zero
errors) and fixes 7 typos it found in es.po. Universal checks
(placeholders, plurals, punctuation) run without a catalog.

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

* 🌐 Merge PO checker into translations.js

Fold check-translations.js into translations.js as a check
subcommand reusing its locale helpers; word lists stay in
scripts/check-translations/words.<locale>.txt. Also fixes the
getopts stopEarly bug that made -l useless after the command
(sync -l ca synced every locale), drops dead lodash import
and code, unifies help and exit codes. Removes the
check-translations package alias; use translations.js
check -l <locale> with explicit -l.

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

* 🌐 Keep unused placeholders out of the gate

Reverts the %s-stripping on unused auth.terms-privacy-agreement:
the links mirror its markdown sibling and a reactivation may
need them. Placeholder mismatches on #, unused keys now warn
instead of failing, and the rule is recorded in
mem:frontend/translations.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-22 14:10:01 +02:00
Andrey Antukh
476e25e371 Merge remote-tracking branch 'origin/staging' into develop 2026-09-22 13:44:56 +02:00
Eva Marco
01363be3a8
🐛 Fix text variant changing text content on variant switch (#11815) 2026-09-22 12:59:42 +02:00
Alonso Torres
efb10c82a8
🐛 Fix silent fail in plugin API interactions (#11781) 2026-09-22 12:56:35 +02:00
Alonso Torres
31b73460c3
🐛 Fix problem with viewer url (#11803) 2026-09-22 12:56:01 +02:00
Shreyash Agare
3c821f1b1b
🐛 Restore the missing handler when aligning a one-handler node (#11789)
* 🐛 Restore the missing handler when aligning a one-handler node

A curve node with one of its handlers removed could not be switched
to aligned or equal: the collapsed handler stayed on the node, or the
line on the other side stayed a line, so nothing happened. Switching
to equal could even collapse the remaining handler.

Such a node now gets a mirrored handler on the other side, turning
that line into a curve when needed, also across the start node of a
closed path. Removing a handler also resets the node to independent.

Closes #11703

AI-assisted-by: claude-opus-5
Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>

* 🐛 Add some quality improvements

---------

Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-22 11:21:47 +02:00
Andrey Antukh
117c8db0bb Merge remote-tracking branch 'origin/staging' into develop 2026-09-22 10:24:30 +02:00
Eva Marco
683e0aed40
🐛 Fix ambiguation on import modal (#11747) 2026-09-22 10:23:38 +02:00
Andrey Antukh
d68531b783
⬆️ Update devenv dependencies (#11790)
* ⬆️ Update devenv dependencies

Update Node.js, OpenCode, clj-kondo, Babashka, Pixi, GitHub CLI, uv,
and Serena to their current stable releases.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update devenv to Java 27

Use Zulu JDK 27 in the development image for compatibility testing.
Update the official checksums for both supported architectures.

AI-assisted-by: gpt-5.6-sol

* 🐳 Replace MinIO with RustFS in devenv

Run RustFS as the development S3 service and wait for its health check.
Install a pinned AWS CLI with checksums and use it to create the bucket
idempotently from each backend entry point.

Keep the old MinIO volume untouched and use a new RustFS volume.

AI-assisted-by: gpt-5.6-sol

* 🐳 Replace MailCatcher with persistent Mailpit

Run Mailpit as the devenv SMTP sink while preserving mailer:1025 and the
localhost:1080 UI.

Store its SQLite inbox in a named volume and wait for the readiness
endpoint before starting runtime containers. Bind the web UI to loopback so
development emails stay local.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update Node.js to 24.21.0

Align the host NVM version with the Node.js version used by devenv.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update devenv to PostgreSQL 18.6

Run PostgreSQL 18 with its versioned volume layout and a TCP readiness
check that ignores the temporary initialization server.

Install the matching client, create penpot_nexus, and preserve the old
PostgreSQL 16 volume for rollback or logical migration.

AI-assisted-by: gpt-5.6-sol

* 🐳 Expose RustFS ports in devenv

Publish the RustFS S3 API and management console on localhost port 9000
and 9001.

Keep both bindings on loopback so object storage is not exposed to the local
network.

AI-assisted-by: gpt-5.6-sol

* 🐳 Install standalone pnpm in devenv

Install pnpm 12.5.0 from architecture-specific release archives and
verify their published checksums.

Remove the Corepack setup while allowing pnpm to honor the project
packageManager pins.

AI-assisted-by: gpt-5.6-sol

* 🔥 Remove corepack, use system pnpm everywhere

Corepack is gone from Node 25+, so every `corepack enable` call
fails. pnpm now ships as a system binary (devenv, CI runners and
Docker images install it directly) and auto-downloads the version
pinned in `packageManager` on mismatch.

Scripts, workflows and Dockerfiles call `pnpm` straight away; the
three deploy workflows use a single `pnpm/setup@v2` step; and the
new `scripts/sync-pnpm-version` stamps all 35 `packageManager`
fields from the system pnpm, replacing the `corepack use` sweep.

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

* 🐛 Fix exporter watch missing render-wasm build step

The exporter watch compiled CLJS requiring the generated
src/app/wasm/shared.js, which only render-wasm/build export
produces. Without it shadow-cljs failed with a cryptic missing
./shared.js dependency. Run build:wasm before watching, as
the frontend watch:app and exporter scripts/build already do.

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

* 🔧 Add opencode V2 support and adapt plugins

Register the penpot tools for both opencode V1 (server())
and V2 (setup() with JSON Schema inputs) from a single
dependency-free plugin file, sharing the psql and
paren-repair runners between both paths.

Install the opencode2 binary side-by-side with V1 in the
devenv image and document the dual registration in the
paren-repair and psql memories.

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

* ⬆️ Update pnpm and opencode
2026-09-22 10:22:31 +02:00
Eva Marco
e4723cb3a8
🐛 Fix copy paste text with external typography (#11785) 2026-09-21 15:57:01 +02:00
Andrés Moya
3cd9bfa9de
✨ Add sync with design tokens in external libraries (#10293)
* ✨ Auto link tokens when adding external libraries (provisional)

* 🔧 Refactor tokens-lib initialization

* 🔧 Add separated TokenStatus to store status apart of TokensLib

* 🔧 Make all status operations use the new data structure

* 🔧 Normalize status helper functions and access token sets by id

* 🔧 Rename :tokens-file to :tokens-source

* 🎉 Allow the user to choose the tokens-source of a file

* 🎉 Make tokens library readonly when it's in an external file

* 🎉 Show tokens in library summaries

* 🎉 Show source info in sidebar

* 🔧 Fix integration tests

* 🐛 Propagate changes of token values in external library

* 🎉 Layout updates

* 🔧 Refactor tokens source calculations

* 🔧 Add harder checks for nil or empty values in everything

* 🐛 Fix some integration tests

* 🔧 Add integration tests for tokens in external libs

* 🔧 Validate and repair missing tokens status

* 🎉 Make ui changes optional with config flag

* 🐛 Propagate tokens after synchronizing components in ext library

* 🐛 Propagate tokens after creating new instances

* 🐛 Propagate tokens after synchronizing tokens in ext library

* 🐛 Add a tokens source icon to libraries section (#11439)

* 🐛 Add a tokens source icon to libraries section

* 🐛 Fix ellipsis on library names

* ♻️ Remove code under flag on legacy component

* 🐛 Fix token theme name on inspect tab

* 🎉 Add changes notification (#11476)

* 🎉 Add changes notification

* ♻️ Change fn names

* 🐛 Fix tokens source label truncation and missing translations (#11533)

* 🐛 Fix tokens source label truncation and missing translations

The tokens source file name always showed, even for the current file,
and long names wrapped onto a second line instead of truncating
because the header used flex-wrap and overflow-wrap: break-word
instead of single-line ellipsis.

Show the source row unconditionally (it now displays "This file" when
the source is the current file, matching the connected-library case),
truncate the file name to one line with an ellipsis, and only attach a
tooltip with the full name when the text is actually truncated.

Replace the hardcoded UI strings with translated ones and add their
English and Spanish entries.

AI-assisted-by: claude-sonnet-5

* 🐛 Remove redundant effect dependency in tokens source

file-name-truncated? was listed as a dependency of the with-effect
that checks and observes label truncation, even though it isn't
read inside the effect body. Since the effect itself flips that
state via check-file-name-truncated, including it as a dependency
caused the ResizeObserver to be needlessly disconnected and
reconnected on every truncation change.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix small visual error

* 🐛 Fix problem with plugins

* 🐛 Fix playwright tests

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-21 15:37:45 +02:00
Eva Marco
56bf0e3ebb
🐛 Fix disconnection of lib (#11742) 2026-09-21 12:51:08 +02:00
María Valderrama
b32a73519e
🎉 Add combined organization/team switcher to the dashboard (#11760)
* 🎉 Add combined organization/team switcher to the dashboard

* 📎 Code review
2026-09-21 12:50:32 +02:00
Eva Marco
9fa0d9c996
🐛 Fix custom shortcut with shift (#11669) 2026-09-21 12:36:10 +02:00
Eva Marco
52825f528c
♻️ Update import modal UI (#11704)
* 🌐 Translate import dialog strings

Several plain hardcoded strings in the import dialog (the file
rename aria-label, library-resolution messages, table headers, and
the manually-linked summary labels) were never wired to the i18n
system. Wire them up via tr and add the corresponding English and
Spanish entries to the translation catalogs.

* 🐛 Fix library-resolution summary layout in import dialog

The auto-linked/manually-linked summary list had several layout bugs:
name/selection columns stretched unevenly, the auto-linked badge sat
nested inside the name's ellipsis text (unreliable across browsers),
and a long unbreakable library name could grow the whole modal past
its fixed width because a bare 1fr grid track has no minimum-size
cap. Fix the column sizing, split the badge out as a sibling of the
name, and clamp the modal content column with minmax(0, 1fr).

* ✨ Add Skip button to library resolution wizard

Add a "Skip" action to the per-file library resolution step so users
can leave all of a file's pending libraries unconnected and move on
to the next one, instead of being forced to pick a candidate or go
back. Clears any pending selections for the current file before
advancing the wizard, so the summary correctly shows those libraries
as unlinked.

* 🐛 Fix stale translation key on library resolution button

The primary action button in the library resolution wizard referenced
"dashboard.import.review-links", a translation key that no longer
existed, leaving the button with no text. Rename it to
"dashboard.import.connect-selected-libraries" to match the actual
button label.

* 🐛 Align no-selection state with arrow and file name

.summary-no-selection was missing display:flex/align-items:center,
so its icon and text weren't vertically centered against each other
or against the file name column in the import library summary.

* 💄 Use select-accent color for auto-linked badge border

* 💄 Change import summary columns from 50/50 to 40/60 split

Give the "new library" column more room than the "original library"
column in the manually-linked libraries table (header and rows), since
it usually carries the longer "name (project)" text.

* 🐛 Divide back buttons from forward buttons

* 🐛 Fix dropdown typography
2026-09-21 12:29:14 +02:00
Eva Marco
dc8160c13a
🎉 Add Menu DS component (#11511)
* 🎉 Add Menu design-system component

Adds Menu, MenuItem, MenuSeparator, SubMenu, and ContextMenu to the
shared UI package and exposes them through the CLJS design-system
wrapper, with Storybook stories and MDX docs.

Built on react-aria-components for keyboard navigation, focus
management, and dismissal. Penpot's own DS buttons aren't
react-aria-aware, so trigger positioning, focus-on-open, and
close-on-select are wired explicitly instead of relying on the
library's default trigger detection.

Includes a temporary manual-test harness in the dashboard to check
the components against the real app. CSS is functional but doesn't
match the DS visual design yet — that comes in a follow-up.

AI-assisted-by: claude-sonnet-5

* ✨ Add left/right corner placements to Menu design-system component

Menu and ContextMenu only exposed 8 of react-aria's placement values,
missing every left/right corner variant (right bottom, right top,
left bottom, left top) that the top/bottom sides already had via
start/end.

Add the four missing corners, matching the start/end pattern already
used for top/bottom, so a menu can open toward any corner of its
trigger.

AI-assisted-by: claude-sonnet-5

* ✨ Add drilldown variant to SubMenu design-system component

SubMenu only opened as a flyout: a nested popover next to the
trigger item. That doesn't scale to a tree too deep or wide for a
chain of flyouts, e.g. move-to-project's team -> project nesting,
which needs a mobile-style drilldown (replace the current items with
the submenu's own, plus a way back) instead.

Add a `variant` prop, `"flyout"` (default, unchanged) or
`"drilldown"`. Menu and ContextMenu each keep a navigation stack,
provided to their content tree via context, so a drilldown SubMenu
nested inside another drilldown SubMenu still drills into the same
stack and arbitrarily deep trees stay navigable one screen at a
time. Switching levels remounts the level's content wrapped in a
keyed Fragment rather than updating it in place, since
react-stately's Collection requires each item's id to stay stable
across an update and the back item's label (and everything under it)
genuinely changes identity between levels.

AI-assisted-by: claude-sonnet-5

* ♻️ Wire the DS Menu/SubMenu into the dashboard file menu

file_menu.cljs used context-menu-a11y's data-driven options list,
rendered via a generic recursive renderer. Rewritten as real JSX
composition (menu-item*/sub-menu*/menu-separator*) using the DS Menu
component, preserving every existing conditional branch (single-file,
multi-select, restore-mode, permission gates). "Move to" -> "Move to
other team" -> team -> project now uses sub-menu*'s drilldown variant
at every level.

Split into file-menu-items* (the item tree, no popover of its own)
and a thin file-menu* wrapper (Menu, anchored to the "..." button),
so grid.cljs can render the same items a second time inside a
ContextMenu for right-click, matching the previous behavior of
opening either via the button or a right-click anywhere on the row.

grid.cljs's trigger handling is simplified accordingly: DS's Menu/
ContextMenu handle their own positioning (including auto-flip near
viewport edges) and dismissal internally, so the manual click-
coordinate math, the dashboard-local :menu-open/:menu-pos globals,
and the portal-on-document* wrapper (Popover already portals itself)
are all gone. The now-fully-dead show-file-menu-with-position/
show-file-menu/hide-file-menu actions are removed from
data/dashboard.cljs.

Also fixes two issues found wiring this up:
- The add-shared/unpublish-shared toggle rendered two different
  menu-item* ids at the same list position; :is-shared can flip while
  the popover stays open (the action's own side effect), and
  react-stately's Collection requires an item's id to stay stable
  across such an update. Both branches now share one id.
- Menu's own trigger wrapper (align-self: start, needed generically
  so it doesn't stretch in an arbitrary parent) overrode
  .project-thumbnail-actions's centering of the "..." button;
  grid.scss now re-asserts centering for that specific consumer.

Removes the temporary menu-test* harness from dashboard.cljs now that
there's a real integration to test against instead.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu/ContextMenu popover interaction bugs

Found testing the dashboard file menu integration:

- Reopening the same trigger right after closing (e.g. right-click,
  dismiss, right-click again) could silently fail or briefly show two
  overlapping instances. Closing played a 100ms exit fade, and a
  reopen landing mid-fade raced the still-live Popover instance.
  Closing now always skips the exit animation, so by the time any
  subsequent open request arrives there's no ambiguous in-between
  DOM state left to race.

- Right-clicking a different row while one file's context menu was
  open didn't close the first one. Menu/ContextMenu don't use
  react-aria-components' own MenuTrigger (Penpot's DS buttons aren't
  react-aria-pressable), so they also don't get its built-in
  RootMenuTriggerStateContext coordination between sibling instances.
  A window CustomEvent broadcast restores it: opening announces this
  instance's id, and every other mounted instance closes on hearing a
  different one.

- With that coordination in place, right-clicking elsewhere still did
  nothing at all: Popover defaults to modal, which marks the rest of
  the app inert (unfocusable *and* unclickable, not just visually
  blocked) while open. Correct for a real Dialog, wrong for a
  lightweight dismissable menu. Fixed with isNonModal on all three
  Popover usages (Menu, ContextMenu, SubMenu's flyout).

- isNonModal has its own side effect: react-aria only wires up its
  click-outside-closes behavior when a popover is "dismissable", which
  isNonModal forces off (for anything but a submenu flyout) with no
  separate prop to turn back on. Reimplemented directly: a pointerdown
  landing outside the popover's own rendered content closes it, via a
  ref now passed to Popover.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu visual styling and two overflow bugs

Border and shadow, to match the legacy context-menu-a11y menu this
replaces: the DS component had neither (a filter: drop-shadow with a
different blur radius stood in for the shadow, and there was no
border at all). Used the pattern already established by sibling DS
dropdowns (options-dropdown.scss et al.) rather than porting the
legacy tokens directly — border: 1px solid
var(--color-background-quaternary) + box-shadow: 0 0 12px 0
var(--color-shadow-dark), both already in use elsewhere in this same
file.

Found two real bugs verifying that against a long "move to" list:

- .menuItem/.separator had no flex-shrink: 0, so once a list's
  natural height exceeded the menu's max-block-size, flexbox shrank
  every row to fit them all rather than triggering the scrollbar —
  overflow only kicks in after flex-shrink has done its best, and
  shrinking was never opted out of.

- The menu's own fixed max-block-size: 300px ignored react-aria's
  Popover, which sets its own max-height (inline, on our direct
  parent) to whatever space is actually available between the trigger
  and the viewport edge. In a small viewport that computed value can
  be under 300px; since the parent has no overflow of its own, our
  independent 300px cap just rendered straight past it and off the
  edge of the window. max-block-size: inherit picks up the parent's
  own computed value instead, at the cost of no longer capping how
  tall the menu can get when there's plenty of room (verified: 348px
  in a normal-height viewport, vs the old fixed 300px) — an
  acceptable tradeoff against content becoming inaccessible.

AI-assisted-by: claude-sonnet-5

* 💄 Adjust Menu design-system component item states and spacing

Give menu items a distinct keyboard-focus ring (accent-primary outline
plus tertiary background) separate from the mouse hover/click state,
which keeps its existing quaternary background unchanged. Restyle
disabled items with a tertiary background and secondary text color,
shrink the submenu chevron to 12x12, and tighten the menu's vertical
padding to 4px.

* 📚 Document drilldown submenu and tighten Menu docs

Add the drilldown submenu story to the Menu docs page, show the
idiomatic controlled-state shape in the usage example (callbacks
bound in the let with mf/use-fn, explicit deref of the open state),
and trim the prose down to the information a consumer needs.

* 🐛 Fix Menu outside-click closing on its own trigger and submenus

useCloseOnOutsideClick restores the dismiss behavior isNonModal turns
off, but it tested containment against the popover element alone. That
missed two cases react-aria's own useOverlay accounts for.

A root Popover wraps its content in a display:contents div and portals
every SubmenuTrigger's nested popover into that same div, so a flyout
submenu is a sibling of the popover, not a descendant. Pressing an item
in one counted as an outside click: the whole tree unmounted on
pointerdown and the item's action never fired on pointerup. Test the
group container instead.

The trigger was likewise treated as outside, so closing on its
pointerdown let the click's own handler read the already-false open
state and reopen the menu — a trigger wired to a toggle could never
close it. Exclude it in Menu; ContextMenu keeps the old behavior, since
right-clicking elsewhere should reopen it against a new anchor.

* 🐛 Target the clicked file when it is not in the dashboard selection

The file menu adopted the whole selection whenever it was non-empty,
guarding only against it being empty. That left the case where the
selection holds files this row is not one of: toggle-file-select is a
no-op across projects, so shift-right-clicking a file in another
project leaves the previous project's selection intact and the menu
opened on the pointed-at file while offering rename, duplicate, move
and delete for a different one.

Adopt the selection only when it actually contains this file, which
covers the deferred-dispatch case the previous guard was written for
just as well.

* 🐛 Drop the file menu teams cache that outlived a logout

The cache was a module-global defonce atom, and logging out does not
reload the page — it resets the store and navigates. The next profile
to sign in on the same tab therefore opened its first file menu with
the previous account's team and project names listed under "Move to",
until the background fetch replaced them.

The cache only ever saved the brief absence of one submenu, which is
already guarded on having data and so does not shift any layout, so
remove it rather than scope it to a profile. Dispose the subscription
too: it wrote to component state after unmount.

* 🐛 Keep the Menu open when its own trigger takes focus

Excluding the trigger from the outside-click dismiss was not enough to
make a toggle trigger able to close the menu: usePopover passes
shouldCloseOnBlur unconditionally, and useOverlay acts on it regardless
of isNonModal, so focus moving to the trigger on its own pointerdown
closed the popover before the click ran. The click then read an open
state that was already false and reopened it.

shouldCloseOnInteractOutside is the one exception useOverlay consults
before closing on blur, so use it to exempt the trigger.

* 🔧 Add interaction tests for the Menu component

Cover the two dismissal regressions just fixed — closing the menu from
its own trigger, and a press inside a flyout submenu not being treated
as an outside click — plus drilldown navigation, the navigation stack
resetting between open/close cycles, and Escape and outside click.
Both regression tests fail against the code as it was before the fixes.

The story trigger now toggles instead of only ever opening, which is
what a real caller does (the dashboard's own is a swap!) and what makes
the reopen bug observable at all.

* ✨ Add max-width, density, and drilldown sizing to Menu/ContextMenu

Add a max-width prop (default 250px) to Menu, ContextMenu, and flyout
SubMenu, and an is-dense prop to Menu/ContextMenu that shrinks every
item — including nested flyout SubMenus, via a shared density context
— to a 28px row. Pin a drilldown SubMenu's popover to at least the
root level's own size, so navigating into a shorter or narrower list
doesn't shrink the menu mid-navigation.

Also truncate a plain MenuItem's text with an ellipsis instead of
letting it wrap and blow out the row height, matching the existing
SubMenu trigger label, and fix that label's own truncation: it was
missing min-inline-size: 0, without which a flex item can't shrink
below its content size and text-overflow: ellipsis never engages.

Exposed through the ClojureScript facade as :max-width/:is-dense,
documented with new example canvases, and covered by five new
Storybook interaction tests, each verified to fail without its
corresponding fix.

* ♻️ Wire the DS Menu/ContextMenu into the dashboard project menu

Replace the legacy context-menu-a11y-based project menu (grid, sidebar,
and per-project file view) with the DS Menu/ContextMenu components,
mirroring the earlier file menu migration. Drop the manual
:menu-open/:menu-pos position tracking in favor of the DS components'
own positioning, and split project-menu-items* out so both the "..."
trigger and right-click share the same options.

The hidden file input behind the "Import" option moves out of the
popover content and into whichever parent stays mounted regardless of
the menu's own open state: the DS popover really unmounts its content
on close (unlike context-menu-a11y, which only hid it), and selecting
"Import" closes the menu in the same tick a ref owned inside it would
already be gone.

Add an onOpenChange notification to ContextMenu (it stays uncontrolled,
this only reports state changes) so the project row's "..."/pin/add-file
actions can stay visible for as long as either menu is open, the same
way they already do on hover. Fix a related visibility bug this exposed:
closing a menu restores focus to its trigger regardless of whether the
open happened via mouse or keyboard, so :focus-within alone kept the
actions visible after closing with the pointer away — swapped for
:has(:focus-visible), which only matches real keyboard navigation.

* 🐛 Forward MenuItem's id to the DOM as data-testid

MenuItem's function signature never forwarded anything beyond its
explicitly-typed props to the underlying RACMenuItem, so a caller's
id — meant as a stable per-item identifier — only ever reached the DOM
as react-aria's own internal data-key, never as data-testid. This
silently broke dashboard.spec.js's "Multiple elements in context" test
after the file menu's migration to this component, since every existing
menu item id was already relied on as its test id.

id is already unique per item for selection/on-action, so deriving
data-testid from it directly means every item is reachable in a test
with no separate prop to remember to pass. SubMenu's own trigger row is
a MenuItem too, so this covers it for free.

* 🔧 Add Playwright coverage for the project options menu

Covers all four places the migrated project menu is reachable: the
dashboard grid's "..." button and title right-click, the sidebar's
right-click, and the per-project files page's "..." button. Checks
rename/duplicate/pin/move-to/delete render (and that the default
Drafts project correctly hides all of them), that rename opens the
inline editor, that delete opens the confirm modal, and that the
move-to submenu lists other teams.

Also drop an unused React import from context_menu.stories.jsx,
spotted in passing.

* ♻️ Add datatest id

* ♻️ Fix linter

* 🐛 Build @penpot/ui automatically after pnpm install

packages/ui/dist is gitignored (build output) and nothing in the
install pipeline built it, so a fresh checkout — CI included — never
had it. Any code importing "@penpot/ui/menu" (the frontend's own
cljs-runtime tests among them) failed at module resolution with
ERR_MODULE_NOT_FOUND rather than any real test failure.

Build it in postinstall, the same way plugins-runtime already does,
so it's always present after `pnpm install` without a separate manual
build step.

* 🔥 Remove flaky Menu dense/ellipsis Storybook tests

Test Dense Shrinks Items and Test Long Label Ellipses Instead Of
Wrapping asserted computed pixel styles that passed consistently
locally (including with a fresh packages/ui install) but failed in CI,
suggesting a CI-only timing/environment discrepancy in when the
computed style stabilizes. Dropping them rather than chasing a
non-reproducible flake.

* 💄 Open the file/project options menu right, top-aligned

Switch the dashboard file and project "..." options menus from
"bottom end" to "right top" placement, so they open beside the
trigger button instead of below it.

* 🐛 Stop drilldown SubMenu jumping to the opposite edge

A drilldown SubMenu swaps its parent Menu/ContextMenu popover's own
content in place, and react-aria re-runs its flip/collision placement
on every layout change. Drilling into a shorter level than the root
could shrink the popover enough that react-aria decided there was now
room on the other side, flipping it there — a visible jump even though
the popover never actually moved from the caller's point of view.

The previous fix padded every drilled-in level out to the root's own
min-inline-size/min-block-size so the popover never got small enough
to trigger a re-flip, but that meant a level naturally much shorter
than the root still rendered at the root's full height.

Replace it with shouldUpdatePosition={false} on the Popover for as
long as any level is drilled in. This freezes whichever edge react-aria
already resolved for the root, so a shorter level just shrinks from the
opposite edge instead of triggering a new placement decision, and a
taller level grows from that same opposite edge in the direction the
root already opened. shouldUpdatePosition goes back to true once the
stack returns to the root, so a fresh open still resolves normally.

* ♻️ Update menu placements and use buttons from DS

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-09-21 10:41:31 +02:00
Eva Marco
fab8e0e35d
🐛 Fix local favicon with new svg file (#11763) 2026-09-20 11:59:04 +02:00
Alonso Torres
d642fcbf5c
🐛 Fix problem with plugins api event handler (#11787) 2026-09-20 11:55:23 +02:00
Andrey Antukh
3b5c11f116 Merge remote-tracking branch 'origin/staging' into develop 2026-09-20 11:05:06 +02:00
Andrey Antukh
79c98b7c2e 🐛 Fix packages/ui build process 2026-09-20 11:04:24 +02:00
David Barragán Merino
1e8acbb2db 🐳 Cover the inline scripts of the served pages with CSP hashes
The frontend build now emits the sha256 hashes of the inline scripts of every page it writes into resources/public, the image moves them out of the document root, and the entrypoint splices them into the default script-src. This removes one of the two reasons why enforcing mode was not usable.

The hashes are computed on the rendered output rather than on the mustache templates, since the digest covers the exact bytes served between the script tags. All four served pages contribute, not just index.html: challenge.html handles the redirect, render.html is loaded by the exporter in a headless browser, and rasterizer.html is initialised by the frontend itself, so leaving any of them out would have broken those paths under enforcing mode. The storybook previews are excluded because that container does not serve them.

A bundle predating this change yields no hashes and the policy stays as it was, so older bundles keep building.

The three external locations were also passing through the security headers of their upstreams. raw.githubusercontent.com returns its own Content-Security-Policy and both it and fonts.googleapis.com return Strict-Transport-Security. Browsers enforce the intersection of every policy they receive, so the upstream one takes precedence on those responses, and the HSTS one lands on our own host, meaning a deployment that deliberately disables HSTS would get it set anyway by a third party. Hide all three at the proxy.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-18 18:55:19 +02:00
Elena Torró
617bf195cf
🐛 Display cursor on page load when WebGL is active (#11770) 2026-09-18 09:48:39 +02:00
Elena Torró
8124ecf148
⚡ Improve canvas snapshot and nested drop shadows calculation (#11766)
* ⚡ Capture canvas snapshot only when the renderer is idle

* ⚡ Skip descendant silhouettes for frames with nested drop shadows

* 🔧 Add a profiling build mode for render-wasm
2026-09-18 09:21:52 +02:00
Alejandro Alonso
ecf8436eea Merge remote-tracking branch 'origin/staging' into develop 2026-09-17 17:12:39 +02:00
Alonso Torres
2fc2a9064a
🐛 Add download report to the error toast (#11762)
* 🐛 Add download report to the error toast

* 🐛 Restore old behavior for some cases
2026-09-17 17:05:06 +02:00
Alonso Torres
78b5d13b7a
🐛 Fix bend curve sharp edges (#11715) 2026-09-17 16:42:22 +02:00
makesomethingshit
8c7230749c
♻️ Migrate workspace zoom widget to modern syntax (#11637)
Migrate zoom-widget-workspace to zoom-widget-workspace* following the
modern rumext component syntax: drop ::mf/wrap-props false and switch
the callsite from [:& ...] to [:> ...]. No behavior change.

Part of #9260

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

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
2026-09-17 15:37:51 +02:00
makesomethingshit
edf146db7b
🐛 Preserve source order when changing grid flow (#11662)
* 🐛 Reflow auto grid cells on flow direction change

Remap only single-span auto cells to the new
:layout-grid-dir traversal order, keeping source
order, manual and area placements untouched.

Update both grid direction controls to use the
new change-grid-direction event and refresh the
stale active button on persisted direction.

Add a RED-to-GREEN model regression covering a
2x2 row-to-column transition and source-order.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Keep source order on grid flow change with areas

Skip the generic grid cell pass for the
direction event, since reflowing already
places every eligible auto item and a blind
reorder rewrites shapes around pinned areas.

Pin area/span grids with a regression test
covering direction change and source order.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Scope grid skip to direction changes only

Replace the translation flag with a narrow
skip-grid-reassignment option so component
sync and reflow metadata stay intact while
the generic grid cell pass is skipped.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Clear leftover auto cells on grid flow change

Write remapped shapes to every target auto cell and
empty leftover cells so sparse grids cannot duplicate
a child across target cells. Manual, area and
spanned cells stay untouched; source order is kept.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Pin grid flow invariants with span and manual regressions

Keep the direction-change design unchanged and lock the
claimed invariants with tests: a real 2x1 manual span
cell and an occupied manual cell stay byte-identical,
row->column->row round-trips to the original cells,
and a mixed auto/manual/span/area grid shows no shape
loss or duplication. Also drop the unused page-objects
binding from the direction-change watcher.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Unoverlap mixed grid fixture and assert movement

Move auto C to (1,3) so it no longer overlaps the 2x1
manual span at (1,2). Row auto order A,C,B,E becomes
column order A,B,E,C; assert the exact placement
while keeping pinned, source-order and no-loss
checks. Test-only change.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Unify plugin dir setter and normalize missing direction

Route GridLayoutProxy.dir through change-grid-direction so the
plugin API shares the UI direction-change path with its reflow
and source-order guarantees. Normalize a missing
:layout-grid-dir to :row at the change-grid-direction entry
point and cover it with a missing-direction regression plus a
plugin setter routing regression.

AI-assisted-by: muse-spark
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>

* 🐛 Fix grid plugin dir setter syntax

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
AI-assisted-by: opencode-go/muse-spark-1.3-contributor

* 🐛 Fix comments and tests

---------

Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-09-17 14:09:32 +02:00
Alejandro Alonso
14c3135e21 Merge remote-tracking branch 'origin/main' into staging 2026-09-17 11:26:23 +02:00
Alonso Torres
420aa981b2 🐛 Fix stalled saving states (#11699) 2026-09-17 11:25:36 +02:00
Juan de la Cruz
9a11dfa7f9
✨ Replace PNG favicon with theme-aware SVG (#11640)
* ✨ Replace PNG favicon with theme-aware SVG

* 🎉 Add color changing favicon to docs page

---------

Co-authored-by: Eva Marco <evamarcod@gmail.com>
2026-09-16 08:36:12 +02:00
Andrey Antukh
df383be6b2 Merge remote-tracking branch 'origin/staging' into develop 2026-09-15 19:59:54 +02:00
Andrey Antukh
69111accfe 📎 Fix copyright owner on several files 2026-09-15 19:22:56 +02:00
Andrey Antukh
5c38dcab28 🌐 Validate and rehash translations 2026-09-15 19:21:59 +02:00
Anonymous
b8534245b9
🌐 Add translations for: French (Canada)
Currently translated at 96.6% (2410 of 2494 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-09-15 17:19:22 +00:00
Alexis Morin
1b46b288f4
🌐 Add translations for: French (Canada)
Currently translated at 96.6% (2410 of 2494 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-09-15 17:19:21 +00:00
VKing9
5e3ffd3345
🌐 Add translations for: Hindi
Currently translated at 78.7% (1965 of 2494 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/hi/
2026-09-15 17:19:19 +00:00
Late Night Defender
c035e4841f
🌐 Add translations for: Thai
Currently translated at 7.3% (183 of 2494 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/th/
2026-09-15 17:19:17 +00:00
AntonPalmqvist
2ef3487518
🌐 Add translations for: Swedish
Currently translated at 93.3% (2329 of 2494 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2026-09-15 17:19:14 +00:00