24466 Commits

Author SHA1 Message Date
Andrey Antukh
5abf66dbf0 🐛 Set a finite idle timeout on binfile transactions
Binfile v1/v3 import and the v3 export get-file step hold a single
transaction while the ZIP is decoded and the file data is realized.
Disabling idle_in_transaction_session_timeout left a stuck connection
holding locks (and a pool slot) forever.

Raise it to 30 minutes instead. The longest import/export observed in
production is under 8 minutes, so the limit still bounds a stuck
session. statement_timeout stays at 300 s.

Add set-transaction-idle-timeout and configure-import-transaction in
app.binfile.common, use them from both paths, and cover the helper
with a test.

AI-assisted-by: deepseek-v4.1-flash
2026-10-19 16:31:15 +02:00
Andrey Antukh
38a6961ff1
✨ Make keyboard shortcuts layout-independent (#12326)
* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)"

This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2.

* 🐛 Migrate mousetrap to key with sane modifiers

Finish the half-done mousetrap migration from code/keyCode to
key: canonical lowercase tokens on both bind and event sides so
modifiers match again, US-only shift remap and keypress legacy
removed, and AltGr phantom ctrl filtered on Windows.

Record punctuation positionally through getLayoutMap (American
fallback) so Shift+Period stores shift+. on every layout, while
letters and digits stay glyph-based for AZERTY. Drop dead 219
aliases and revert the stray CamelCase escape command.

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

* ♻️ Rewrite mousetrap as ESM class without behavior changes

Turn the 2012 constructor-plus-prototype shape into a class
with underscore-private methods, const tables and arrow
listeners. Drop dead _belongsTo, fold one-line prevent/stop
helpers into _fireCallback, and let handleKey absorb the old
_handleKey indirection. Public API and matching logic stay
identical; verified by existing tests plus a runtime smoke.

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

* 🎉 Add node:test suite for mousetrap shortcuts

Cover canonical key normalization, modifiers with AltGr,
bind arrays plus aliases keyup reset overwrite, sequences
with fake timers, and the full stopCallback matrix, all
through isolated instances and synthetic events. Hook the
zero-dependency suite into frontend/scripts/test for CI.

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

* 🐛 Record shortcuts by glyph instead of position

Drop the getLayoutMap positional layer and the
American fallback: punctuation now records event.key
lowercased, the same token Mousetrap dispatches, so
Shift combos match with and without a layout map.
Keeps the Numpad numN branch both sides share.
Updates the pinned positional tests and adds the
Spanish Shift-plus regression.

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

* 🐛 Resolve shifted digits to the digit itself

Shift+DigitN now resolves to the digit on every layout, so
shift+0 fires on QWERTY ()) as well as AZERTY (0), like the
old keyCode behavior. Applies in the recorder and Mousetrap;
unshifted digits and numpad are untouched. Also stages the
layout-map suite left untracked.

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

* 🐛 Cover punctuation defaults with layout twins

Some defaults only fired where their glyph is unshifted (+ dead
on US layouts, shift++ dead outside ES/DE, \ dead where it
needs AltGr). Bind the twin token per layout family and freeze
the display with :show-command. No tooltip or translation
changes.

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

* ✨ Warn when the keyboard layout API is missing

Shows a warning on the shortcuts settings page in browsers
without navigator.keyboard.getLayoutMap, where Shift with
punctuation may not fire. Uses a synchronous probe, English
and Spanish strings, and documents the layout limitations in
the user guide.

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

* 🐛 Fix hide-ui twins and qualify layout docs

The ctrl+alt+\ twin could never fire (the AltGr filter drops
Ctrl), so it goes away; alt+º covers Chrome on Spanish
layouts where AltGr+º resolves to º. The user guide now
states which claims need the layout map and which hold
everywhere.

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

