23819 Commits

Author SHA1 Message Date
Andrey Antukh
d9e28fb1a4
🐛 Add authorization checks to WebSocket subscriptions (#11069)
Subscribe-file and subscribe-team handlers now verify the requesting
profile has read permissions on the target resource before creating
a subscription. Pointer-update handler now validates that the message
file-id matches the subscribed file-id before publishing.

Closes #11067

AI-assisted-by: mimo-v2.5-pro
2026-09-22 11:16:14 +02:00
Miguel de Benito Delgado
f0880cf3f9
🐛 Crop cached drop shadows to their filter bounds (#11801)
Snapshot only the scaled shadow bounds instead of the full reusable
filter surface. This avoids retaining viewport-sized textures for each
small shadow while keeping cached pixels valid when the surface is reused.

Use the bounded snapshot for both shape and frame shadow caches. Add
regression tests for rounding, scaling, surface limits, and snapshot reuse.

AI-assisted-by: gpt-6
2026-09-22 11:09:45 +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
5c22f5bfb7
⚡ Build the frontend bundle once for all E2E suites (#11792)
* ⚡ Build the frontend bundle once for all E2E suites

Merge tests-integration, tests-composable-suite and tests-plugin-api-suite
into one "CI: E2E" workflow. Each of the three ran its own full
frontend/scripts/build on every PR, so one PR paid the build three times.

The new build-bundle job restores actions/cache key frontend-bundle-<sha>,
runs frontend/scripts/build only on a miss and saves the key before the
job ends. The integration shards, the composable suite and the mocked
Plugin API suite now all need build-bundle and restore the same key with
fail-on-cache-miss, so none of them builds. A workflow re-run of the same
SHA reuses the cached bundle instead of rebuilding it.

Triggers become the union of the previous paths (frontend, common,
render-wasm, plugins): the bundle embeds the built plugins, so a plugins
change runs the whole set. workflow_dispatch keeps running the
integration job only, as before.

Job names are kept identical on purpose: they are the GitHub check
contexts and branch protection may match them by name.

Docs: new mem:frontend/e2e-ci-workflow records the build-once contract,
referenced from mem:frontend/core and mem:frontend/testing; the composable
memory and both suite READMEs are updated.

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

* 🐛 Fix mocked plugin suites crashing without frontend deps

The mocked CI drivers shelled out to frontend/scripts/e2e-server.js,
which imports express from frontend/node_modules. CI jobs install
only plugins/ deps, so the import failed with ERR_MODULE_NOT_FOUND
and the run timed out waiting for localhost:3000.

Serve the prebuilt bundle with a zero-dependency static server
built into each driver (ci/static-server.ts, kept in sync in both
suites) plus node:test coverage for it.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-22 10:23:15 +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
Andrey Antukh
e05747b546
✨ Restrict optional RPC ids to user-provided UUIDs (#11777)
* ✨ Restrict optional RPC ids to user-provided UUIDs

Add ::sm/user-provided-uuid, backed by a version and variant
aware regex that only accepts v4, v7 and v8 instances. Use it
for the optional :id of the creation RPC commands so reserved
versions such as v3 are rejected at validation time. Reads
such as get-team keep the lax ::sm/uuid. Cover the predicate
and the schema on both JVM and JS runtimes.

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

* ✨ Cover id version restriction at the RPC boundary

Add backend regression tests proving the seven creation commands
reject reserved-version ids (v3) with :params-validation and
accept v4 ids (plus v7/v8 on create-team) through the real
decode and validate path. Also drop two duplicated assertions
and document the version and variant of every fixture UUID in
user-provided-test.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-22 09:45:50 +02:00
Andrey Antukh
e34c618c3a Merge remote-tracking branch 'origin/staging' into develop 2026-09-22 08:44:45 +02:00
Álvaro Tejero-Cantero
d8fddf9942
♻️ Keep agent content on standard paths, drop client shims (#11719)
* ♻️ Keep agent content on standard paths, drop client shims

Agent guidance stays where the ecosystem reads it: AGENTS.md at the
root and the skills in .agents/skills. Codex scans .agents/skills and
follows symlinks, opencode lists it among its discovery locations, and
Cursor, Zed, Warp, Cline and Amp use the same project path.

Claude Code reads neither, so it needed CLAUDE.md and .claude/skills.
Committing those two paths is not a neutral default. Both are
gitignored, git treats ignored files as expendable, and a checkout
that needs the path deletes whatever a contributor keeps there without
a warning. Neither sparse-checkout nor skip-worktree offers a way out,
because both make the next pull abort. The two links are now created
by the contributor, once per clone, and documented in
.agents/README.md.

AGENTS.local.md and .agents/local/ give every client one personal
layer that composes on top of the shared one, instead of one file per
client inside the repository.

sync-workspace seeds the two links for ws1 and above, the way it
already seeds the gitignored config.js, so the devenv workflow does
not change.

AI-assisted-by: claude-opus-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* ♻️ Lean on Claude Code reading AGENTS.md, drop the pointer

Claude Code 2.1.277 reads AGENTS.md through the built-in agents-md
plugin, whose default mode loads it in a project that has no
instruction file of its own. Deleting the committed CLAUDE.md is what
turns that on here, so the pointer a Claude user had to create is no
longer part of the setup, and only the .claude/skills link remains:
the plugin covers instruction files, and skills still load from that
path alone.

The instruction that replaces the pointer is a removal. A CLAUDE.md,
.claude/CLAUDE.md or CLAUDE.local.md anywhere from the root down to
the working directory hands the project back to CLAUDE.md and hides
this repository's AGENTS.md, while .claude/rules/*.md and the
person's own ~/.claude/CLAUDE.md do not count and stay usable for
personal steering. CLAUDE.local.md joins the ignore list, and a hard
rule in AGENTS.md forbids all three.

The docs name the version, say the fallback has not reached Bedrock,
Vertex or Foundry, and say the stable auto-update channel runs about
a week behind the default one, so the symlink is documented as the
stopgap for those cases and as something to drop afterwards.

sync-workspace seeds the skills link alone for the same reason.

AI-assisted-by: claude-opus-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>

* 📎 Remove skills directory mention on AGENTS.md

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

---------

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-22 08:43:25 +02:00
David Barragán Merino
433f877449 🔧 Disable scheduled build for temporary environment 'tokens'
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-21 19:54:52 +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
b402637fe4 🐳 Allow extending the CSP directives without replacing the policy
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-18 19:05:37 +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
David Barragán Merino
3c19a026dc 🐛 Fix docker tags not promoted for already-built commits
The bundle and docker-image build/dedup checks used different cache
keys: the bundle was cached by ref name (`penpot-<gh_ref>.zip`) while
the docker image marker was cached by commit sha
(`markers/images-sha-<sha>`). A tag built from a commit already
promoted under another ref (e.g. `develop`) would rebuild the bundle
unnecessarily, while `build-docker`'s `promote` job silently inherited
the skip from `build` and never created that ref's branch tags
(`backend:<gh_ref>`, `frontend:<gh_ref>`, ...), even though the
underlying sha-tagged images already existed.

- Key the bundle S3 object by commit sha (`penpot-sha-<sha>.zip`)
  instead of by ref name, matching the docker marker's semantics.
- Drop the S3 metadata round-trip for `bundle_version` in
  build-docker.yml; compute it locally with `git describe`, same as
  build-bundle.yml (requires fetch-depth: 0 on that checkout).
- Split `promote` into two mutually-exclusive jobs, `promote` (needs
  `build` to succeed) and `retag` (needs only `prepare`, runs when
  `prepare.outputs.exists == 'true'`), each moving the `:<gh_ref>`
  branch tags to the current sha. This replaces relying on `build`'s
  skip/success state with two explicit conditions, so the tags always
  get moved regardless of which path built the images.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-18 15:25:33 +02:00
David Barragán Merino
76763ddd6d 🐛 Fix docker tags not promoted for already-built commits
The bundle and docker-image build/dedup checks used different cache
keys: the bundle was cached by ref name (`penpot-<gh_ref>.zip`) while
the docker image marker was cached by commit sha
(`markers/images-sha-<sha>`). A tag built from a commit already
promoted under another ref (e.g. `develop`) would rebuild the bundle
unnecessarily, while `build-docker`'s `promote` job silently inherited
the skip from `build` and never created that ref's branch tags
(`backend:<gh_ref>`, `frontend:<gh_ref>`, ...), even though the
underlying sha-tagged images already existed.

- Key the bundle S3 object by commit sha (`penpot-sha-<sha>.zip`)
  instead of by ref name, matching the docker marker's semantics.
- Drop the S3 metadata round-trip for `bundle_version` in
  build-docker.yml; compute it locally with `git describe`, same as
  build-bundle.yml (requires fetch-depth: 0 on that checkout).
- Split `promote` into two mutually-exclusive jobs, `promote` (needs
  `build` to succeed) and `retag` (needs only `prepare`, runs when
  `prepare.outputs.exists == 'true'`), each moving the `:<gh_ref>`
  branch tags to the current sha. This replaces relying on `build`'s
  skip/success state with two explicit conditions, so the tags always
  get moved regardless of which path built the images.

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-18 15:23:05 +02:00
Alma Faris
b89dffff95
📚 Fix broken Weblate profile links in THANKYOU.md (#11771)
Two entries in THANKYOU.md had the first part of the Weblate username
pasted in front of the URL (pablo.https://... and swapnil.https://...),
which makes GitHub render them as broken relative links. Point them at
the real profiles, https://hosted.weblate.org/user/pablo.alba and
https://hosted.weblate.org/user/swapnil.cx, which both exist.

Signed-off-by: Alma Faris <almazaf19@gmail.com>
2026-09-18 13:10:52 +02:00
Belén Albeza
0581c35452
🐛 Fix drop shadows in masks (#11754)
* 🐛 Fix shadows on a masked group in the WASM renderer

A masked group renders in two passes: its content, then the mask shape
composited with DstIn so everything outside the mask silhouette is
erased. Both happen inside one save_layer, and the group's drop shadow
was composited into that same layer before the mask pass — so the mask
erased it. A drop shadow lives mostly outside the silhouette, so it
disappeared entirely.

Inner shadows never drew at all: render_fill_inner_shadows needs fill
geometry to paint into, and a group has none.

Both now ride on an image filter set on the masked-group layer, which
Skia evaluates after the mask is composited, so the shadow comes from
the real masked pixels rather than the group's own, empty geometry.
The effects compose in the order the SVG renderer uses for a group:
drop shadows, then the source, then inner shadows, with the layer blur
over all of it.

That layer is opened on a canvas carrying no transform, so the filter
is built in device units. Shadow::scale_to_device does that rather
than scale_content, because radius_to_sigma is affine: scaling the
radius applies its constant term once at device scale, while a filter
built in document space has the term scaled by the canvas matrix. The
two would blur differently by 0.5 · (scale - 1) sigma, visible as a
masked group's own shadow being narrower than the same shadow on its
parent. The masked-group layer blur had the same flaw.

Three paths are suppressed for masked groups so nothing is drawn twice:
the silhouette composite, the nested_shadows inheritance that would
reach text descendants, and the fill inner-shadow pass.

Every save and restore around that layer is keyed on the shape alone.
Enter and exit run on different walker passes, and a pan or zoom in
between changes fast mode, so deriving them from render state could
leave the canvas clip stack unbalanced.

Refs #11697

AI-assisted-by: claude-opus-5

* 🐛 Fix a container's drop shadow over a masked group in WASM

A container builds its drop shadow by drawing each descendant as a
black silhouette and blurring the result. The walk descends only
through children that can be flattened, and a masked group never can,
so it stopped there and asked the group to draw its own geometry. A
group has none, so nothing was drawn and the shadow layer stayed
blank: no shadow at all for a group, board or frame holding a masked
group. This is what the file attached to the issue reproduces.

render_drop_black_shadow now draws the masked silhouette for such a
group — content children flat black, DstIn the mask, and only then the
offset, blur and spread. Masking after the blur would trim the shadow
along the wrong edge.

The walk recurses, so it narrows the clip the way the main walker
does: content a clipping container hides must not widen the shadow.
The clip rule now lives in one place, shared with the walker, and a
test pins the two against each other. The shadow layer is sized to the
silhouette plus the shadow's reach rather than falling back to the
clip, so a wide blur is not cut at the tile edge.

Spread keeps the renderer's existing behaviour: the silhouette goes
through the same get_drop_shadow_filter every other shadow uses, so a
masked group gains no ordering of its own.

Closes #11697

AI-assisted-by: claude-opus-5
2026-09-18 13:04:11 +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
Andrey Antukh
fe89e9e52c Merge remote-tracking branch 'origin/staging' into develop 2026-09-17 20:23:41 +02:00
Andrey Antukh
5b3e36489c 📚 Document int?/integer? predicate coverage in Clojure memory
Review assumed int? was 32-bit; it covers Long/Integer/Short/Byte.
Note it in mem:clojure/idioms so the mistake is not repeated.

AI-assisted-by: muse-spark-1.3-contributor
2.18.0-RC7
2026-09-17 20:23:22 +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
2cdfc912d8 Merge remote-tracking branch 'origin/staging' into develop 2026-09-17 11:33:50 +02:00
Alejandro Alonso
14c3135e21 Merge remote-tracking branch 'origin/main' into staging 2.18.0-RC6 2026-09-17 11:26:23 +02:00
Alonso Torres
420aa981b2 🐛 Fix stalled saving states (#11699) 2026-09-17 11:25:36 +02:00
Elena Torró
4c940296f1
🐛 Fix fixed-scroll on the viewer (#11752) 2026-09-17 09:43:13 +02:00
Elena Torró
cc9c60507d
🐛 Do not apply stroke style to caps (#11744) 2026-09-17 09:39:39 +02:00
Andrey Antukh
729cc5a828 Merge remote-tracking branch 'origin/staging' into develop 2026-09-16 20:25:12 +02:00
Andrey Antukh
9ae967c621 📚 Add plan status lifecycle and review log
Give every plan a write-restricted Status (draft, reviewed,
done) and an append-only Review Log with UTC ISO 8601 lines.

Make-a-plan creates plans as draft and is the only flow
writing reviewed, on explicit user apply. Review-plan stays
read-only. Implement-plan closes the plan to done with the
issue URL when one exists, in the same commit as the code.
Document the lifecycle in the agents README.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-16 18:12:12 +00:00
Andrey Antukh
ebba70ad2b 📚 Normalize plan followup and sub-plan naming
Define derived plan naming for .agents/plans/.

Parent basename stays intact and derivatives append
--review-NN for review followups and --task-NN for
roadmap sub-plans, with no new date so ls groups them.
Document the rule in the planner skill, the in-place
vs new-file policy in make-a-plan, and examples in
the agents README.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-16 18:00:55 +00:00
Andrey Antukh
30e52af22e 📎 Backport creating-issue serena memories from develop 2026-09-16 19:48:50 +02:00
David Barragán Merino
443622b2f9 🐳 Keep dist-upgrade unattended across the image builds
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-16 18:28:48 +02:00
David Barragán Merino
2e94361f62 🐳 Keep dist-upgrade unattended across the image builds
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-16 18:28:03 +02:00
Alejandro Alonso
20547f658e
🐛 Preserve tile coverage when packing atlas slots (#11749)
Packed atlas compose inset Linear samples after blitting the full
drawable into the slot, which dropped edge texels and opened
multi-pixel gaps on text that crossed tile seams (#11696), especially
under HiDPI packing. Write the drawable into the inset content rect
and clamp-pad the 1px frame so Linear compose keeps coverage without
bleeding into the next cell.

Closes #11696
2026-09-16 16:40:33 +02:00
Alejandro Alonso
fcae641c7b
🐛 Skip content clip on WASM SVG drop silhouettes (#11655)
Clipped frames (show-content=false) were applying clipPath to the drop
silhouette. Spread/offset fills were outset correctly but then truncated
to the true selrect, so spread rings vanished on export. Match the GPU:
paint drop silhouettes outside the content clip; keep clipping on the
real content pass only.

Closes #11653
2026-09-16 16:08:17 +02:00
Alejandro Alonso
b8af89c1b8
✨ Export text strokes to WASM SVG (#11578)
SkSVGDevice drops save_layer composition for text strokes, so emit
center strokes (with <g opacity> when alpha), inner strokes via glyph
clipPath, and outer strokes via inverse-glyph luminance mask.

Closes #11386
2026-09-16 15:54:07 +02:00