---------

Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-19 16:08:47 +02:00
Pablo Alba
c7f16d3236
🐛 Keep accented letters in organization avatar initials (#12328)
get-initials used an ASCII-only [a-zA-Z] match, so names like Ábaco
and Órbita skipped the leading accented letter. Match Unicode letters
with \p{L} (CLJS RegExp + u/g flags) so org default avatars match the
expected first letter. Closes #12324.

AI-assisted-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:56:05 +02:00
Eva Marco
1c70b0ce2d
♻️ Use the DS icon in the workspace left header (#12432)
* ♻️ Use the DS icon in the workspace left header

The shared library badge of the left header used a deprecated icon; use
the DS library icon instead.

Drop the deprecated icons inside the save status dot. They rendered as
500px SVGs with no stroke, so they never showed: the dot is only its
background color, and its title keeps the status text. The Penpot logo is
brand art and stays.

Add a visual test that opens a shared file and shows the header with the
badge, then the saved status after drawing a shape.

AI-assisted-by: claude-opus-5-5

* ♻️ Move workspace left header styles off the deprecated styles

Inline the flex and ellipsis mixins, move spacing, sizes and borders to
DS tokens and logical properties, and resolve the color aliases to their
DS tokens. The status dot takes its color from a local variable. The
badge icon gets its color through a class instead of an svg selector.

The project name uses the DS headline-small typography: 12px instead of
11px. The file name matches title-small exactly.

Left with a TODO: the rename input font size and z-index have no DS
match, and the pending status orange (#f5a91b) has no DS color.

left_header.scss no longer imports refactor/.

AI-assisted-by: claude-opus-5-5

* ♻️ Resolve the TODOs left in the workspace left header styles

The status dot used the legacy warning orange #f5a91b, which drifted from
the DS one when both replaced #fe4811 a few days apart in early 2025. Use
the DS --color-accent-warning (#fe9c07), the orange of every other DS
warning, so the dot looks a little more orange while saving.

The rename input had z-index 20, which did nothing: the input is neither
positioned nor a flex or grid item. Remove it.

The rename input only set its font size and got the rest from the global
reset; it now uses the DS title-small typography, like the file name. Its
line-height goes from normal to 1.2, so it grows from 16 to 16.8px.

AI-assisted-by: claude-opus-5-5
2026-10-19 15:52:48 +02:00
David Barragán Merino
d6e1cb23e0
🐳 Serve Penpot and the Admin Console from a subpath (#12358)
* 🐳 Serve the frontend under the path of PENPOT_PUBLIC_URI

Requests below the path are rewritten to the root, so a reverse proxy can
forward them unchanged. The Admin Console keeps receiving the original path.

AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>

* 🐳 Strip the public path before proxying to the Admin Console

The Admin Console is served at the root of its own server and applies the
public path at runtime, so it needs a matching Admin Console image.

AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>

* 📚 Document subpath deployments in docker-compose.yaml

AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>

* 📚 Describe subpath deployments without proxy rewrites

The frontend now serves the subpath itself, so the reverse proxy must
forward it unchanged.

AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>

---------

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-19 15:49:41 +02:00
Eva Marco
e4da0527fc
♻️ Use DS buttons and icons in the grid layout options (#12431)
* ♻️ Use DS buttons and icons in the grid layout options

The "Edit grid" button of a grid layout and the "Exit" button of the grid
editor were raw buttons with the deprecated secondary style. Replace them
with DS secondary buttons; their text and actions stay the same. Drop the
"alt" attribute of "Edit grid": it is not valid on a button.

The expand and add buttons of the column and row headers are 52px parts of
a custom header, not DS buttons, so they stay raw; only their deprecated
icons move to the DS icon, as does the direction icon of each track.

Add a visual test that shows the grid layout options and the grid editor
with the column tracks expanded.

AI-assisted-by: claude-opus-5-5

* ♻️ Remove deprecated button styles from the grid layout options

"Edit grid" and "Exit" are now DS buttons, so their secondary placeholder
and typography go away; the classes only place them in the grid and center
their label. The track header and direction icons drop the deprecated icon
placeholder and get their colors through classes. The header buttons keep
their secondary and tertiary placeholders, since they stay raw.

AI-assisted-by: claude-opus-5-5
2026-10-19 15:49:14 +02:00
Eva Marco
3d6823eacd
♻️ Use DS icon buttons for the flex wrap and padding mode (#12428)
* ♻️ Use DS icon buttons for the flex wrap and padding mode

The wrap button of a flex layout and the button that shows the four
padding sides (flex and grid layouts) were raw buttons with deprecated
icons. Replace them with DS ghost icon buttons that show their state
through aria-pressed, with the tooltip above and to the left since they
sit at the right edge of the sidebar.

The wrap label is now always "Wrap": its title switched to "No wrap"
while pressed, which conflicts with the pressed state.

Add a visual test that selects a flex board and shows both buttons at
rest and pressed.

AI-assisted-by: claude-opus-5-5

* ♻️ Remove deprecated wrap and padding toggle styles

The wrap and padding mode buttons are now DS icon buttons, so their
tertiary button, icon and selected placeholders go away. The tooltip
wrappers take the same grid cells the buttons had.

AI-assisted-by: claude-opus-5-5
2026-10-19 15:48:59 +02:00
Eva Marco
398678e54c
♻️ Use a DS icon button for the layout element margin mode (#12423)
* ♻️ Use a DS icon button for the layout element margin mode

The button that switches a flex or grid element between simple and
multiple margins was a raw button with a deprecated icon. Replace it with
a DS ghost icon button that shows the multiple state through aria-pressed.
Its title moves to the label, shown in the DS tooltip above and to the
left since the button sits at the right edge of the sidebar. The grid
column class goes on the tooltip wrapper.

The legacy margin icons label the numeric inputs and stay for the numeric
inputs group; the radio buttons are out of scope.

Add a visual test that selects a flex element and shows both margin modes.

AI-assisted-by: claude-opus-5-5

* ♻️ Remove deprecated button styles from layout element options

The margin mode button is now a DS icon button, so its tertiary button,
icon and selected placeholders and the deprecated 32px height go away.
The class only places the tooltip wrapper in the third grid column.

The refactor/ import stays for %input-element, still used by the legacy
numeric inputs.

AI-assisted-by: claude-opus-5-5
2026-10-19 15:48:43 +02:00
Eva Marco
c345c7cbaa
♻️ Use a DS icon button to remove imported SVG attributes (#12382)
* ♻️ Use a DS icon button to remove imported SVG attributes

Replace the raw remove button of each imported SVG attribute with the DS
ghost icon button and the DS remove icon. The button had no title, so it
gets a new "Remove attribute" label, which the DS tooltip shows. Drop the
wrapper div that only held the button.

Add a visual test that selects a group imported from SVG so the section
shows.

AI-assisted-by: claude-opus-5-5

* ♻️ Move imported SVG attributes styles to DS tokens

The remove button is a DS icon button now, so .attr-actions and
.attr-action-btn with their button placeholders go away. Inline the flex
and ellipsis mixins, move spacing to DS tokens and logical properties,
sizes to px2rem and the title color alias to its DS token.

Use the DS body-small typography; its line-height is 1.3 instead of 1.4.
Drop the line clamp rules of the attribute name: its later display:
inline-block already overrode the -webkit-box they need.

The refactor/ import stays only for %input-element on the value input,
which moves with the inputs migration.

AI-assisted-by: claude-opus-5-5
2026-10-19 15:35:44 +02:00
Alejandro Alonso
655300e4d1
🐛 Include board drop shadow in multi-element raster export (#12440)
Headless raster/PDF used the board selrect so overflowing children stayed
cropped, but that also clipped the board's own drop shadow. Use
export_root_bounds: own strokes/shadows/blur without joining children.

Closes #12388
2026-10-19 15:29:40 +02:00
Belén Albeza
c99980327f
🐛 Resize fit-content flex boards while typing in v3 text editor (#12330)
* 🐛 Resize fit-content flex boards while typing in v3 text editor

Typing in an auto-width text did not resize its fit-content layout
parent until the editor closed. The v3 editor now previews the live
text size in the layout on each keystroke, also when the text sits
in a group.

* 🐛 Preview text layout when typing with a pending caret style

Both v3 editor commit paths share v3-update-editor-content.

* 🐛 Skip the layout preview for absolute texts

Absolute layout items do not affect their board.

* ⚡ Preview text layout only when the text size changes

Each preview propagates modifiers, so skip unchanged sizes.

* 🐛 Leave interactive render mode when the v3 editor closes

Finalize only ends it when it applies geometry.
2026-10-19 15:06:56 +02:00
Eva Marco
18ca25518e
♻️ Use a DS icon button to close the nudge amount modal (#12439)
* ♻️ Use a DS icon button to close the nudge amount modal

The close button of the nudge amount modal (main menu, Preferences) was a
raw button with a deprecated icon and no accessible name. Replace it with
a DS ghost icon button labeled "Close", shown in its DS tooltip. The class
that places it in the corner goes on the tooltip wrapper, so the tooltip
anchors to the button. The numeric inputs stay for the inputs group.

Add a visual test that opens the modal from the main menu.

AI-assisted-by: claude-opus-5-5

* ♻️ Move nudge modal styles off the deprecated styles

The close button is a DS icon button, so its placeholder goes away; it is
now 32px wide instead of 28px, and sits 4px from the edge instead of 6px
so the icon stays in the same place. Spacing, sizes and the color aliases
move to DS tokens and logical properties, the flex mixin is inlined, and
the label rule targets its class instead of the label element.

The title matches the DS headline-medium typography exactly. The content
and labels use body-large and the wrapper body-small, whose line-heights
differ by 0.1 from the legacy ones; the labels keep their 1.5 override,
so nothing visible changes.

refactor/ stays only for the shared modal and labeled input placeholders.

AI-assisted-by: claude-opus-5-5
2026-10-19 14:28:03 +02:00
Andrey Antukh
688c0fc398
🐛 Keep file_change revisions on their own deletion schedule (#12419)
* 🐛 Keep file_change revisions on their own deletion schedule

Restoring a file from the trash cleared the deletion mark on every
file_change row of that file, revisions included. A revision is born
already scheduled for deletion one hour later, and objects-gc only
looks at rows that carry a date, so unmarking one left it in the
table for ever: production had 358,517 of them across 3,614 files.
The file deletion cascade had the mirror problem: it rewrote the date
of rows that already had one, pushing a revision out to the trash
lifetime.

Restrict the restore to durable history and date only the rows that
have no date yet, so a revision keeps the schedule it was born with.

A pinning test covers the snapshot update path, which cannot reach a
revision because it resolves its row through a read filter. The
backend memories now carry the model of the three file_data classes
and this invariant, in place of the two superseded stubs.

Closes #12418

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

* 📚 Add autovacuum and Postgres exporter notes to Grafana memory

Record what this Grafana instance can and cannot show about autovacuum
so the next session does not have to re-derive it:

- The Postgres exporter scrapes the primary as well as the
  read-only replica, so the primary's pg_stat_activity and
  pg_settings_* are in Prometheus even though the SQL
  datasource cannot reach them.
- It publishes no pg_stat_progress_* collector, and
  pg_stat_activity_count has no backend_type label: autovacuum
  workers are the usename/application_name-less bucket with
  state=active. The launcher shows up as the AutoVacuumMain
  wait event, and it is absent on the replica.
- A metric the exporter does not publish needs a custom query
  block in its YAML queries file and a restart of the exporter.
- The Postgres server ships its own log to Loki (the job is
  postgresql, primary only), whose streams carry only filename,
  instance, job and level. The completed vacuum lines live
  there: a per-table history, not a view of what runs now.
- The PostgreSQL section now ends with the vacuum panels,
  with the Vacuum TXID age table last; an in-flight per-table
  view would need a pg_stat_progress_vacuum collector in the
  exporter itself.

AI-assisted-by: deepseek-v4.1-flash
2026-10-19 14:00:13 +02:00
Eva Marco
4c655e6e9e
🐛 Keep the picture in place in the SVG preview of an image crop (#12387)
With the SVG renderer, resizing a shape with an image fill and the mod
key showed the picture scaled during the drag, as in a normal resize.
The crop only showed after releasing the mouse.

The SVG preview applies the resize matrix to the shape node and ignores
the fill transform change carried by the modifiers. Now the pictures of
the shape fill patterns get a matrix that takes them from the old fill
transform to the new one, which undoes that resize and keeps them in
place. This works for shapes and boards.

Closes #12349

AI-assisted-by: claude-opus-5-5
2026-10-19 13:40:26 +02:00
Andrey Antukh
84d92cfd5a
✨ Add tail buckets and update-file validation timings (#12409)
* ✨ Extend backend histogram buckets beyond 7.5s

Give every backend timing histogram a tail past the shared 7500ms cap
so slow RPC calls, CLIMIT waits and background tasks report real
quantiles instead of clipping at the top bucket.

Request and wait metrics grow to 60s, background tasks to 300s and
Redis eval to 10s; the sub-7.5s buckets stay untouched to keep the
history comparable.

Closes #12408

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

* 🐛 Fix flaky assertion in climit republish test

Read the last published waiting value instead of the exact
publication list. The sampler thread started with the component
runs its first tick asynchronously, and when it lands inside the
recording window it publishes the same count twice, which made
the full-suite CI run fail while focused runs stayed green.

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

* ✨ Add file_data blob to JSON decode helper

Allow decoding file_data blobs (app.util.blob) to JSON
for investigations with jq, offline from a blob file or
from nREPL with the backend running.

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

* ✨ Add validation step timings to update-file logs and metrics

Measure the six phases inside process-changes-and-validate
(resolve-libs, process-changes, soft and hard affected and
schema validations) with tpoint and try/finally, so timings
are recorded even when a hard validation throws.

Publish each phase to a new histogram
penpot_rpc_update_file_validation_timing with a fixed step
label using the request buckets (up to 60000 ms), and extend
the update-file debug log with the same *-ms fields plus
file, project and team ids. The deferred log only runs on
success, so failures get an inline error log with partial
timings.

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

* 💄 Translate decode-file-data comments to English

Translate the Spanish docstrings, section headers and test
messages of the file_data decode helper to English, keeping
code untouched. The repo convention is English in code and
comments; the helper was first written in Spanish.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-19 13:01:35 +02:00
Elena Torró
c401151541
🐛 Fix mask group drop shadow in PDF export (#12362) 2026-10-19 12:07:51 +02:00
Andrey Antukh
5262aab3ff
🐛 Count saved versions in the version quota (#12405)
* 🐛 Count saved versions in the version quota

The snapshots-per-file and snapshots-per-team quotes counted the
saved versions by file_change.data, a column that only the legacy-db
file data backend writes: versions keep their payload in file_data,
so the count returned 0 and a configured quota never applied.

Count by label instead, which tells a version apart with every file
data backend, and add a partial index on file_change with the same
predicate so both counts are answered by an index only scan instead
of walking every row of every file of the team (10.3 seconds in
production for a team with 829 versions).

Closes #12404

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

* ♻️ Drop the ! suffix from the snapshot quota test helpers

The check helpers assert the outcome of a command instead of mutating
anything, so they do not take the ! that this codebase reserves for
functions whose contract changes state or runs a command (the
th/command! call inside them already carries it).

AI-assisted-by: deepseek-v4.1-flash
2026-10-19 11:15:51 +02:00
Eva Marco
f4f581ab74
🐛 Fix export scale dropdown clipped in Inspect (#12351)
* 🐛 Fix export scale dropdown clipped in Inspect

The export selects in the Inspect tab used a dropdown class that always
opened them upwards, overriding the select's own up/down choice. On
elements with few properties the export section sits near the top of
the panel, so the scale list (now eight options) went past the top of
the scroll area and hid 0.5x. Keep only the dropdown width and let the
select pick the direction.

Closes #12346

AI-assisted-by: claude-opus-5-5

* ♻️ Move Inspect exports styles to DS tokens

Replace the deprecated spacing and size variables, the flex mixins, the
color aliases and the typography mixins with DS tokens, and use logical
properties. The typographies switch to the closest DS ones: body-small
for the suffix input (line-height 1.3 instead of 1.4) and headline-small
for the export button (12px instead of 11px). The @extend placeholders
still come from refactor/ and are left for a later migration.

AI-assisted-by: claude-opus-5-5

* ♻️ Use DS buttons in Inspect exports

Replace the add, remove and export buttons in the Inspect exports section
with the DS icon-button and button, as the workspace export section does.
Delete their now unused styles and the sizes import; the export button
class only stretches the button and centers its label.

AI-assisted-by: claude-opus-5-5

* 🐛 Fix add export button lookup in the Inspect exports e2e test

The add export button in Inspect is a DS icon button now and lost its
.add-export class, so the test timed out waiting for it. Find it by its
role and accessible name instead.

AI-assisted-by: claude-opus-5-5
2026-10-19 10:50:32 +02:00
Eva Marco
a5678657f5
🐛 Fix padding on sets section (#12422) 2026-10-19 10:48:39 +02:00
Stephen Sagarino Jr.
8787937b7c
🐛 Truncate long labels in the shadow options panel (#12414)
Long translations of "Blur" and "Spread" ran over the number input in the
advanced shadow options. The label inherited display: flex from the legacy
%input-label placeholder, where text-overflow has no effect, and it could not
shrink below its text.

Make the label a block with the DS text-ellipsis mixin, size it to its text,
and cap it at the field width minus room for the number. Labels that fit look
the same as before; longer ones end in an ellipsis and keep their tooltip.

Closes #4456
2026-10-19 10:24:11 +02:00
makesomethingshit
91a5b3a9fb
🐛 Fix token tree path handling for repeated and nested segments (#11591)
* 🐛 Fix token tree collapsing repeated path segments

Tokens whose path contains the same segment name twice in a row
(e.g. test.pointer.pointer) collapsed to a single level in the
token tree.

build-tree-node marked a node as a leaf when the current segment
name matched the first segment of every remaining path. Because
group-by-first-segment rewrites nested paths into the segment
name, a still-nested remaining path was indistinguishable from a
token ending at the current node, so test.pointer.pointer merged
into one leaf.

Mark rewritten segments with an internal ::remaining? marker and
strip it when the rest-path is fully consumed; is-leaf? only
accepts unmarked remaining segments as leaves.

Closes #11588

AI-assisted-by: glm-5.3-flash
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>

* 🐛 Preserve ancestor tokens in path trees

Keep a node's token and descendants independently when imported paths
contain both an ancestor and a child. Render both in the token tree and
include the ancestor token in existing path-based group operations.

Cover accepted same-set imports, leaf IDs, repeated segments, and internal
marker removal without changing import or collision validation.

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

* ♻️ Rework token tree path building

Carry the segments left to descend as a ::remaining-path vector on each
segment and advance it by subvec, instead of rewriting the segment :name
and re-splitting it later.

With that, build-tree-node finds the leaf token with a predicate over
::remaining-path and takes children from the segments that still have
one, so the pre-grouping children-first sort no longer changes the
result. Drop sort-children-first and its segment-has-children? helper,
and document inside-path?.

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

* 🐛 Fix and relocate the token tree tests

The ancestor-and-descendant test called a function that does not exist
in app.common.types.tokens-lib, so the namespace failed to load and the
whole common suite errored. Use make-tokens-status-from-lib and the
two-argument get-tokens-in-active-sets instead.

Move the build-tree-root tests to path-names-tree-test so
path-names-test stays a pure split/join unit test.

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

* ♻️ Refactor the token tree rendering and share path helpers

Extract the token pill into a leaf-node* component and treat a node as a
leaf only when it holds a token and has no children, so a node can carry
both a token and its children.

Make token-separator public and reuse it from the token tree and the
management panel instead of local copies. Both management path filters
inlined the same ancestor-or-descendant rule; extract it as
token-in-path? and cover it with a unit test.

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

---------

Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: luisddm <luis.dedios@kaieldos.net>
2026-10-19 09:44:32 +02:00
Rafael X.
addc5bc31e
📚 Fix typo in incomplete user info hint (#12413)
Signed-off-by: Rafael X. <chocoladisco@gmail.com>
2026-10-18 22:29:18 +02:00
Andrey Antukh
49f746ddd8 📚 Never commit the plan file with the implementation
The implement-plan flow asked to commit the closed plan together with
the code, so the resulting commit looked like it was missing a piece.

State the rule in the three places that describe it: the implement-plan
skill, the commit workflow memory and the agents README. A plan is
session bookkeeping, not part of the change, and its absence from the
commit is correct rather than a missing piece.

AI-assisted-by: deepseek-v4.1-flash
2026-10-18 20:40:23 +02:00
Andrey Antukh
16ea1ab3cd 🔧 Ignore generated artifacts and local root files
Local scratch files and autogenerated build output were
showing up in git status:

- /*.html and /*.json cover local reports and token
  dumps at the repo root; !/package.json keeps the
  tracked root manifest visible to git.
- /media-processor/target/ and /scripts/__pycache__/
  cover build and Python cache output.
- render-wasm artifacts copied into the exporter go in
  their own commented block.

Record the exporter and render-wasm generated paths in
the module memories so nobody edits or commits them by
hand.

AI-assisted-by: deepseek-v4.1-flash
2026-10-18 18:01:22 +00:00
Andrey Antukh
49aeeb053b 🐛 Use multipart helper in jobs upload-chunk test
The multipart-only upload guard rejects the :content map sent by
upload-chunked! over plain command!. Send it through
multipart-command! like the other upload tests; forged-path tests
stay on plain command!.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-17 21:53:29 +00:00
Andrey Antukh
b633093336 Merge remote-tracking branch 'origin/staging' into develop 2026-10-17 16:28:59 +02:00
Andrey Antukh
70b317a976
🐛 Harden upload path handling and validation error bodies (#12067)
* 🚑 Reject forged upload paths outside multipart

Transit bodies can smuggle upload maps whose path points at any
server file; the schema only checks the shape and handlers open
the path directly.

app.rpc now injects a server-side multipart flag that bodies cannot
forge, and methods declare their upload keys with the new
multipart-only-params metadata, enforced centrally after schema
validation with a params-validation error. Covers all nine upload
methods, including chunked and management flows.

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

* 🐛 Sanitize raw explain in generic validation error bodies

The generic :validation branch returned ex-data untouched, so errors
carrying the raw malli explain (live schema objects, e.g. storage
invalid-storage-metadata) broke JSON encoding mid-stream and clients
got truncated bodies. Apply the same clean treatment the known codes
already use: drop the raw explain and attach its humanized text.

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

* 🐛 Add e2e path traversal tests for binfile and media upload

Add two e2e suites that hand-write transit bodies with a forged
["~#path", ...] upload path (no transit library needed) and expect
400 validation/params-validation. They cover import-binfile :file,
upload-file-media-object :content and upload-chunk :content, plus a
plain-JSON string-path control case. Before the multipart-path fix
they failed (500 io-exception, invalid-image, 200 stored chunk);
after it they pass.

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

* 🐛 Use multipart helper in binfile SVG test setup

The SVG import setup uploaded through plain command!, so the new
multipart-only guard rejected it with params-validation. Switch
the setup to multipart-command! like the other upload tests.

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

* 🐛 Use multipart helper in remaining upload tests

The multipart-only upload guard rejects plain RPC calls carrying
upload maps. Five tests with real files still used command! and
failed with params-validation. Switch them to multipart-command!
while keeping the forged-content tests on plain command!.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-17 16:01:04 +02:00
Andrey Antukh
15606a3f85 🐛 Answer 400 on a request body the parser cannot read
A body declared as JSON that clojure.data.json cannot read ended as a 500
:unexpected with the whole stack trace in the log: wrap-parse-request
classified the failure by exception class, and the library signals its
syntax errors with a bare java.lang.Exception.

Classify it where it happens instead: read-body-params reads the body and
raises :validation :malformed-json (400) for anything it cannot parse,
keeps RequestTooBigException at 413 and re-raises a structured error from
deeper untouched. The body is read outside the handler try, so the error
goes out as a throw like the other specific-exception branches.

The failure is now one log line (path, peer address, content-type, parser
message) without the stack trace, and it is counted: the middleware takes
the metrics instance as a middleware argument, so the backend exports
penpot_http_malformed_requests_total and nexus declares its own
nexus_http_malformed_requests_total.

Tests: parse-request-malformed-json-body reproduces the production body
and asserts the 400 and the counter; the existing parse-request tests now
pass a metrics stub.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 15:54:52 +02:00
Andrey Antukh
07e085c2cf :revind: Backport several updates for .github/workflows from develop 2026-10-17 14:55:22 +02:00
Andrey Antukh
9336962a77 ⏪ Backport docker changes from develop 2026-10-17 14:54:23 +02:00
Andrey Antukh
fcbc4f1769 🐛 Remove duplicate declarations in the typography sample
`.typography-sample-input` declared `font-size` and `line-height`
twice with the same values, so stylelint failed the frontend lint
with `declaration-block-no-duplicate-properties`.

The duplication came from an earlier staging into develop merge: one
side moved the pair next to its comment, the other kept it below
`block-size`, and the merge kept both. Drop the second pair.

No behaviour change: both pairs held the same values, and only the
last declaration of a block takes effect.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 12:53:13 +00:00
Andrey Antukh
8c5113b815 Merge remote-tracking branch 'origin/staging' into develop 2026-10-17 14:44:44 +02:00
penpot-renovate[bot]
1a68928aca
⬆️ Update DHI base images (#12399)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/imagemagick/Dockerfile)
- dhi.io/debian-base trixie-debian13-dev@7b1d652 (docker/devenv/Dockerfile)
- dhi.io/nginx 1.31.7-alpine3.24@6d3811f (docker/images/Dockerfile.storybook)
- dhi.io/nginx 1.31.7-debian13-dev@10c0037 (docker/images/Dockerfile.frontend)
- dhi.io/node 24.22.0@ (docker/devenv/Dockerfile)
- dhi.io/node 24.22.0@ (.nvmrc)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.media-processor)
- dhi.io/node 24.22.0-debian13@c6ddacf (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.0-debian13-dev@39f5547 (docker/images/Dockerfile.exporter)
2026-10-17 14:06:09 +02:00
penpot-renovate[bot]
53f83c98cf
⬆️ Update GitHub Actions (#12401)
- actions/checkout v7 (.github/workflows/commit-checker.yml)
- actions/download-artifact v8.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)
- actions/upload-artifact v7.0.2 (.github/workflows/tests-e2e.yml)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-10-17 14:01:55 +02:00
Andrey Antukh
231adf4edc ✨ Add a skill to rewrite a squashed commit message
The new skill turns HEAD's stacked subjects, bodies and repeated trailers
(a typical result of squashing) into one message that follows
mem:workflow/creating-commits: a single imperative subject, a body
covering the whole branch diff, then the trailers.

It collects every distinct AI-assisted-by value before rewriting, so an
attribution is never dropped, and it requires ./scripts/check-commit to
exit 0 after the amend. It never pushes: rewriting history over commits
the user already pushed is their call, not the agent's.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Andrey Antukh
016c3976d0 🔧 Install opencode on demand instead of baking it in
The opencode build stage was already commented out, so the image carried
a dead block and a commented COPY. Drop both: opencode now comes from
scripts/setup-opencode, which installs @opencode/cli as a global pnpm
package in the penpot user's $PNPM_HOME. That dir sits in the user-data
volume, so the install survives a container re-creation and needs no
root.

pnpm 12 keeps the global binaries in $PNPM_HOME/bin, so put that dir on
PATH in the bashrc (and make the guard check the same path) instead of
$PNPM_HOME, which left the commands unreachable.

The entrypoint now also chowns ~/.local, ~/.cache and the dotfiles it
copies from /root. EXTERNAL_UID remaps penpot to the host UID on every
start, so the ownership the image set at build time stops matching; that
broke global pnpm installs and pnpm's own packageManager download.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Andrey Antukh
d1aa36b814 ⬆️ Update pnpm to 12.14.0 and Node to v24.22.0
Bump the pnpm pin to 12.14.0 in the devenv and the production images
and stamp the same version into every package.json, realigning the
drift between the devenv (12.9.1) and the rest (12.6.0).

Bump Node to v24.22.0, the newest patch of the Krypton LTS line, in
.nvmrc, the devenv and image Dockerfiles and the plugins-deploy
workflows.

The pnpm arch checksums are those of the GitHub release tarballs, not
of the npm package. Lockfile changes are metadata only.

AI-assisted-by: deepseek-v4.1-flash
2026-10-17 14:00:43 +02:00
Marina López
cec8080351
🐛 Show trial status on subscription page (#12389) 2026-10-16 18:22:51 +02:00
Alonso Torres
02e10a005a
✨ Add token source to plugins (#12183)
* ✨ Add token source to plugins

* ✨ Changes after review

* ✨ Move plugins code to library-edit
2026-10-16 17:58:51 +02:00
Andrey Antukh
da1791bf00 📎 Disable temporary the storage gc job from cron 2.18.4-RC3 2026-10-16 15:57:32 +02:00
Andrey Antukh
d0f455d521 ⚡ Chunk storage-gc-deleted reads with a keyset cursor
The storage-gc-deleted task used to stream all the pending rows with
db/plan inside one long read-only transaction, blocking the table
vacuum for the whole run and generating bloat on long deletions.

Now every chunk of 25 objects is one short read-write transaction:
the SELECT reads and locks the next objects after a (deleted_at, id)
keyset cursor with FOR UPDATE SKIP LOCKED, deletes the database rows
grouped by backend and removes their backend content. Concurrent runs
never take the same chunk, and the double SELECT of the old lock-ids
step is gone.

A chunk that fails aborts its transaction: nothing of the chunk is
deleted, its locks are released, and the run skips it; the cursor
still advances to the last row read, so a failed chunk is not visited
again in the same run.

AI-assisted-by: glm-5.3-flash
2026-10-16 15:57:32 +02:00
alonso.torres
f23eb31bd7 ✨ Send pending edits when the user leaves the tab or window
Edits wait up to 3s in a buffer before they are sent. Closing the
laptop or switching away right after an edit could leave that batch
unsent, and a crash or a discarded tab then lost it.

The batch is now sent at once when the tab is hidden (another tab, a
minimized window, a closed tab or laptop) or the window loses focus
(another app or a plugin iframe). The 3s window itself is unchanged.
pagehide and beforeunload are left out: they come too late for an
async save, and the tab is already hidden by then.
2026-10-16 15:24:22 +02:00
alonso.torres
a3fc7c8465 ✨ Save before downloading a .penpot file and warn if behind
The .penpot download is built from the server copy. Users who
downloaded a file to keep their work while saves were failing got an
old or empty file, with no warning.

Opening the download dialog now sends the pending changes of the files
and waits up to 10s for them. If some are still not saved, the dialog
says the download will not include them.
2026-10-16 15:24:22 +02:00
alonso.torres
a1e51aacba 🐛 Keep the workspace open when an export fails
A failed export raised the exporter error to the global handler, which
replaced the workspace with the error page. Users then reloaded and
lost the changes that were not saved yet.

- A failed single or multiple export now shows an error toast and
  keeps the workspace. Exporter defects still report as unhandled.
- A failed multiple export request ends its progress listeners, so the
  leave-page warning it turned on is released.
- Before exporting, the pending changes of the exported files are sent
  and the export waits up to 10s for them, instead of 400ms. Exports
  are built from the server copy, so if some changes are still unsaved
  a toast warns that the export may not include them.

force-persist-files-and-wait waits only for the given files, so a stuck
save of another file does not delay the export.
2026-10-16 15:24:22 +02:00
alonso.torres
781ac534e0 ✨ Count save failures with a save-failed audit event
We only learn how often users lose work from support tickets. Each time
the user is told a save failed, persistence now emits a save-failed
audit event with the cause type, the code, whether the queue is still
retrying and how many changes are waiting.

It follows the same rule as the failure toast: a repeated failure of
the slow retry cycle is not counted again. Failures handed to another
handler, such as an expired session, are counted too, since they also
stop the save.
2026-10-16 15:24:22 +02:00
alonso.torres
4b668acf96 🐛 Retry saves that a busy backend refused or rolled back
A save waits for the lock that another save holds on the same file. A
slow save, such as one on a large file, blocks the other saves to that
file. The waiting request also holds the user's only save slot on the
backend, so the user's saves to other files wait behind it too.

Those saves ended in answers that stopped autosave for good:

- a 429 after 60s, when no save slot freed up in time;
- a 504 written by the backend after 300s, with :statement-timeout,
  when Postgres cancelled the wait for the lock.

In both cases the backend never applied the save, so sending it again
is safe. transient-error? now treats them as temporary, and the save
takes the retry path. A statement timeout takes 300s per attempt, so
the slow failure limit still bounds it. GET requests and error reports
are unchanged: the check lives in the save pipeline, not in
retryable-types.

Both answers were reproduced in the devenv by holding the file lock
from a separate database session.
2026-10-16 15:24:22 +02:00
Elena Torró
01aacde0a8
🐛 Refresh board bounds after toggling clip content in WASM SVG export (#12363) 2026-10-16 14:01:48 +02:00
Andrey Antukh
03134d35ae
🐛 Fix silent failure when uploading custom fonts (#12315)
* 🐛 Fix silent failure when uploading custom fonts

Custom font upload crashed with re-seq must match
against a string when opentype.js returned nil
subfamily names, leaving the user with no feedback.
Unknown signatures and unparseable files were also
silently dropped.

Make variant parsing nil-safe, harden entry
preparation for missing tables, report every
per-file failure with a bad-font notification, and
show a generic error if the stream itself fails.

Closes #12304

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

* ♻️ Rename font mtype helper and mark helpers private

Avoid confusion with app.worker.import/parse-mtype,
which detects binfile signatures. The font helper now
lives as parse-font-mtype, and the upload helpers only
used internally are private, with tests reaching them
through var references.

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

* 💄 Fix formatting in font upload tests

Realign map keys shifted by the var-quote prefix so
cljfmt check passes again. No behavior change.

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

* 🐛 Fix infer warnings in fonts metrics code

Add missing ^js type hints to the hhea and os2 metric lookups in
prepare-font so shadow-cljs can infer the interop targets and the
release build reports zero warnings.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-16 12:51:48 +02:00
Andrey Antukh
472ba8dde5 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:49:04 +02:00
Andrey Antukh
dfc55a7192 Merge remote-tracking branch 'origin/staging' into develop 2026-10-16 12:48:49 +02:00